Compare commits

..
2 Commits
Author SHA1 Message Date
jaredandClaude Opus 5.5 f82c13612d feat: v0.8.2 clickable paths, alert sessions, ops guard and trail
build-deb / Lint, test, package (push) Successful in 2m45s
build-deb / Deploy to CT 126 (push) Successful in 5s
build-deb / Notify on failure (push) Skipped
- Terminal link provider: printed paths that are files of the session open
  in Files (at the line); the file list is preloaded so the first hover works.
- Alerts: "In a new session" opens alert-<name> and types the prompt once
  Claude is up.
- devdesk-guard PreToolUse(Bash) hook: permissionDecision "ask" for
  destructive commands (verified to prompt in auto mode); DEVDESK_GUARD and
  guard.local.
- State hook logs Bash commands and derives host tags (ssh targets, CT/VM
  ids); --list carries tags, --peek the last commands; UI shows both.

Closes #21
Closes #18
Closes #27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:37:46 -04:00
jaredandClaude Opus 5.5 a92d10ace3 feat: v0.8.1 answer sessions from the inbox: allow/deny, peek & reply
build-deb / Lint, test, package (push) Successful in 2m4s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped
- 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>
2026-10-10 20:22:37 -04:00
23 changed files with 787 additions and 27 deletions
+2 -2
View File
@@ -40,8 +40,8 @@ install: all
install -m 0644 libexec/devdesk-lib.sh $(DESTDIR)$(LIBDIR)/devdesk-lib.sh
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-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix $(DESTDIR)$(LIBDIR)/
install -m 0644 libexec/devdesk-files.sh $(DESTDIR)$(LIBDIR)/devdesk-files.sh
libexec/devdesk-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix libexec/devdesk-guard $(DESTDIR)$(LIBDIR)/
install -m 0644 libexec/devdesk-files.sh libexec/devdesk-inbox.sh $(DESTDIR)$(LIBDIR)/
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 -d $(DESTDIR)$(SHAREDIR)/assets
+3
View File
@@ -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.
- 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.
- **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).
- **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.
@@ -48,6 +49,8 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
- **Repo / CI panel:** branch, ahead/behind, uncommitted changes, HEAD's Gitea CI status and open PRs for the session's folder. When CI fails, **Ask Claude to fix CI** types a prompt to read the failed job's log.
- **Files** (Ctrl+Shift+E), laid out like VS Code: an explorer tree (git colours, gitignored entries dimmed) and an editor with tabs (a click opens a preview tab, a double-click keeps it), **Go to file** (Ctrl+P in the pane), markdown previews (sanitized, with relative images), images, and a Changes view of uncommitted files with line counts, a **C** badge on files Claude edited, and diffs against HEAD. **@Claude** types `@path` into the prompt. Tabs and open folders are remembered per session. Paths can't leave the session folder.
- **Editing:** text files are editable; ● marks unsaved tabs; Ctrl+S (or Save) writes atomically and keeps the file's mode and owner. If the file changed on disk since you opened it (Claude edited it), saving asks: Compare (a diff of disk vs yours), Overwrite or Cancel; a change on disk while you edit shows a banner instead of replacing your text. Unsaved edits are kept as drafts in the browser, so a reload or a session switch doesn't lose them. + creates a file (folders as needed). The editor bundle (`/usr/share/devdesk/assets/editor.js`) loads over ttyd the first time and is cached by the browser.
- **Paths are links:** a file path Claude prints (`web/src/files.js`, `notes.md:12`) opens in Files when clicked, at that line if given.
- **Ops guard:** `devdesk-guard` (a PreToolUse hook) makes Claude ask before destructive commands (`pct destroy`, `zfs destroy`, Ceph pool/OSD removal, `rm -r /…`, `git push --force`, `reboot`…), even in auto mode. `DEVDESK_GUARD=0` turns it off; `/etc/devdesk/guard.local` adds rules (`regex<TAB>reason`). Bash commands are logged per session, and sessions are tagged with the hosts they touch (ssh targets, CT/VM ids).
- **Context:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use and cost to `/var/lib/devdesk/context/<session>.json`. The Sessions panel shows it as a bar (cost in the tooltip), and Claude's own footer shows a one-line summary.
- **Light mode:** the terminal switches palettes with the page. DevDesk starts Claude with its `dark-ansi` theme so Claude draws with the terminal's 16 colours, and the light palette maps bright white to ink; tmux and the status line use ANSI colours too. A session picks up Claude's theme the next time Claude starts in it (`/exit`, then ⏎ resumes the same conversation).
- **Phone:** a key bar replaces the shortcut hints (Esc, Tab, ⇧Tab, ^C, ↑, ↓, ⏎, Claude/Shell window), and the panels open as a drawer.
+18 -1
View File
@@ -19,6 +19,8 @@ set -euo pipefail
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
# shellcheck source=libexec/devdesk-files.sh
. "$DEVDESK_LIBEXEC/devdesk-files.sh"
# shellcheck source=libexec/devdesk-inbox.sh
. "$DEVDESK_LIBEXEC/devdesk-inbox.sh"
usage() {
sed -n '2,12s/^# \{0,1\}//p' "$0"
@@ -67,6 +69,7 @@ cmd_list() {
--arg proj "$(projects_tsv)" \
--argjson ctx "$(per_session_json "$DEVDESK_CONTEXT_DIR")" \
--argjson st "$(per_session_json "$DEVDESK_STATUS_DIR")" \
--argjson tags "$(per_session_json "$DEVDESK_STATE_DIR/tags")" \
--arg dflt "$DEVDESK_DEFAULT_SESSION" \
--arg root "$DEVDESK_PROJECTS_ROOT" '
def rows($s): $s | split("\n") | map(select(length > 0) | split("\t"));
@@ -83,7 +86,7 @@ cmd_list() {
| map({name: .key, dir: .value.dir, running: false, attached: 0,
activity: null, windows: 0,
claude_session_id: (.value.claude_session_id // null)}))
) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null)}),
) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null), tags: ($tags[.name].hosts // [])}),
projects: ([{name: $dflt, dir: $root}]
+ [rows($proj)[] | {name: .[0], dir: (.[1:] | join("\t"))}
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
@@ -325,6 +328,20 @@ cmd_attach() {
dd_linger
return
;;
--peek)
shift
cmd_peek "$@"
dd_linger
return
;;
--answer | --reply)
local action=${1#--}
shift
"cmd_$action" "$@"
ok_json
dd_linger
return
;;
--new | --rename | --end)
local action=${1#--}
shift
+44
View File
@@ -1,3 +1,47 @@
devdesk (0.8.2) bookworm; urgency=medium
* Click file paths in the terminal (#21): a path Claude prints
(web/src/files.js, ./notes.md:12, /root/code/x/y.py:3) is a link when it
is a file of the session; it opens in Files, at that line if given.
* Alerts: "In a new session" (#18) opens (or reuses) alert-<name> in the
default folder and types the investigation prompt there once Claude is
up, without sending it.
* Ops trail and guard (#27):
- devdesk-guard, a PreToolUse hook for Bash, makes Claude ask before
commands that destroy things (pct/qm destroy, zfs/zpool destroy, Ceph
pool/OSD removal, rbd rm, wipefs/mkfs/dd to /dev, rm -r of / or a
top-level folder, git push --force, DROP TABLE, stopping devdesk-tmux,
reboot/poweroff…), also in auto mode. It never blocks or allows on its
own. DEVDESK_GUARD=0 turns it off; /etc/devdesk/guard.local adds rules
(regex<TAB>reason).
- Every Bash command is logged per session (last 500); sessions are
tagged with the hosts they work on (ssh targets, CT/VM ids), shown in
the session list, the switcher and the palette; Peek lists the last
commands.
* Sessions pick up the new hooks when Claude next starts (/exit, then ⏎).
-- Jared Vititoe <jjvititoe1@gmail.com> Sat, 10 Oct 2026 20:36:32 -0400
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
* Edit and save files in the Files pane (#20):
+5
View File
@@ -42,5 +42,10 @@ DEVDESK_PUBLIC_URL=https://dev.lotusguild.org
DEVDESK_MATRIX_WEBHOOK_FILE=/etc/devdesk/matrix-webhook
DEVDESK_MATRIX_DELAY=30
# 0 turns off the guard that makes Claude ask before destructive commands
# (pct/zfs/ceph destroy, rm -r /…, force pushes, reboots). Extra rules:
# /etc/devdesk/guard.local, one "regex<TAB>reason" per line.
DEVDESK_GUARD=1
# 1 = also enable Remote Control so sessions can be continued from the Claude app.
DEVDESK_REMOTE_CONTROL=0
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# Claude Code PreToolUse hook for Bash (wired in via claude-settings.json):
# commands that destroy things (containers, VMs, pools, disks, root-level
# folders, cluster members…) get permissionDecision "ask", so Claude has to get
# your OK even in auto mode (devdesk#27). It never blocks and never allows on
# its own; anything else passes through untouched.
#
# DEVDESK_GUARD=0 in /etc/devdesk/env.local turns it off. Extra rules go in
# /etc/devdesk/guard.local, one per line: an extended regex, a tab, a reason.
# shellcheck source=libexec/devdesk-lib.sh
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" 2>/dev/null || exit 0
[ "${DEVDESK_GUARD:-1}" != 0 ] || exit 0
command=$(jq -rM '.tool_input.command // empty' 2>/dev/null) || exit 0
[ -n "$command" ] || exit 0
# Built-in rules: extended regex <TAB> reason.
rules=$(
cat <<'RULES'
\bpct[[:space:]]+destroy\b destroys a Proxmox container
\bqm[[:space:]]+destroy\b destroys a Proxmox VM
\bpvecm[[:space:]]+delnode\b removes a node from the Proxmox cluster
\bpveceph[[:space:]]+(osd|pool|mon|mgr|mds|fs)[[:space:]]+destroy\b destroys Ceph components
\bceph[[:space:]]+osd[[:space:]]+(purge|destroy|rm|out)\b removes or takes out a Ceph OSD
\bceph[[:space:]]+osd[[:space:]]+pool[[:space:]]+(delete|rm)\b deletes a Ceph pool
\bceph[[:space:]]+(fs[[:space:]]+rm|mon[[:space:]]+remove|auth[[:space:]]+del)\b removes Ceph components
\brbd[[:space:]]+(rm|remove|purge)\b deletes RBD images
\bzfs[[:space:]]+destroy\b destroys a ZFS dataset or snapshot
\bzpool[[:space:]]+(destroy|labelclear)\b destroys a ZFS pool
\b(lvremove|vgremove|pvremove|wipefs|shred)\b wipes volumes or disks
\bmkfs(\.[a-z0-9]+)?\b formats a filesystem
\bsgdisk[[:space:]]+(-[a-zA-Z]*Z|--zap) wipes a partition table
\bdd\b[^|;&]*\bof=/dev/ writes raw to a device
\brm[[:space:]]+(-[^[:space:]]*[[:space:]]+)*-[^[:space:]]*[rR][^[:space:]]*[[:space:]]+(-[^[:space:]]*[[:space:]]+)*['"]?/([a-z0-9_.*-]+/?)?['"]?([[:space:];&|)]|$) recursively deletes / or a top-level folder
\bgit[[:space:]]+push\b[^|;&]*([[:space:]]-f\b|--force) force-pushes (rewrites remote history)
\b(DROP[[:space:]]+(DATABASE|SCHEMA|TABLE)|TRUNCATE[[:space:]]+TABLE)\b drops database objects
\bsystemctl[[:space:]]+(stop|restart|kill|disable|mask)[[:space:]]+devdesk-tmux ends every DevDesk session
(^|[;&|][[:space:]]*|\bssh[[:space:]]+[^[:space:]]+[[:space:]]+['"]?|\bsystemctl[[:space:]]+)(reboot|poweroff|halt|shutdown)\b reboots or shuts down a machine
\bkubectl[[:space:]]+delete\b deletes Kubernetes resources
\bdocker[[:space:]]+(system[[:space:]]+prune|volume[[:space:]]+(rm|prune))\b deletes Docker data
RULES
cat "${DEVDESK_GUARD_LOCAL:-/etc/devdesk/guard.local}" 2>/dev/null
)
while IFS=$'\t' read -r pattern reason; do
if [ -z "$pattern" ] || [ "${pattern:0:1}" = "#" ]; then continue; fi
if grep -Eiq -- "$pattern" <<<"$command" 2>/dev/null; then
jq -cMn --arg r "DevDesk guard: this command ${reason:-matches a rule in guard.local}. Confirm before it runs." \
'{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "ask", permissionDecisionReason: $r}}'
exit 0
fi
done <<<"$rules"
exit 0
+114
View File
@@ -0,0 +1,114 @@
# 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 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
}
+10 -4
View File
@@ -132,7 +132,8 @@ dd_registry_set_claude() {
dd_registry_remove() {
dd_registry_update 'del(.sessions[$n])' --arg n "$1"
rm -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_STATUS_DIR/$1.json" "$DEVDESK_STATE_DIR/edits/$1.txt"
rm -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_STATUS_DIR/$1.json" "$DEVDESK_STATE_DIR/edits/$1.txt" \
"$DEVDESK_STATE_DIR/commands/$1.jsonl" "$DEVDESK_STATE_DIR/tags/$1.json"
}
dd_registry_rename() {
@@ -144,9 +145,14 @@ dd_registry_rename() {
mv -f "$d/$1.json" "$d/$2.json"
fi
done
if [ -f "$DEVDESK_STATE_DIR/edits/$1.txt" ]; then
mv -f "$DEVDESK_STATE_DIR/edits/$1.txt" "$DEVDESK_STATE_DIR/edits/$2.txt"
fi
local f
for f in "edits/%s.txt" "commands/%s.jsonl" "tags/%s.json"; do
# shellcheck disable=SC2059 # the pattern is ours
if [ -f "$DEVDESK_STATE_DIR/$(printf "$f" "$1")" ]; then
# shellcheck disable=SC2059
mv -f "$DEVDESK_STATE_DIR/$(printf "$f" "$1")" "$DEVDESK_STATE_DIR/$(printf "$f" "$2")"
fi
done
}
# Project name (directory or its slug) -> absolute directory inside the projects root.
+2
View File
@@ -27,6 +27,8 @@ attached=$(dd_tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev
[ "${attached:-0}" = 0 ] || exit 0
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=""
# existing=1: if the session was ended by the time you tap, say so instead of
# starting a new Claude there (devdesk#38).
+51 -4
View File
@@ -2,6 +2,9 @@
# 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:
# 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)
# Stop, SessionStart -> idle
# SessionEnd -> exited
@@ -33,9 +36,39 @@ PostToolUse:Edit | PostToolUse:Write | PostToolUse:MultiEdit | PostToolUse:Noteb
fi
;;
esac
# Ops trail (devdesk#27): every Bash command, and the hosts a session works on
# (ssh targets, Proxmox CT/VM ids) as tags for the session list.
if [ "$event:$tool" = PostToolUse:Bash ]; then
cmd=$(jq -rM '.tool_input.command // empty' <<<"$input" 2>/dev/null)
if [ -n "$cmd" ]; then
base="${DEVDESK_STATE_DIR:-/var/lib/devdesk}"
mkdir -p "$base/commands" "$base/tags" 2>/dev/null
log="$base/commands/$session.jsonl"
jq -cMn --arg c "${cmd:0:500}" '{at: (now | floor), cmd: $c}' >>"$log" 2>/dev/null
if [ "$(wc -l <"$log" 2>/dev/null || echo 0)" -gt 600 ]; then
tail -n 500 "$log" >"$log.tmp" 2>/dev/null && mv -f "$log.tmp" "$log"
fi
hosts=$(
# ssh [options] [user@]host: the first word that isn't an option (or an option's value)
awk '{ for (i = 1; i <= NF; i++) if ($i == "ssh") { j = i + 1
while (j <= NF && $j ~ /^-/) { if ($j ~ /^-[bcDEeFIiJLlmOopQRSWw]$/) j++; j++ }
if (j <= NF) { h = $j; sub(/^[^@]*@/, "", h); gsub(/[^A-Za-z0-9_.-].*$/, "", h); if (h != "") print h } } }' <<<"$cmd"
grep -oE '\bpct[[:space:]]+(exec|enter|push|pull|config|set|start|stop|shutdown|reboot|status|snapshot|rollback|migrate)[[:space:]]+[0-9]{3,}' <<<"$cmd" | grep -oE '[0-9]+$' | sed 's/^/CT /'
grep -oE '\bqm[[:space:]]+(guest[[:space:]]+exec|terminal|config|set|start|stop|shutdown|reboot|status|snapshot|rollback|migrate)[[:space:]]+[0-9]{3,}' <<<"$cmd" | grep -oE '[0-9]+$' | sed 's/^/VM /'
)
if [ -n "$hosts" ]; then
tags="$base/tags/$session.json"
jq -cMn --rawfile new <(printf '%s\n' "$hosts") --slurpfile old <(cat "$tags" 2>/dev/null || echo '{}') '
($new | split("\n") | map(select(length > 0))) as $n
| {hosts: ($n + ($old[0].hosts // []) | reduce .[] as $h ([]; if index([$h]) then . else . + [$h] end) | .[:4]),
at: (now | floor)}' >"$tags.tmp" 2>/dev/null && mv -f "$tags.tmp" "$tags"
fi
fi
fi
case $event in
UserPromptSubmit | PostToolUse) state=working ;;
Notification) state=waiting ;;
Notification | PermissionRequest) state=waiting ;;
Stop | SessionStart) state=idle ;;
SessionEnd) state=exited ;;
*) exit 0 ;;
@@ -47,19 +80,33 @@ file="$dir/$session.json"
if [ "$state" != waiting ] && [ -f "$file" ] && grep -q "\"state\":\"$state\"" "$file"; then
exit 0
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
# `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)
was_waiting=0
if [ "$state" = waiting ] && grep -q '"state":"waiting"' "$file" 2>/dev/null; then
was_waiting=1
since=$(jq -rM '.since // empty' "$file" 2>/dev/null) || 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
mkdir -p "$dir" 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" \
'{state: $s, since: $t, event: $e, message: (if $m == "" then null else $m end)}' >"$tmp"; then
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)} + (if $r then {request: $r} else {} end)' >"$tmp"; then
mv -f "$tmp" "$file"
else
rm -f "$tmp"
+23
View File
@@ -73,6 +73,29 @@
}
]
}
],
"PermissionRequest": [
{
"hooks": [
{
"type": "command",
"command": "/usr/lib/devdesk/devdesk-state-hook",
"timeout": 5
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/usr/lib/devdesk/devdesk-guard",
"timeout": 5
}
]
}
]
},
"statusLine": {
+110
View File
@@ -675,3 +675,113 @@ fs_write() { # SESSION PATH BASE CONTENT-FILE
[ "$status" -ne 0 ]
[ "$(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"* ]]
}
@test "the guard asks before destructive commands and lets the rest through (#27)" {
guard() { jq -cn --arg c "$1" '{hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: {command: $c}}' | "$REPO/libexec/devdesk-guard"; }
asks=("pct destroy 105" "ssh root@large1 'pct destroy 105 --purge'" "qm destroy 210" "zfs destroy rpool/data/vm-105-disk-0"
"zpool destroy tank" "ceph osd pool delete rbd rbd --yes-i-really-really-mean-it" "rbd rm vm-105-disk-0"
"rm -rf /" "rm -rf /etc" "sudo rm -r -f /var/" "dd if=/dev/zero of=/dev/sdb bs=1M" "mkfs.ext4 /dev/sdb1" "wipefs -a /dev/sdc"
"git push --force origin main" "git push -f" "psql -c 'drop table users'" "systemctl restart devdesk-tmux"
"ssh pve1 reboot" "systemctl poweroff" "cd /tmp && shutdown -h now")
for c in "${asks[@]}"; do
run guard "$c"
[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")" = ask ] || { echo "should ask: $c"; false; }
done
passes=("pct list" "pct exec 105 -- df -h" "zfs list" "rm -rf /tmp/build-cache" "rm -rf node_modules" "rm -rf ./dist"
"git push origin main" "dd if=a.img of=b.img" "echo reboot later" "systemctl restart devdesk" "ceph -s" "ls -la /")
for c in "${passes[@]}"; do
run guard "$c"
[ -z "$output" ] || { echo "should pass: $c -> $output"; false; }
done
# Local rules and the off switch
printf 'nuke-it\tis a local rule\n' >"$TMP/guard.local"
run env DEVDESK_GUARD_LOCAL="$TMP/guard.local" bash -c "jq -cn '{tool_input: {command: \"nuke-it now\"}}' | '$REPO/libexec/devdesk-guard'"
[[ $output == *"is a local rule"* ]]
run env DEVDESK_GUARD=0 bash -c "jq -cn '{tool_input: {command: \"pct destroy 1\"}}' | '$REPO/libexec/devdesk-guard'"
[ -z "$output" ]
}
@test "Bash commands are logged and tag the session with the hosts it works on (#27)" {
start_server
devdesk new alpha
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
bash_cmd() { jq -cn --arg c "$1" '{hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: {command: $c}}' |
TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; }
bash_cmd "ssh -o BatchMode=yes root@large1 'pct exec 167 -- systemctl status authelia'"
bash_cmd "ls -la"
bash_cmd "ssh pve2 qm status 210"
[ "$(wc -l <"$DEVDESK_STATE_DIR/commands/alpha.jsonl")" -eq 3 ]
run devdesk attach --list
[ "$(jq -r '.sessions[] | select(.name == "alpha") | .tags | join(",")' <<<"$output")" = "pve2,VM 210,large1,CT 167" ]
run devdesk attach --peek alpha
[ "$(jq -r '.commands | map(.cmd) | .[1]' <<<"$output")" = "ls -la" ]
devdesk rename alpha beta
[ -f "$DEVDESK_STATE_DIR/commands/beta.jsonl" ] && [ -f "$DEVDESK_STATE_DIR/tags/beta.json" ]
devdesk end beta
[ ! -e "$DEVDESK_STATE_DIR/commands/beta.jsonl" ] && [ ! -e "$DEVDESK_STATE_DIR/tags/beta.json" ]
}
+12
View File
@@ -86,3 +86,15 @@ test('untrusted text loses control characters before it is typed (#2)', () => {
assert.equal(pasteSafe('plain text'), 'plain text');
assert.equal(pasteSafe(null), '');
});
test('file paths in terminal output are found, URLs and plain words are not (#21)', async () => {
const { findPaths } = await import('../web/src/links.js');
const paths = (text) => findPaths(text).map((p) => `${p.path}${p.line ? `:${p.line}` : ''}`);
assert.deepEqual(paths('● Update(web/src/files.js)'), ['web/src/files.js']);
assert.deepEqual(paths(' ⎿ Wrote 12 lines to notes/todo.md'), ['notes/todo.md']);
assert.deepEqual(paths('error at src/app.py:42:7 and ./README.md:3'), ['src/app.py:42', './README.md:3']);
assert.deepEqual(paths('see /root/code/devdesk/Makefile'), ['/root/code/devdesk/Makefile']);
assert.deepEqual(paths('open https://code.lotusguild.org/a/b.js now'), []);
// Candidates only: they become links only if they're files of the session.
assert.deepEqual(paths('just some words, v1.2 and e.g. stuff'), ['v1.2']);
});
+9 -1
View File
@@ -135,10 +135,17 @@ function baseExtensions(path, { readOnly = true, wrap = false } = {}) {
];
}
// Put the cursor on a line (1-based) and scroll it to the middle.
function gotoLine(view, line) {
const target = view.state.doc.line(Math.max(1, Math.min(line, view.state.doc.lines)));
view.dispatch({ selection: { anchor: target.from, head: target.to }, effects: EditorView.scrollIntoView(target.from, { y: 'center' }) });
view.focus();
}
// Read-only view of a file.
export function viewer(parent, { doc, path, wrap }) {
const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: baseExtensions(path, { wrap }) }) });
return { view, destroy: () => view.destroy() };
return { view, goto: (line) => gotoLine(view, line), destroy: () => view.destroy() };
}
// Editable file (devdesk#20): onChange(text) after every edit, onSave() on
@@ -159,6 +166,7 @@ export function editor(parent, { doc, path, wrap, onChange, onSave }) {
});
return {
view,
goto: (line) => gotoLine(view, line),
text: () => view.state.doc.toString(),
// Replace the whole document (reload from disk) keeping the cursor roughly where it was.
setText: (text) => {
+6 -1
View File
@@ -60,5 +60,10 @@ export function renderAlerts(root, data) {
el('span', { class: 'dd-muted dd-alert-age' }, age(a.since))),
a.instances?.length ? el('div', { class: 'dd-muted dd-alert-instances' }, a.instances.join(' · ')) : null,
a.summary ? el('div', { class: 'dd-alert-summary' }, a.summary) : null,
el('button', { type: 'button', class: 'lt-btn lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a) }, 'Ask Claude'))));
el('div', { class: 'dd-ask-row' },
el('button', { type: 'button', class: 'lt-btn lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a), title: 'Type the prompt into the current session (not sent)' }, 'Ask Claude'),
el('button', {
type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a),
'data-ask-session': `alert-${a.name}`, title: 'Open a separate ops session for this alert and type the prompt there (not sent)',
}, 'In a new session')))));
}
+15
View File
@@ -215,6 +215,21 @@ async function uploadNow(session, file, onProgress) {
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`
// 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
+52 -2
View File
@@ -437,6 +437,7 @@ export class FilesPane {
async loadFileList() {
if (this.fileList && Date.now() - this.fileList.at < 60000 && this.fileList.session === this.session) return this.fileList.items;
const list = parseListOutput(await runCommand(['--fs-find', this.session], 45000));
this.fileListRoot = list.root;
const items = list.files.map((path) => {
const name = base(path);
const [icon] = iconFor(name);
@@ -462,6 +463,9 @@ export class FilesPane {
}
async claudeTurnEnded() {
// New files may exist now: refresh the list terminal links are checked against.
this.fileList = null;
this.knownFiles();
if (!this.isOpen) {
// Next time it opens it revalidates anyway; open tabs re-check on activation.
for (const v of this.views.values()) v.stale = true;
@@ -707,8 +711,9 @@ export class FilesPane {
// Single click opens a preview tab (italic) that the next click replaces;
// double click, Enter or go-to-file keeps it.
openFile(path, { mode, pin = false } = {}) {
const wanted = mode ?? (isMarkdown(path) ? 'preview' : 'view');
openFile(path, { mode, pin = false, line } = {}) {
// A line number means source: markdown opens as text, not as a preview.
const wanted = mode ?? (isMarkdown(path) && !line ? 'preview' : 'view');
const key = `${wanted}:${path}`;
let tab = this.tabs.find((t) => t.key === key) ?? this.tabs.find((t) => t.path === path && !mode);
if (!tab) {
@@ -729,6 +734,11 @@ export class FilesPane {
tab.pinned = true;
}
this.pushRecent(path);
if (line) {
const loaded = this.views.get(tab.key)?.editor;
if (loaded?.goto) setTimeout(() => loaded.goto(line), 0);
else tab.line = line;
}
this.activate(tab.key);
if (this.root.classList.contains('is-narrow')) this.setExplorer(false);
}
@@ -1030,9 +1040,49 @@ export class FilesPane {
view.node.replaceChildren(this.bar(tab, header, view), content);
if (banner) this.oldDraft(tab.key, banner);
if (view.dirty) this.renderTabs();
this.gotoPending(tab, view);
return;
}
view.node.replaceChildren(this.bar(tab, header, view), content);
this.gotoPending(tab, view);
}
gotoPending(tab, view) {
if (!tab.line || !view.editor?.goto) return;
const line = tab.line;
delete tab.line;
setTimeout(() => view.editor.goto(line), 0);
}
// ------------------------------------------------------------ paths in the terminal (devdesk#21)
// The session's file list (cached by go-to-file). Loaded ahead of time (on
// connect and after Claude's turns): xterm asks for a line's links once and
// caches the answer, so it has to be ready before the first hover.
knownFiles() {
if (this.session !== this.hooks.session()) this.syncSession();
if (this.fileList?.session === this.session) {
if (!this.fileSet || this.fileSet.list !== this.fileList.items) {
this.fileSet = { list: this.fileList.items, set: new Set(this.fileList.items.map((x) => x.id)) };
}
return this.fileSet.set;
}
if (!this.loadingList && this.session) {
this.loadingList = true;
this.loadFileList().catch(() => {}).finally(() => { this.loadingList = false; });
}
return null;
}
// A path as printed in the terminal -> the session-relative path, if it's a
// file of this session (absolute paths inside the folder count too).
resolvePrinted(text) {
const known = this.knownFiles();
if (!known) return null;
let path = text.replace(/^\.\//, '');
const root = this.fileListRoot;
if (path.startsWith('/') && root && path.startsWith(`${root}/`)) path = path.slice(root.length + 1);
return known.has(path) ? path : null;
}
// ------------------------------------------------------------ editing (devdesk#20)
+114 -4
View File
@@ -5,6 +5,8 @@
import { el } from './dom.js';
import { ago } from './sessions.js';
import { answerPrompt, peekSession, replyTo } from './connection.js';
import { toast } from './ui.js';
const KEY = 'devdesk.inbox';
const MAX = 30;
@@ -65,6 +67,8 @@ export class Inbox {
if (open) this.panel.removeAttribute('aria-hidden');
else this.panel.setAttribute('aria-hidden', 'true');
this.button.setAttribute('aria-expanded', String(open));
if (!open) this.peeking = null;
this.panel.classList.toggle('is-peek', false);
if (open) this.render();
}
@@ -113,7 +117,88 @@ export class Inbox {
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.'),
data.commands?.length ? el('div', { class: 'dd-peek-commands' },
el('span', { class: 'dd-peek-label' }, 'Recent commands'),
...data.commands.slice(-8).map((c) => el('div', { class: 'dd-peek-command', title: c.cmd },
el('span', { class: 'dd-peek-when' }, ago(c.at)), el('code', {}, c.cmd)))) : null,
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() {
if (this.peeking) return; // the peek view stays until you go back
const close = (run) => () => {
this.setOpen(false);
run();
@@ -136,11 +221,31 @@ export class Inbox {
if (this.waiting.length) {
nodes.push(el('div', { class: 'dd-notif-section' }, 'Needs you'));
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'}`,
meta: s.status?.since ? `waiting ${ago(s.status.since)}` : 'waiting',
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) {
@@ -160,7 +265,7 @@ export class Inbox {
const kind = KINDS[e.kind] ?? { tone: 'cyan' };
// 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);
nodes.push(item({
const recentNode = item({
dot: e.read || ended ? 'read' : kind.tone, unread: !e.read && !ended, title: e.text,
meta: `${when(e.at)}${ended ? ' · session ended' : ''}`,
onclick: close(() => {
@@ -170,7 +275,12 @@ export class Inbox {
else if (ended) this.hooks.ended?.(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.'));
+45
View File
@@ -0,0 +1,45 @@
// File paths in the terminal become links (devdesk#21): click one in Claude's
// output (web/src/files.js, ./notes.md:12, /root/code/x/y.py:3:9) to open it in
// the Files pane, at that line if there is one. Only paths that are files of
// the session become links, so words and URLs are left alone.
// A path: optional ./ ../ or /, segments, and either a slash or an extension;
// then an optional :line[:col].
const PATH_RE = /(?<![\w/.@~+-])((?:\.{1,2}\/|\/)?(?:[\w@+~-][\w.@+~-]*\/)*[\w@+~-][\w.@+~-]*(?:\.[A-Za-z0-9]{1,10})?)(?::(\d+)(?::(\d+))?)?/g;
const URL_RE = /\b[a-z][a-z0-9+.-]*:\/\/\S+/gi;
export function findPaths(text) {
const urls = [...text.matchAll(URL_RE)].map((m) => [m.index, m.index + m[0].length]);
const out = [];
for (const m of text.matchAll(PATH_RE)) {
const candidate = m[1];
if (!candidate.includes('/') && !/\.[A-Za-z0-9]{1,10}$/.test(candidate)) continue;
if (urls.some(([a, b]) => m.index >= a && m.index < b)) continue;
out.push({ index: m.index, length: m[0].length, path: candidate, line: m[2] ? Number(m[2]) : undefined });
}
return out;
}
export function registerPathLinks(term, { resolve, open }) {
term.registerLinkProvider({
provideLinks(y, callback) {
const line = term.buffer.active.getLine(y - 1);
if (!line) {
callback(undefined);
return;
}
const links = [];
for (const found of findPaths(line.translateToString(true))) {
const path = resolve(found.path);
if (!path) continue;
links.push({
range: { start: { x: found.index + 1, y }, end: { x: found.index + found.length, y } },
text: path,
decorations: { underline: true, pointerCursor: true },
activate: () => open(path, found.line),
});
}
callback(links.length ? links : undefined);
},
});
}
+53 -4
View File
@@ -4,7 +4,7 @@ import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js';
import {
TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError,
} from './connection.js';
import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js';
import { parseTitle, pasteSafe, sessionFromSearch, slugify } from './protocol.js';
import { SessionsPanel, recentNames, rememberSession, sessionStatus } from './sessions.js';
import { renderAlerts } from './alerts.js';
import { renderRepo } from './repo.js';
@@ -13,6 +13,7 @@ import { heardWithin, onShared, share } from './shared.js';
import { CommandPalette, dialogOpen, openDialog, toast } from './ui.js';
import { Inbox } from './inbox.js';
import { FilesPane } from './files.js';
import { registerPathLinks } from './links.js';
import { el } from './dom.js';
import * as notify from './notify.js';
@@ -357,6 +358,7 @@ function connect(session, { existing = false, reconnect = false } = {}) {
state.attempts = 0;
setWsState('connected', 'live');
if (session) rememberSession(session);
if (session) files?.knownFiles(); // so paths in the output are links from the first hover
refreshSoon(800); // opening ?arg=<new name> just created this session
refreshRepo();
fit.fit();
@@ -548,7 +550,8 @@ function renderSwitcher() {
title: s.dir || '', onclick: choose(() => connect(s.name, { existing: true })),
},
el('span', { class: `lt-dot ${status.dot}` }),
el('span', { class: 'dd-switch-name' }, s.name),
el('span', { class: 'dd-switch-name' }, s.name,
s.tags?.length ? el('span', { class: 'dd-tags' }, ...s.tags.slice(0, 2).map((t) => el('span', { class: 'dd-tag' }, t))) : null),
el('span', { class: 'dd-switch-meta' }, `${status.label}${ctx}`));
});
if (!items.length) items.push(el('div', { class: 'dd-menu-note' }, sessionsPanel?.data ? 'No sessions yet.' : 'Loading…'));
@@ -589,6 +592,38 @@ async function toggleNotifications() {
}
}
// "In a new session" (devdesk#18): open (or reuse) a session for this alert,
// wait until its Claude is up (SessionStart sets it idle), then type the
// prompt without sending it.
let pendingAsk = null;
function askInSession(name, text) {
closeDrawerIfNarrow();
pendingAsk = { session: name, text, until: Date.now() + 60000 };
connect(name);
toast.info(`Opening ${name}; the prompt will be typed when Claude is ready`);
const tick = async () => {
if (!pendingAsk || pendingAsk.session !== name) return;
if (Date.now() > pendingAsk.until) {
toast.warning(`${name}: Claude didn't start in time; the prompt was not typed`);
pendingAsk = null;
return;
}
await sessionsPanel.reload();
const s = sessionsPanel.data?.sessions?.find((x) => x.name === name);
const ready = s && ['idle', 'waiting', 'working'].includes(sessionStatus(s).key);
if (ready && state.session === name && state.conn?.isOpen) {
setTimeout(() => {
term.paste(pasteSafe(pendingAsk.text));
term.focus();
pendingAsk = null;
}, 800);
return;
}
setTimeout(tick, 1500);
};
setTimeout(tick, 1500);
}
// ---------------------------------------------------------------- palette & help
const sendKeys = (keys) => state.conn?.sendInput(keys);
@@ -620,7 +655,8 @@ function buildCommands() {
if (s.name === state.session) continue;
const status = sessionStatus(s);
add({
id: `switch:${s.name}`, group: 'Sessions', label: `Switch to ${s.name}`, hint: status.label, keywords: s.dir,
id: `switch:${s.name}`, group: 'Sessions', label: `Switch to ${s.name}`, hint: [status.label, ...(s.tags ?? []).slice(0, 2)].join(' · '),
keywords: `${s.dir} ${(s.tags ?? []).join(' ')}`,
icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name, { existing: true }),
});
}
@@ -718,6 +754,7 @@ const KEY_HELP = [
['Ctrl+⇧ C / V', 'Copy selection / paste'],
['⇧ drag', 'Select text in the browser'],
['Drop or paste a file', 'Upload it and attach it for Claude'],
['Click a file path', 'Open it in Files (at the line, if given)'],
['Ctrl+W', 'Asks before closing; Chromium fullscreen keeps it for the terminal'],
]],
];
@@ -867,6 +904,13 @@ function wireChrome() {
},
});
$('files-toggle').addEventListener('click', () => files.toggle());
registerPathLinks(term, {
resolve: (printed) => (state.session ? files.resolvePrinted(printed) : null),
open: (path, line) => {
if (!files.isOpen) files.open();
files.openFile(path, { pin: true, line });
},
});
wireResizer();
palette = new CommandPalette(buildCommands);
$('cmd-toggle').addEventListener('click', () => palette.open());
@@ -900,6 +944,7 @@ function wireChrome() {
hooks: {
openSession: (name) => connect(name, { existing: true }),
ended: (name) => toast.info(`${name} has ended; its conversation is still in Claude's history`),
refresh: () => sessionsPanel.reload(),
showAlerts: () => {
setSidebar(true);
$('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' });
@@ -1078,7 +1123,11 @@ async function boot() {
onData: (_data, meta) => onSessionData(meta),
},
});
bindAskClaude($('sidebar'), (text) => {
bindAskClaude($('sidebar'), (text, session) => {
if (session) {
askInSession(slugify(session).slice(0, 48) || 'alert', text);
return;
}
// Alert text is untrusted: no control characters (a newline would run it in a
// shell window, CR would submit), and bracketed paste around it (devdesk#2).
term.paste(pasteSafe(text));
+2 -1
View File
@@ -285,7 +285,8 @@ export class SessionsPanel {
return el('li', { class: `dd-session-item is-${status.key}${isCurrent ? ' is-current' : ''}` },
el('button', { class: 'dd-session-open', type: 'button', title, onclick: () => this.hooks.onOpen(s.name) },
el('span', { class: `lt-dot ${status.dot}` }),
el('span', { class: 'dd-session-name' }, s.name),
el('span', { class: 'dd-session-name' }, s.name,
s.tags?.length ? el('span', { class: 'dd-tags', title: 'Hosts this session worked on' }, ...s.tags.slice(0, 3).map((t) => el('span', { class: 'dd-tag' }, t))) : null),
el('span', { class: 'dd-session-meta' }, meta),
pct != null ? el('span', { class: `dd-ctx${pct >= 90 ? ' is-full' : pct >= 70 ? ' is-high' : ''}`, 'aria-hidden': 'true' },
el('span', { class: 'dd-ctx-fill', style: `width:${Math.min(100, pct)}%` })) : null),
+4 -3
View File
@@ -1,11 +1,12 @@
// "Ask Claude": any [data-ask-claude] button (Ops Alerts, Repo/CI) types its
// prompt into the current session without pressing Enter, so you can review it
// before sending.
// prompt into a session without pressing Enter, so you can review it before
// sending: the current session, or (data-ask-session) a separate one, e.g. an
// ops session per alert (devdesk#18).
export function bindAskClaude(root, send) {
root.addEventListener('click', (event) => {
const button = event.target.closest('[data-ask-claude]');
if (!button || button.disabled) return;
send(button.dataset.askClaude);
send(button.dataset.askClaude, button.dataset.askSession || null);
});
}
+39
View File
@@ -947,3 +947,42 @@ 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-compare { display: flex; height: min(64vh, 640px); }
.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; }
.dd-ask-row { display: flex; flex-wrap: wrap; gap: 6px; align-self: flex-start; margin-top: 2px; }
.dd-alert .dd-ask-row .dd-ask { margin-top: 0; }
/* ---- ops trail (devdesk#27) ------------------------------------------------------------- */
.dd-tags { display: inline-flex; flex-wrap: wrap; gap: 3px; margin-left: 6px; vertical-align: middle; }
.dd-tag { padding: 0 4px; border: 1px solid var(--accent-cyan-border); color: var(--accent-cyan); font-size: 0.58rem; font-weight: 400; letter-spacing: 0; }
.dd-peek-commands { display: flex; flex-direction: column; gap: 2px; }
.dd-peek-command { display: flex; gap: 8px; min-width: 0; font-size: 0.68rem; }
.dd-peek-command code { overflow: hidden; color: var(--text-secondary); font-family: var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }
.dd-peek-when { flex: 0 0 2.6em; color: var(--text-muted); text-align: right; }