Files
devdesk/libexec/devdesk-inbox.sh
T

114 lines
6.1 KiB
Bash
Raw Normal View History

# 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)
2026-10-11 01:01:51 -04:00
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
}