feat: v0.8.1 answer sessions from the inbox: allow/deny, peek & reply
- PermissionRequest hook: the state file records tool, detail and description of a pending permission; Notification keeps it. - devdesk attach --answer (presses 1/Esc only if the same wait is on and the prompt is visible), --peek (last exchange from the transcript), --reply (bracketed paste + Enter, streamed like uploads). - Inbox: Allow/Deny/Open for permission waits; Peek & reply for other waits and finished sessions. Matrix messages include the command. Closes #22 Closes #23 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,7 +41,7 @@ install: all
|
|||||||
install -m 0755 libexec/devdesk-picker libexec/devdesk-claude-run libexec/devdesk-session-hook \
|
install -m 0755 libexec/devdesk-picker libexec/devdesk-claude-run libexec/devdesk-session-hook \
|
||||||
libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline \
|
libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline \
|
||||||
libexec/devdesk-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix $(DESTDIR)$(LIBDIR)/
|
libexec/devdesk-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix $(DESTDIR)$(LIBDIR)/
|
||||||
install -m 0644 libexec/devdesk-files.sh $(DESTDIR)$(LIBDIR)/devdesk-files.sh
|
install -m 0644 libexec/devdesk-files.sh libexec/devdesk-inbox.sh $(DESTDIR)$(LIBDIR)/
|
||||||
install -m 0755 build/ttyd $(DESTDIR)$(LIBDIR)/bin/ttyd
|
install -m 0755 build/ttyd $(DESTDIR)$(LIBDIR)/bin/ttyd
|
||||||
install -m 0644 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/
|
install -m 0644 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/
|
||||||
install -d $(DESTDIR)$(SHAREDIR)/assets
|
install -d $(DESTDIR)$(SHAREDIR)/assets
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
|
|||||||
- The sidebar shows it with context % and a context bar; the session chip's dot shows the current session's state.
|
- The sidebar shows it with context % and a context bar; the session chip's dot shows the current session's state.
|
||||||
- A "… needs you" chip jumps to other sessions that are waiting, and a desktop notification fires when one starts waiting.
|
- A "… needs you" chip jumps to other sessions that are waiting, and a desktop notification fires when one starts waiting.
|
||||||
- The **🔔 inbox** lists what needs you (waiting sessions, firing Grafana alerts) and what happened while you were away (a background session finished or started waiting, CI passed or failed); its badge counts them.
|
- The **🔔 inbox** lists what needs you (waiting sessions, firing Grafana alerts) and what happened while you were away (a background session finished or started waiting, CI passed or failed); its badge counts them.
|
||||||
|
- **Answer from the inbox:** a session waiting on a permission shows the exact command/file and **Allow / Deny** (the key is pressed only if that prompt is still on screen); other waits and finished sessions offer **Peek & reply**: the last exchange and a reply box.
|
||||||
- **Matrix "Claude needs you":** when a session has waited for `DEVDESK_MATRIX_DELAY` seconds (30) and no browser tab or SSH client is attached to it, `devdesk-notify-matrix` posts one message per wait, with a link, to the hookshot webhook URL in `/etc/devdesk/matrix-webhook` (mode 600; no file = off).
|
- **Matrix "Claude needs you":** when a session has waited for `DEVDESK_MATRIX_DELAY` seconds (30) and no browser tab or SSH client is attached to it, `devdesk-notify-matrix` posts one message per wait, with a link, to the hookshot webhook URL in `/etc/devdesk/matrix-webhook` (mode 600; no file = off).
|
||||||
- **File & image drop:** drag files onto the terminal or paste a screenshot.
|
- **File & image drop:** drag files onto the terminal or paste a screenshot.
|
||||||
- They're uploaded through ttyd to `/var/lib/devdesk/uploads/<session>/` (25 MB max, pruned after 7 days), and the path is pasted into Claude.
|
- They're uploaded through ttyd to `/var/lib/devdesk/uploads/<session>/` (25 MB max, pruned after 7 days), and the path is pasted into Claude.
|
||||||
|
|||||||
+16
@@ -19,6 +19,8 @@ set -euo pipefail
|
|||||||
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
|
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
|
||||||
# shellcheck source=libexec/devdesk-files.sh
|
# shellcheck source=libexec/devdesk-files.sh
|
||||||
. "$DEVDESK_LIBEXEC/devdesk-files.sh"
|
. "$DEVDESK_LIBEXEC/devdesk-files.sh"
|
||||||
|
# shellcheck source=libexec/devdesk-inbox.sh
|
||||||
|
. "$DEVDESK_LIBEXEC/devdesk-inbox.sh"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
sed -n '2,12s/^# \{0,1\}//p' "$0"
|
sed -n '2,12s/^# \{0,1\}//p' "$0"
|
||||||
@@ -325,6 +327,20 @@ cmd_attach() {
|
|||||||
dd_linger
|
dd_linger
|
||||||
return
|
return
|
||||||
;;
|
;;
|
||||||
|
--peek)
|
||||||
|
shift
|
||||||
|
cmd_peek "$@"
|
||||||
|
dd_linger
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
--answer | --reply)
|
||||||
|
local action=${1#--}
|
||||||
|
shift
|
||||||
|
"cmd_$action" "$@"
|
||||||
|
ok_json
|
||||||
|
dd_linger
|
||||||
|
return
|
||||||
|
;;
|
||||||
--new | --rename | --end)
|
--new | --rename | --end)
|
||||||
local action=${1#--}
|
local action=${1#--}
|
||||||
shift
|
shift
|
||||||
|
|||||||
Vendored
+20
@@ -1,3 +1,23 @@
|
|||||||
|
devdesk (0.8.1) bookworm; urgency=medium
|
||||||
|
|
||||||
|
* Answer sessions from the 🔔 inbox without switching to them:
|
||||||
|
- Allow / Deny (#22): a PermissionRequest hook records what Claude wants
|
||||||
|
to run (tool, command/file/URL, Claude's description); the inbox shows
|
||||||
|
it with Allow, Deny and Open. The key is pressed in that session's
|
||||||
|
Claude window only if the same wait is still on and the permission
|
||||||
|
prompt is on screen, so a stale button never types into Claude.
|
||||||
|
- Peek & reply (#23): the last thing you asked and Claude's latest answer
|
||||||
|
(from the session's transcript), and a reply box; the reply is pasted
|
||||||
|
as one bracketed paste and sent. Available for sessions that wait for
|
||||||
|
input and from "finished" entries.
|
||||||
|
- Matrix "needs you" messages include the command.
|
||||||
|
* New attach commands: --answer SESSION SINCE allow|deny, --peek SESSION,
|
||||||
|
--reply SESSION BYTES (text streamed like uploads).
|
||||||
|
* Sessions pick up the new PermissionRequest hook when Claude next starts
|
||||||
|
(/exit, then ⏎).
|
||||||
|
|
||||||
|
-- Jared Vititoe <jjvititoe1@gmail.com> Sat, 10 Oct 2026 20:22:37 -0400
|
||||||
|
|
||||||
devdesk (0.8.0) bookworm; urgency=medium
|
devdesk (0.8.0) bookworm; urgency=medium
|
||||||
|
|
||||||
* Edit and save files in the Files pane (#20):
|
* Edit and save files in the Files pane (#20):
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# 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)
|
||||||
|
[[ $id =~ ^[0-9a-f-]{36}$ ]] || return 1
|
||||||
|
compgen -G "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects/*/$id.jsonl" | head -n1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --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
|
||||||
|
dd_valid_name "$session" || dd_die "invalid session name: $session"
|
||||||
|
status=$(jq -cM . "$(dd_state_file "$session")" 2>/dev/null) || status=null
|
||||||
|
transcript=$(dd_transcript "$session") || transcript=""
|
||||||
|
if [ -z "$transcript" ] || [ ! -r "$transcript" ]; then
|
||||||
|
jq -cMn --arg s "$session" --argjson st "${status:-null}" '{session: $s, status: $st, assistant: null, user: null}'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
tail -n 400 -- "$transcript" | jq -cMs --arg s "$session" --argjson st "${status:-null}" '
|
||||||
|
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)}' 2>/dev/null ||
|
||||||
|
jq -cMn --arg s "$session" --argjson st "${status:-null}" '{session: $s, status: $st, assistant: null, user: null}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# --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
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ attached=$(dd_tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev
|
|||||||
[ "${attached:-0}" = 0 ] || exit 0
|
[ "${attached:-0}" = 0 ] || exit 0
|
||||||
|
|
||||||
message=$(jq -rM '.message // "Claude is waiting for your input"' "$state_file" 2>/dev/null)
|
message=$(jq -rM '.message // "Claude is waiting for your input"' "$state_file" 2>/dev/null)
|
||||||
|
detail=$(jq -rM '.request.detail // empty' "$state_file" 2>/dev/null)
|
||||||
|
[ -z "$detail" ] || message="$message: ${detail:0:300}"
|
||||||
url=""
|
url=""
|
||||||
# existing=1: if the session was ended by the time you tap, say so instead of
|
# existing=1: if the session was ended by the time you tap, say so instead of
|
||||||
# starting a new Claude there (devdesk#38).
|
# starting a new Claude there (devdesk#38).
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
# Claude Code hook (wired in via claude-settings.json) that tracks what Claude is
|
# Claude Code hook (wired in via claude-settings.json) that tracks what Claude is
|
||||||
# doing in each DevDesk session, for the sidebar's at-a-glance status:
|
# doing in each DevDesk session, for the sidebar's at-a-glance status:
|
||||||
# UserPromptSubmit, PostToolUse -> working
|
# UserPromptSubmit, PostToolUse -> working
|
||||||
|
# PermissionRequest -> waiting, with what Claude wants to run
|
||||||
|
# (tool + command/file/URL), for the inbox's
|
||||||
|
# Allow/Deny (devdesk#22)
|
||||||
# Notification -> waiting (Claude needs input or a permission)
|
# Notification -> waiting (Claude needs input or a permission)
|
||||||
# Stop, SessionStart -> idle
|
# Stop, SessionStart -> idle
|
||||||
# SessionEnd -> exited
|
# SessionEnd -> exited
|
||||||
@@ -35,7 +38,7 @@ PostToolUse:Edit | PostToolUse:Write | PostToolUse:MultiEdit | PostToolUse:Noteb
|
|||||||
esac
|
esac
|
||||||
case $event in
|
case $event in
|
||||||
UserPromptSubmit | PostToolUse) state=working ;;
|
UserPromptSubmit | PostToolUse) state=working ;;
|
||||||
Notification) state=waiting ;;
|
Notification | PermissionRequest) state=waiting ;;
|
||||||
Stop | SessionStart) state=idle ;;
|
Stop | SessionStart) state=idle ;;
|
||||||
SessionEnd) state=exited ;;
|
SessionEnd) state=exited ;;
|
||||||
*) exit 0 ;;
|
*) exit 0 ;;
|
||||||
@@ -47,19 +50,33 @@ file="$dir/$session.json"
|
|||||||
if [ "$state" != waiting ] && [ -f "$file" ] && grep -q "\"state\":\"$state\"" "$file"; then
|
if [ "$state" != waiting ] && [ -f "$file" ] && grep -q "\"state\":\"$state\"" "$file"; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
# What a permission prompt is about. tool_use_id is null in PermissionRequest,
|
||||||
|
# so a wait is identified by its `since` instead.
|
||||||
|
request=null
|
||||||
|
if [ "$event" = PermissionRequest ]; then
|
||||||
|
request=$(jq -cM '(.tool_input // {}) as $i | {tool: (.tool_name // "a tool"),
|
||||||
|
detail: (($i.command // $i.file_path // $i.notebook_path // $i.url // $i.pattern // $i.query // $i.path // ($i | tostring)) | tostring | .[0:600]),
|
||||||
|
description: ($i.description // null)}' <<<"$input" 2>/dev/null) || request=null
|
||||||
|
message="Claude wants to use ${tool:-a tool}"
|
||||||
|
fi
|
||||||
# A second Notification during the same wait updates the message but keeps
|
# A second Notification during the same wait updates the message but keeps
|
||||||
# `since`, so the wait stays one wait (one Matrix message at most).
|
# `since` (and what the wait is about), so the wait stays one wait (one Matrix
|
||||||
|
# message at most).
|
||||||
since=$(date +%s)
|
since=$(date +%s)
|
||||||
was_waiting=0
|
was_waiting=0
|
||||||
if [ "$state" = waiting ] && grep -q '"state":"waiting"' "$file" 2>/dev/null; then
|
if [ "$state" = waiting ] && grep -q '"state":"waiting"' "$file" 2>/dev/null; then
|
||||||
was_waiting=1
|
was_waiting=1
|
||||||
since=$(jq -rM '.since // empty' "$file" 2>/dev/null) || since=$(date +%s)
|
since=$(jq -rM '.since // empty' "$file" 2>/dev/null) || since=$(date +%s)
|
||||||
[[ $since =~ ^[0-9]+$ ]] || since=$(date +%s)
|
[[ $since =~ ^[0-9]+$ ]] || since=$(date +%s)
|
||||||
|
if [ "$request" = null ]; then
|
||||||
|
request=$(jq -cM '.request // null' "$file" 2>/dev/null) || request=null
|
||||||
|
[ "$request" = null ] || message=$(jq -rM '.message // empty' "$file" 2>/dev/null)
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
mkdir -p "$dir" 2>/dev/null || exit 0
|
mkdir -p "$dir" 2>/dev/null || exit 0
|
||||||
tmp=$(mktemp "$dir/.$session.XXXXXX" 2>/dev/null) || exit 0
|
tmp=$(mktemp "$dir/.$session.XXXXXX" 2>/dev/null) || exit 0
|
||||||
if jq -cMn --arg s "$state" --arg m "$message" --arg e "$event" --argjson t "$since" \
|
if jq -cMn --arg s "$state" --arg m "$message" --arg e "$event" --argjson t "$since" --argjson r "${request:-null}" \
|
||||||
'{state: $s, since: $t, event: $e, message: (if $m == "" then null else $m end)}' >"$tmp"; then
|
'{state: $s, since: $t, event: $e, message: (if $m == "" then null else $m end)} + (if $r then {request: $r} else {} end)' >"$tmp"; then
|
||||||
mv -f "$tmp" "$file"
|
mv -f "$tmp" "$file"
|
||||||
else
|
else
|
||||||
rm -f "$tmp"
|
rm -f "$tmp"
|
||||||
|
|||||||
@@ -73,6 +73,17 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"PermissionRequest": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "/usr/lib/devdesk/devdesk-state-hook",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"statusLine": {
|
"statusLine": {
|
||||||
|
|||||||
@@ -675,3 +675,68 @@ fs_write() { # SESSION PATH BASE CONTENT-FILE
|
|||||||
[ "$status" -ne 0 ]
|
[ "$status" -ne 0 ]
|
||||||
[ "$(cat "$p/docs/notes/new.md")" = hello ]
|
[ "$(cat "$p/docs/notes/new.md")" = hello ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "permission requests are recorded and answered only while their prompt shows (#22)" {
|
||||||
|
start_server
|
||||||
|
devdesk new alpha
|
||||||
|
claude_args alpha >/dev/null
|
||||||
|
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
|
||||||
|
hook() { TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; }
|
||||||
|
echo '{"hook_event_name":"PermissionRequest","tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/x","description":"Remove x"}}' | hook
|
||||||
|
echo '{"hook_event_name":"Notification","message":"Claude needs your permission","notification_type":"permission_prompt"}' | hook
|
||||||
|
st="$DEVDESK_STATE_DIR/state/alpha.json"
|
||||||
|
[ "$(jq -r '[.state, .request.tool, .request.detail, .request.description, .message] | @tsv' "$st")" = \
|
||||||
|
"$(printf 'waiting\tBash\trm -rf /tmp/x\tRemove x\tClaude wants to use Bash')" ]
|
||||||
|
since=$(jq -r .since "$st")
|
||||||
|
# The fake Claude window shows no prompt: refused, nothing typed.
|
||||||
|
run devdesk attach --answer alpha "$since" allow
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ $output == *"isn't on screen"* ]]
|
||||||
|
# Put a prompt on screen (what Claude draws), then allow.
|
||||||
|
tmux -S "$DEVDESK_SOCKET" respawn-window -k -t =alpha:claude "printf ' Do you want to proceed?\n ❯ 1. Yes\n 2. No\n'; read -r -n1 k; echo \"got:\$k\" > '$TMP/key'; sleep 30"
|
||||||
|
sleep 0.5
|
||||||
|
run devdesk attach --answer alpha 999 allow
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ $output == *"already answered"* ]]
|
||||||
|
run devdesk attach --answer alpha "$since" allow
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
sleep 0.5
|
||||||
|
[ "$(cat "$TMP/key")" = "got:1" ]
|
||||||
|
[ "$(jq -r .state "$st")" = working ]
|
||||||
|
# Answered: a second click is refused.
|
||||||
|
run devdesk attach --answer alpha "$since" deny
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "peek shows the last exchange; reply pastes into the Claude window (#23)" {
|
||||||
|
start_server
|
||||||
|
devdesk new alpha
|
||||||
|
claude_args alpha >/dev/null
|
||||||
|
export CLAUDE_CONFIG_DIR="$TMP/claude"
|
||||||
|
id=11111111-2222-3333-4444-555555555555
|
||||||
|
mkdir -p "$CLAUDE_CONFIG_DIR/projects/-x"
|
||||||
|
cat >"$CLAUDE_CONFIG_DIR/projects/-x/$id.jsonl" <<'JSONL'
|
||||||
|
{"type":"user","message":{"role":"user","content":"Please upgrade PBS"},"timestamp":"2026-10-10T10:00:00Z"}
|
||||||
|
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{}}]},"timestamp":"2026-10-10T10:00:05Z"}
|
||||||
|
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"ok"}]},"timestamp":"2026-10-10T10:00:06Z"}
|
||||||
|
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"PBS is on 4.1. Reboot now?"}]},"timestamp":"2026-10-10T10:00:09Z"}
|
||||||
|
JSONL
|
||||||
|
jq --arg id "$id" '.sessions.alpha.claude_session_id = $id' "$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json"
|
||||||
|
run devdesk attach --peek alpha
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(jq -r '[.assistant.text, .user.text] | @tsv' <<<"$output")" = "$(printf 'PBS is on 4.1. Reboot now?\tPlease upgrade PBS')" ]
|
||||||
|
# reply: lands in the Claude window as one paste + Enter
|
||||||
|
tmux -S "$DEVDESK_SOCKET" respawn-window -k -t =alpha:claude "IFS= read -r line; printf '%s' \"\$line\" > '$TMP/got'; sleep 30"
|
||||||
|
sleep 0.3
|
||||||
|
printf 'yes, reboot it' >"$TMP/msg"
|
||||||
|
run bash -c "base64 -w0 '$TMP/msg' | '$REPO/bin/devdesk' attach --reply alpha 14"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
sleep 0.5
|
||||||
|
[ "$(cat "$TMP/got")" = "yes, reboot it" ]
|
||||||
|
# not while a permission prompt waits
|
||||||
|
mkdir -p "$DEVDESK_STATE_DIR/state"
|
||||||
|
printf '{"state":"waiting","since":1,"request":{"tool":"Bash"}}\n' >"$DEVDESK_STATE_DIR/state/alpha.json"
|
||||||
|
run bash -c "base64 -w0 '$TMP/msg' | '$REPO/bin/devdesk' attach --reply alpha 14"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ $output == *"answer that first"* ]]
|
||||||
|
}
|
||||||
|
|||||||
@@ -215,6 +215,21 @@ async function uploadNow(session, file, onProgress) {
|
|||||||
return result.path;
|
return result.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Answer a session from the inbox (devdesk#22, #23).
|
||||||
|
export function answerPrompt(session, since, choice) {
|
||||||
|
return sessionAction('answer', session, String(since), choice);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function peekSession(session) {
|
||||||
|
return parseListOutput(await runCommand(['--peek', session], 15000));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replyTo(session, text) {
|
||||||
|
const bytes = new TextEncoder().encode(text);
|
||||||
|
const result = await limited(() => streamIn(['--reply', session, String(bytes.length)], 'DEVDESK-REPLY-READY', new Blob([bytes])));
|
||||||
|
if (!result.ok) throw new Error('not sent');
|
||||||
|
}
|
||||||
|
|
||||||
// Save a file from the editor (devdesk#20): `--fs-write SESSION PATH BYTES BASE`
|
// Save a file from the editor (devdesk#20): `--fs-write SESSION PATH BYTES BASE`
|
||||||
// with the content streamed after the ready line. Resolves the server's JSON:
|
// with the content streamed after the ready line. Resolves the server's JSON:
|
||||||
// {ok: true, sha, size, mtime} or {ok: false, conflict: true, sha} when the file
|
// {ok: true, sha, size, mtime} or {ok: false, conflict: true, sha} when the file
|
||||||
|
|||||||
+110
-4
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
import { el } from './dom.js';
|
import { el } from './dom.js';
|
||||||
import { ago } from './sessions.js';
|
import { ago } from './sessions.js';
|
||||||
|
import { answerPrompt, peekSession, replyTo } from './connection.js';
|
||||||
|
import { toast } from './ui.js';
|
||||||
|
|
||||||
const KEY = 'devdesk.inbox';
|
const KEY = 'devdesk.inbox';
|
||||||
const MAX = 30;
|
const MAX = 30;
|
||||||
@@ -65,6 +67,8 @@ export class Inbox {
|
|||||||
if (open) this.panel.removeAttribute('aria-hidden');
|
if (open) this.panel.removeAttribute('aria-hidden');
|
||||||
else this.panel.setAttribute('aria-hidden', 'true');
|
else this.panel.setAttribute('aria-hidden', 'true');
|
||||||
this.button.setAttribute('aria-expanded', String(open));
|
this.button.setAttribute('aria-expanded', String(open));
|
||||||
|
if (!open) this.peeking = null;
|
||||||
|
this.panel.classList.toggle('is-peek', false);
|
||||||
if (open) this.render();
|
if (open) this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +117,84 @@ export class Inbox {
|
|||||||
if (this.isOpen) this.renderPanel();
|
if (this.isOpen) this.renderPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ answer from here
|
||||||
|
|
||||||
|
async answer(session, since, choice, button) {
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
await answerPrompt(session, since, choice);
|
||||||
|
toast.success(choice === 'allow' ? `Allowed in ${session}` : `Denied in ${session}`);
|
||||||
|
this.hooks.refresh?.();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`${session}: ${error.message}`);
|
||||||
|
this.hooks.refresh?.();
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peek: the last exchange of a session, and a reply box.
|
||||||
|
async peek(session) {
|
||||||
|
this.peeking = session;
|
||||||
|
this.panel.classList.add('is-peek');
|
||||||
|
const body = el('div', { class: 'dd-peek' }, el('div', { class: 'dd-notif-empty' }, 'Loading…'));
|
||||||
|
this.list.replaceChildren(body);
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await peekSession(session);
|
||||||
|
} catch (error) {
|
||||||
|
body.replaceChildren(el('div', { class: 'dd-notif-empty' }, error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.peeking !== session) return;
|
||||||
|
const input = el('textarea', { class: 'lt-input dd-peek-input', rows: '3', placeholder: `Reply to ${session}… (Ctrl+Enter sends)`, 'aria-label': `Reply to ${session}` });
|
||||||
|
const send = el('button', { type: 'button', class: 'lt-btn lt-btn-primary lt-btn-sm' }, 'Send');
|
||||||
|
const submit = async () => {
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
send.disabled = true;
|
||||||
|
try {
|
||||||
|
await replyTo(session, text);
|
||||||
|
toast.success(`Sent to ${session}`);
|
||||||
|
this.peeking = null;
|
||||||
|
this.setOpen(false);
|
||||||
|
this.hooks.refresh?.();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`${session}: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
send.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
send.addEventListener('click', submit);
|
||||||
|
input.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
|
||||||
|
event.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
event.stopPropagation(); // keys belong to the reply box, not the terminal or shortcuts
|
||||||
|
});
|
||||||
|
const status = data.status;
|
||||||
|
const waitingRequest = status?.state === 'waiting' && status.request;
|
||||||
|
body.replaceChildren(
|
||||||
|
el('div', { class: 'dd-peek-head' },
|
||||||
|
el('button', { type: 'button', class: 'dd-peek-back', onclick: () => { this.peeking = null; this.panel.classList.remove('is-peek'); this.renderPanel(); } }, '←'),
|
||||||
|
el('span', { class: 'dd-peek-title' }, session),
|
||||||
|
el('span', { class: 'dd-peek-state' }, status?.state ? `${status.state}${status.since ? ` · ${ago(status.since)}` : ''}` : '')),
|
||||||
|
data.user ? el('div', { class: 'dd-peek-user' }, el('span', { class: 'dd-peek-label' }, 'You'), data.user.text) : null,
|
||||||
|
el('div', { class: 'dd-peek-assistant' },
|
||||||
|
el('span', { class: 'dd-peek-label' }, 'Claude'),
|
||||||
|
data.assistant ? `${data.assistant.truncated ? '…' : ''}${data.assistant.text}` : 'Nothing yet.'),
|
||||||
|
waitingRequest
|
||||||
|
? el('div', { class: 'dd-notif-empty' }, 'Claude is asking for a permission: answer it from the list, or open the session.')
|
||||||
|
: el('div', { class: 'dd-peek-reply' }, input, el('div', { class: 'dd-peek-actions' },
|
||||||
|
el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: () => { this.setOpen(false); this.hooks.openSession(session); } }, 'Open session'),
|
||||||
|
send)));
|
||||||
|
if (!waitingRequest) input.focus();
|
||||||
|
body.querySelector('.dd-peek-assistant').scrollTop = 1e9;
|
||||||
|
}
|
||||||
|
|
||||||
renderPanel() {
|
renderPanel() {
|
||||||
|
if (this.peeking) return; // the peek view stays until you go back
|
||||||
const close = (run) => () => {
|
const close = (run) => () => {
|
||||||
this.setOpen(false);
|
this.setOpen(false);
|
||||||
run();
|
run();
|
||||||
@@ -136,11 +217,31 @@ export class Inbox {
|
|||||||
if (this.waiting.length) {
|
if (this.waiting.length) {
|
||||||
nodes.push(el('div', { class: 'dd-notif-section' }, 'Needs you'));
|
nodes.push(el('div', { class: 'dd-notif-section' }, 'Needs you'));
|
||||||
for (const s of this.waiting) {
|
for (const s of this.waiting) {
|
||||||
nodes.push(item({
|
const request = s.status?.request;
|
||||||
|
const node = item({
|
||||||
dot: 'red', unread: true, title: `${s.name}: ${s.status?.message || 'Claude is waiting for your input'}`,
|
dot: 'red', unread: true, title: `${s.name}: ${s.status?.message || 'Claude is waiting for your input'}`,
|
||||||
meta: s.status?.since ? `waiting ${ago(s.status.since)}` : 'waiting',
|
meta: s.status?.since ? `waiting ${ago(s.status.since)}` : 'waiting',
|
||||||
onclick: close(() => this.hooks.openSession(s.name)),
|
onclick: close(() => this.hooks.openSession(s.name)),
|
||||||
}));
|
});
|
||||||
|
const stop = (run) => (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
run(event.currentTarget);
|
||||||
|
};
|
||||||
|
const body = node.querySelector('.lt-notif-item-body');
|
||||||
|
if (request) {
|
||||||
|
body.append(
|
||||||
|
request.description ? el('div', { class: 'dd-request-desc' }, request.description) : null,
|
||||||
|
el('code', { class: 'dd-request' }, request.detail || request.tool),
|
||||||
|
el('div', { class: 'dd-request-actions' },
|
||||||
|
el('button', { type: 'button', class: 'lt-btn lt-btn-primary lt-btn-sm', onclick: stop((b) => this.answer(s.name, s.status.since, 'allow', b)) }, 'Allow'),
|
||||||
|
el('button', { type: 'button', class: 'lt-btn lt-btn-danger lt-btn-sm', onclick: stop((b) => this.answer(s.name, s.status.since, 'deny', b)) }, 'Deny'),
|
||||||
|
el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: stop(() => { this.setOpen(false); this.hooks.openSession(s.name); }) }, 'Open')));
|
||||||
|
} else {
|
||||||
|
body.append(el('div', { class: 'dd-request-actions' },
|
||||||
|
el('button', { type: 'button', class: 'lt-btn lt-btn-sm', onclick: stop(() => this.peek(s.name)) }, 'Peek & reply'),
|
||||||
|
el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: stop(() => { this.setOpen(false); this.hooks.openSession(s.name); }) }, 'Open')));
|
||||||
|
}
|
||||||
|
nodes.push(node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.alerts.length) {
|
if (this.alerts.length) {
|
||||||
@@ -160,7 +261,7 @@ export class Inbox {
|
|||||||
const kind = KINDS[e.kind] ?? { tone: 'cyan' };
|
const kind = KINDS[e.kind] ?? { tone: 'cyan' };
|
||||||
// An entry about a session that has since ended must not bring it back (devdesk#38).
|
// An entry about a session that has since ended must not bring it back (devdesk#38).
|
||||||
const ended = !e.url && e.session && this.known && !this.known.has(e.session);
|
const ended = !e.url && e.session && this.known && !this.known.has(e.session);
|
||||||
nodes.push(item({
|
const recentNode = item({
|
||||||
dot: e.read || ended ? 'read' : kind.tone, unread: !e.read && !ended, title: e.text,
|
dot: e.read || ended ? 'read' : kind.tone, unread: !e.read && !ended, title: e.text,
|
||||||
meta: `${when(e.at)}${ended ? ' · session ended' : ''}`,
|
meta: `${when(e.at)}${ended ? ' · session ended' : ''}`,
|
||||||
onclick: close(() => {
|
onclick: close(() => {
|
||||||
@@ -170,7 +271,12 @@ export class Inbox {
|
|||||||
else if (ended) this.hooks.ended?.(e.session);
|
else if (ended) this.hooks.ended?.(e.session);
|
||||||
else if (e.session) this.hooks.openSession(e.session);
|
else if (e.session) this.hooks.openSession(e.session);
|
||||||
}),
|
}),
|
||||||
}));
|
});
|
||||||
|
if (e.kind === 'finished' && !ended) {
|
||||||
|
recentNode.querySelector('.lt-notif-item-body').append(el('div', { class: 'dd-request-actions' },
|
||||||
|
el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: (event) => { event.stopPropagation(); this.peek(e.session); } }, 'Peek & reply')));
|
||||||
|
}
|
||||||
|
nodes.push(recentNode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!nodes.length) nodes.push(el('div', { class: 'dd-notif-empty' }, 'Nothing needs you. Sessions that finish or wait while you\'re elsewhere show up here.'));
|
if (!nodes.length) nodes.push(el('div', { class: 'dd-notif-empty' }, 'Nothing needs you. Sessions that finish or wait while you\'re elsewhere show up here.'));
|
||||||
|
|||||||
@@ -900,6 +900,7 @@ function wireChrome() {
|
|||||||
hooks: {
|
hooks: {
|
||||||
openSession: (name) => connect(name, { existing: true }),
|
openSession: (name) => connect(name, { existing: true }),
|
||||||
ended: (name) => toast.info(`${name} has ended; its conversation is still in Claude's history`),
|
ended: (name) => toast.info(`${name} has ended; its conversation is still in Claude's history`),
|
||||||
|
refresh: () => sessionsPanel.reload(),
|
||||||
showAlerts: () => {
|
showAlerts: () => {
|
||||||
setSidebar(true);
|
setSidebar(true);
|
||||||
$('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' });
|
$('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||||
|
|||||||
@@ -947,3 +947,32 @@ html[data-theme="light"] .dd-files.is-narrow .dd-explorer { box-shadow: 8px 0 24
|
|||||||
.dd-dialog.is-wide .lt-modal-body { padding: 0; }
|
.dd-dialog.is-wide .lt-modal-body { padding: 0; }
|
||||||
.dd-compare { display: flex; height: min(64vh, 640px); }
|
.dd-compare { display: flex; height: min(64vh, 640px); }
|
||||||
.dd-compare > .cm-editor { flex: 1; min-width: 0; }
|
.dd-compare > .cm-editor { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
/* ---- inbox: answer & peek (devdesk#22, #23) --------------------------------------------- */
|
||||||
|
.dd-inbox-panel.is-peek { width: min(460px, calc(100vw - 16px)); }
|
||||||
|
.dd-request-desc { margin-top: 4px; color: var(--text-secondary); font-size: 0.7rem; }
|
||||||
|
.dd-request {
|
||||||
|
display: block;
|
||||||
|
max-height: 7.5em;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
overflow: auto;
|
||||||
|
border-left: 2px solid var(--accent-amber);
|
||||||
|
background: var(--bg-terminal);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.dd-request-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||||||
|
.dd-peek { display: flex; flex-direction: column; gap: 8px; padding: 8px 10px 10px; }
|
||||||
|
.dd-peek-head { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.dd-peek-back { padding: 0 6px; border: 1px solid var(--border-color); background: none; color: var(--accent-cyan); cursor: pointer; }
|
||||||
|
.dd-peek-title { flex: 1; color: var(--text-primary); font-weight: 700; font-size: 0.8rem; }
|
||||||
|
.dd-peek-state { color: var(--text-muted); font-size: 0.66rem; }
|
||||||
|
.dd-peek-label { display: block; margin-bottom: 2px; color: var(--accent-orange); font-size: 0.6rem; letter-spacing: 0.1em; text-transform: uppercase; }
|
||||||
|
.dd-peek-user { padding: 6px 8px; border-left: 2px solid var(--border-color); color: var(--text-secondary); font-size: 0.72rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.dd-peek-assistant { max-height: 38vh; padding: 6px 8px; overflow: auto; border-left: 2px solid var(--accent-cyan); background: var(--bg-terminal); color: var(--text-primary); font-size: 0.74rem; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.dd-peek-input { width: 100%; min-height: 4.5em; resize: vertical; font-family: var(--font-mono); font-size: 0.76rem; }
|
||||||
|
.dd-peek-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user