- run_resume falls back to a new conversation only when the transcript file is gone; other failures end at the menu with the recorded id intact. - The SessionStart hook resolves the session name under the registry lock; rename holds it across tmux rename-session and the registry move (dd_registry_locked, nestable). - dd_load_env parses KEY=value like systemd instead of sourcing the files. - devdesk-deploy: DPkg::Lock::Timeout, explicit FAILED lines on stdout and in the log; the hook answers synchronously and CI checks the HTTP status. logrotate for the deploy log. - postinst drops duplicate terminal-features entries; hooks sweep stale temp files. Closes #12 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
6.1 KiB
Bash
114 lines
6.1 KiB
Bash
# 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
|
||
}
|