# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables # Answer a session without switching to it (devdesk#22, #23), sourced by # bin/devdesk and reached through `devdesk attach --answer|--peek|--reply`. dd_state_file() { printf '%s/%s.json\n' "$DEVDESK_STATUS_DIR" "$1" } # The Claude window's visible screen (not the scrollback). dd_claude_screen() { dd_tmux capture-pane -p -J -t "=$1:claude" 2>/dev/null } # --answer SESSION SINCE allow|deny: press the permission prompt's key in that # session's Claude window. Only when the same wait is still on (SINCE) and the # prompt is actually on screen, so a stale button never types into Claude. cmd_answer() { [ $# -eq 3 ] || dd_die "usage: devdesk attach --answer SESSION SINCE allow|deny" local session=$1 since=$2 choice=$3 file dd_valid_name "$session" || dd_die "invalid session name: $session" [[ $since =~ ^[0-9]+$ ]] || dd_die "bad wait id: $since" case $choice in allow | deny) ;; *) dd_die "answer allow or deny" ;; esac if ! { dd_server_up && dd_tmux has-session -t "=$session" 2>/dev/null; }; then dd_die "session '$session' isn't running"; fi file=$(dd_state_file "$session") if [ "$(jq -rM '"\(.state) \(.since) \(.request != null)"' "$file" 2>/dev/null)" != "waiting $since true" ]; then dd_die "nothing to answer: it was already answered" fi # Every permission prompt lists "1. Yes" first (Bash, edits, fetches, MCP). if ! dd_claude_screen "$session" | tail -n 40 | grep -Eq '^[[:space:]]*(❯[[:space:]]*)?1\.[[:space:]]+Yes'; then dd_die "the permission prompt isn't on screen any more" fi if [ "$choice" = allow ]; then dd_tmux send-keys -t "=$session:claude" 1 else dd_tmux send-keys -t "=$session:claude" Escape fi # A denial doesn't fire a hook; the turn ends there. An approval is followed # by PostToolUse, which takes it from here. local tmp tmp=$(mktemp "$DEVDESK_STATUS_DIR/.$session.XXXXXX") || return 0 if jq -cMn --arg s "$([ "$choice" = allow ] && echo working || echo idle)" --arg m "$([ "$choice" = allow ] && echo "Allowed from DevDesk" || echo "Denied from DevDesk")" \ '{state: $s, since: (now | floor), event: "DevDeskAnswer", message: $m}' >"$tmp"; then mv -f "$tmp" "$file" else rm -f "$tmp" fi } # The session's conversation file, from the id the SessionStart hook recorded. dd_transcript() { local id id=$(dd_registry_get "$1" claude_session_id) dd_convo_file "$id" } # --peek SESSION: what the session is doing and the last exchange, from the end # of its transcript (the last assistant text and the last thing you asked). cmd_peek() { [ $# -eq 1 ] || dd_die "usage: devdesk attach --peek SESSION" local session=$1 transcript status commands dd_valid_name "$session" || dd_die "invalid session name: $session" status=$(jq -cM . "$(dd_state_file "$session")" 2>/dev/null) || status=null # The last commands this session ran (devdesk#27). commands=$(tail -n 8 "$DEVDESK_STATE_DIR/commands/$session.jsonl" 2>/dev/null | jq -cMs '.' 2>/dev/null) || commands='[]' [ -n "$commands" ] || commands='[]' transcript=$(dd_transcript "$session") || transcript="" if [ -z "$transcript" ] || [ ! -r "$transcript" ]; then jq -cMn --arg s "$session" --argjson st "${status:-null}" --argjson c "$commands" '{session: $s, status: $st, assistant: null, user: null, commands: $c}' return fi tail -n 400 -- "$transcript" | jq -cMs --arg s "$session" --argjson st "${status:-null}" --argjson c "$commands" ' def text: if type == "string" then . else ([.[]? | select(.type == "text") | .text] | join("\n")) end; [.[] | select(.isSidechain != true and .isMeta != true)] as $all | ([$all[] | select(.type == "assistant") | {text: (.message.content | text), at: .timestamp} | select(.text != "")] | last) as $a | ([$all[] | select(.type == "user" and (.message.content | type == "string" or (any(.[]?; .type == "text")))) | {text: (.message.content | text), at: .timestamp} | select(.text != "" and (.text | startswith("<") | not))] | last) as $u | {session: $s, status: $st, assistant: (if $a then {text: ($a.text | .[-4000:]), at: $a.at, truncated: ($a.text | length > 4000)} else null end), user: (if $u then {text: ($u.text | .[-600:]), at: $u.at} else null end), commands: $c}' 2>/dev/null || jq -cMn --arg s "$session" --argjson st "${status:-null}" --argjson c "$commands" '{session: $s, status: $st, assistant: null, user: null, commands: $c}' } # --reply SESSION BYTES: type a message into the session's Claude prompt and # send it. The text arrives as base64 after the READY line (like uploads) and is # pasted as one bracketed paste, so newlines in it don't send it early. cmd_reply() { [ $# -eq 2 ] || dd_die "usage: devdesk attach --reply SESSION BYTES" local session=$1 size=$2 file tmp b64len dd_valid_name "$session" || dd_die "invalid session name: $session" if ! [[ $size =~ ^[0-9]+$ ]] || [ "$size" -lt 1 ] || [ "$size" -gt 16384 ]; then dd_die "a reply is 1 to 16384 bytes"; fi if ! { dd_server_up && dd_tmux has-session -t "=$session" 2>/dev/null; }; then dd_die "session '$session' isn't running"; fi file=$(dd_state_file "$session") if [ "$(jq -rM '"\(.state) \(.request != null)"' "$file" 2>/dev/null)" = "waiting true" ]; then dd_die "Claude is asking for a permission: answer that first" fi tmp=$(mktemp) trap 'rm -f "$tmp"' HUP INT TERM # shellcheck disable=SC2017 # integer ceil(size/3)*4: the base64 length, on purpose b64len=$(((size + 2) / 3 * 4)) stty raw -echo 2>/dev/null || true printf 'DEVDESK-REPLY-READY\r\n' head -c "$b64len" | base64 -d >"$tmp" 2>/dev/null || true stty sane 2>/dev/null || true if [ "$(stat -c %s "$tmp")" != "$size" ]; then rm -f "$tmp" dd_die "reply incomplete" fi dd_tmux load-buffer -b devdesk-reply "$tmp" && dd_tmux paste-buffer -p -d -b devdesk-reply -t "=$session:claude" && sleep 0.2 && dd_tmux send-keys -t "=$session:claude" Enter rm -f "$tmp" trap - HUP INT TERM }