From 73e77aca7394b829adef2d30b42d84e5bd12997b Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 10 Oct 2026 21:45:26 -0400 Subject: [PATCH] feat: v0.9.0 conversation search, plan usage meters, quiet phone at the desk - devdesk attach --search (grep + jq over ~/.claude/projects, six files at a time) and --resume-convo NAME ID [fork]; devdesk-claude-run passes --fork-session once. Search dialog with Resume / Fork / Open. - statusLine snapshots rate_limits and pr; footer/menu meters with a 90 % warning; PR chip on session rows. - CLAUDE_CLIENT_PRESENCE_FILE per tmux session id; --presence on|off with a self-expiring marker; the page heartbeats it while visible. Closes #24 Closes #25 Closes #26 Co-Authored-By: Claude Opus 5.5 --- Makefile | 2 +- README.md | 3 + bin/devdesk | 15 ++++- debian/changelog | 23 +++++++ libexec/devdesk-claude-run | 19 +++++- libexec/devdesk-history.sh | 126 ++++++++++++++++++++++++++++++++++ libexec/devdesk-lib.sh | 1 + libexec/devdesk-statusline | 9 ++- tests/attach.bats | 61 +++++++++++++++++ web/index.template.html | 3 + web/src/history.js | 135 +++++++++++++++++++++++++++++++++++++ web/src/main.js | 68 ++++++++++++++++++- web/src/sessions.js | 8 ++- web/styles.css | 33 +++++++++ 14 files changed, 497 insertions(+), 9 deletions(-) create mode 100644 libexec/devdesk-history.sh create mode 100644 web/src/history.js diff --git a/Makefile b/Makefile index e3996f6..26bfb72 100644 --- a/Makefile +++ b/Makefile @@ -41,7 +41,7 @@ install: all 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 libexec/devdesk-guard $(DESTDIR)$(LIBDIR)/ - install -m 0644 libexec/devdesk-files.sh libexec/devdesk-inbox.sh $(DESTDIR)$(LIBDIR)/ + install -m 0644 libexec/devdesk-files.sh libexec/devdesk-inbox.sh libexec/devdesk-history.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 diff --git a/README.md b/README.md index e8c9fda..b1925ee 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,9 @@ The tmux server runs in its own unit, so every Claude process lives in **that** - **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 (`regexreason`). Bash commands are logged per session, and sessions are tagged with the hosts they touch (ssh targets, CT/VM ids). +- **Search past conversations** (⋯ menu, palette): full text over every Claude conversation on the host; **Resume** it in a new session or **Fork** it. +- **Plan usage:** the 5-hour and weekly windows as meters in the footer (warning at 90 %); a **PR** chip on sessions whose branch has a pull request. +- **Quiet phone at the desk:** while a tab shows a session, Remote Control holds back phone pushes for it (client-presence marker, expires ~2 min after you leave). - **Context:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use and cost to `/var/lib/devdesk/context/.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. diff --git a/bin/devdesk b/bin/devdesk index 31f3e9e..c23ec85 100755 --- a/bin/devdesk +++ b/bin/devdesk @@ -21,6 +21,8 @@ set -euo pipefail . "$DEVDESK_LIBEXEC/devdesk-files.sh" # shellcheck source=libexec/devdesk-inbox.sh . "$DEVDESK_LIBEXEC/devdesk-inbox.sh" +# shellcheck source=libexec/devdesk-history.sh +. "$DEVDESK_LIBEXEC/devdesk-history.sh" usage() { sed -n '2,12s/^# \{0,1\}//p' "$0" @@ -328,9 +330,18 @@ cmd_attach() { dd_linger return ;; - --peek) + --peek | --search) + local action=${1#--} shift - cmd_peek "$@" + "cmd_$action" "$@" + dd_linger + return + ;; + --resume-convo | --presence) + local action=${1#--} + shift + "cmd_${action//-/_}" "$@" + ok_json dd_linger return ;; diff --git a/debian/changelog b/debian/changelog index da79dd0..b6b0220 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,26 @@ +devdesk (0.9.0) bookworm; urgency=medium + + * Search past conversations (#24): every Claude conversation on this host + (DevDesk and code-server), by what you asked, what Claude said, the + commands it ran and their output (⋯ menu or the palette). Results show + the title, folder, dates and highlighted snippets; Resume opens the + conversation in a new session in its folder, Fork continues it as a new + conversation (claude --fork-session) and leaves the original as it is. + A conversation already open in a running session offers "Open" instead, + and resuming it twice is refused. + * Plan usage and PRs (#25): the 5-hour and weekly plan windows from + Claude's statusLine, as meters in the footer (⋯ menu on phones), with a + warning at 90 % of the 5-hour window; a PR chip (with review state) on + sessions whose branch has a pull request. (The statusLine has no prompt + cache expiry, so there is no cache timer.) + * Phone pushes held back while you're at the desk (#26): while a DevDesk + tab shows a session, its Claude sees a client-presence marker + (CLAUDE_CLIENT_PRESENCE_FILE) and Remote Control holds back pushes; the + marker expires about 2 minutes after the tab stops showing it. Applies to + sessions whose Claude started on this version (/exit, then ⏎). + + -- Jared Vititoe Sat, 10 Oct 2026 21:44:07 -0400 + devdesk (0.8.2) bookworm; urgency=medium * Click file paths in the terminal (#21): a path Claude prints diff --git a/libexec/devdesk-claude-run b/libexec/devdesk-claude-run index adebab9..3545d09 100755 --- a/libexec/devdesk-claude-run +++ b/libexec/devdesk-claude-run @@ -42,15 +42,22 @@ run_new() { } run_resume() { - local id args started rc + local id args started rc fork=() id=$(dd_registry_get "$NAME" claude_session_id) if [ -z "$id" ]; then run_new return fi mapfile -d '' args < <(common_args) + # "Fork" from conversation search (devdesk#24): continue as a new + # conversation, once; the SessionStart hook then records the new id. + if [ "$(dd_registry_get "$NAME" fork)" = true ]; then + fork=(--fork-session) + # shellcheck disable=SC2016 # $n is a jq variable + dd_registry_update 'del(.sessions[$n].fork)' --arg n "$NAME" 2>/dev/null || true + fi started=$SECONDS - "$CLAUDE_BIN" --resume "$id" "${args[@]}" + "$CLAUDE_BIN" --resume "$id" "${fork[@]}" "${args[@]}" rc=$? # A resume that fails straight away means the transcript is gone; start fresh. if [ "$rc" -ne 0 ] && [ $((SECONDS - started)) -lt 10 ]; then @@ -69,6 +76,14 @@ menu() { } printf '\033]2;%s\033\134' "$NAME" # pane title (ST = ESC \\) + +# While a DevDesk tab shows this session, this marker exists and Claude tells +# Remote Control a client is present, which holds back phone pushes (devdesk#26). +# Keyed by tmux's session id, which survives renames. +sid=$(dd_tmux display -p -t "${TMUX_PANE:-}" '#{session_id}' 2>/dev/null) +if [ -n "$sid" ]; then + export CLAUDE_CLIENT_PRESENCE_FILE="$DEVDESK_PRESENCE_DIR/${sid#\$}" +fi mode=resume while :; do NAME=$(dd_current_session) # follow renames diff --git a/libexec/devdesk-history.sh b/libexec/devdesk-history.sh new file mode 100644 index 0000000..e6148cb --- /dev/null +++ b/libexec/devdesk-history.sh @@ -0,0 +1,126 @@ +# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables +# Past conversations (devdesk#24) and client presence (devdesk#26), sourced by +# bin/devdesk and reached through `devdesk attach --search|--resume-convo|--presence`. + +dd_projects_dir() { + printf '%s/projects\n' "${CLAUDE_CONFIG_DIR:-$HOME/.claude}" +} + +dd_convo_file() { + [[ $1 =~ ^[0-9a-f-]{36}$ ]] || return 1 + compgen -G "$(dd_projects_dir)/*/$1.jsonl" | head -n1 +} + +# --search QUERY: full-text search over every Claude conversation on this host +# (DevDesk and code-server alike). Case-insensitive, plain text. Newest first; +# per conversation: title, folder, dates and up to 3 snippets (what you asked, +# Claude's answers, commands it ran, their output). +cmd_search() { + [ $# -eq 1 ] || dd_die "usage: devdesk attach --search QUERY" + local query=$1 dir files f + if [ ${#query} -lt 2 ] || [ ${#query} -gt 200 ] || [[ $query == *$'\n'* ]]; then dd_die "search for 2 to 200 characters"; fi + dir=$(dd_projects_dir) + # Files that mention it at all (grep is fast even over hundreds of MB), newest first. + mapfile -t files < <({ LC_ALL=C grep -rlFi --include='*.jsonl' -- "$query" "$dir" 2>/dev/null || true; } | + while IFS= read -r f; do printf '%s\t%s\n' "$(stat -c %Y "$f")" "$f"; done | sort -rn | head -n 30 | cut -f2-) + # Six conversations at a time (each is a grep and a jq over large lines). + local tmp i=0 + tmp=$(mktemp -d) + for f in "${files[@]}"; do + dd_search_one "$query" "$f" >"$tmp/$(printf '%03d' "$i")" & + i=$((i + 1)) + if [ $((i % 6)) -eq 0 ]; then wait; fi + done + wait + { cat "$tmp"/* 2>/dev/null || true; } | jq -cMs --arg q "$query" '{query: $q, results: [.[] | select(. != null)][:25]}' + rm -rf "$tmp" +} + +# One conversation's search result (or nothing), for cmd_search. +dd_search_one() { + local query=$1 f=$2 + { LC_ALL=C grep -Fi -m 30 -- "$query" "$f" || true; } | jq -cMs --arg q "$query" --arg id "$(basename "$f" .jsonl)" \ + --arg mtime "$(stat -c %Y "$f")" --slurpfile head <(head -n 80 "$f" | jq -cM 'select(.type == "custom-title" or .type == "user" or .cwd)' 2>/dev/null | jq -cMs .) ' + def texts: + if .type == "user" then + (.message.content | if type == "string" then [{role: "you", text: .}] + else [.[]? | if .type == "text" then {role: "you", text: .text} + elif .type == "tool_result" then {role: "output", text: (.content | if type == "string" then . else ([.[]? | .text? // empty] | join("\n")) end)} + else empty end] end) + elif .type == "assistant" then + [.message.content[]? | if .type == "text" then {role: "claude", text: .text} + elif .type == "tool_use" then {role: "command", text: (.input.command // .input.file_path // .input.url // .input.pattern // empty)} + else empty end] + else [] end; + ($q | ascii_downcase) as $lq + | [.[] | select(.isSidechain != true) | . as $e | texts[] | select(.text | type == "string") + | (.text | ascii_downcase | index($lq)) as $i | select($i != null) + | {role, at: $e.timestamp, + snippet: ((if $i > 90 then "…" else "" end) + (.text[([$i - 90, 0] | max):($i + ($q | length) + 140)] | gsub("\\s+"; " ")) + + (if (.text | length) > $i + ($q | length) + 140 then "…" else "" end))}] as $m + | select($m | length > 0) + | ($head[0] // []) as $h + | {id: $id, + title: (([$h[] | select(.type == "custom-title") | .customTitle] | last) + // ([$h[] | select(.type == "user" and (.message.content | type == "string") and (.message.content | startswith("<") | not)) | .message.content] | first | .[0:120]) + // "untitled"), + prompt: ([$h[] | select(.type == "user" and (.message.content | type == "string") and (.message.content | startswith("<") | not)) | .message.content] | first | .[0:160]), + cwd: ([$h[] | .cwd // empty] | first), + started: ([$h[] | .timestamp // empty] | first), + updated: ($mtime | tonumber), + hits: ($m | length), matches: ($m[:3])}' 2>/dev/null +} + +# --resume-convo NAME ID [fork]: open a past conversation in a new session NAME, +# in the folder it ran in. "fork" continues it as a new conversation and leaves +# the original untouched (claude --fork-session). +cmd_resume_convo() { + if [ $# -lt 2 ] || [ $# -gt 3 ]; then dd_die "usage: devdesk attach --resume-convo NAME ID [fork]"; fi + local name=$1 id=$2 fork=${3:-} file dir + dd_valid_name "$name" || dd_die "invalid session name: $name" + if [ -n "$fork" ] && [ "$fork" != fork ]; then dd_die "the third argument can only be: fork"; fi + file=$(dd_convo_file "$id") || dd_die "no conversation $id" + [ -n "$file" ] || dd_die "no conversation $id" + if dd_server_up && dd_tmux has-session -t "=$name" 2>/dev/null; then dd_die "a session called $name is already running"; fi + # Two Claudes on one transcript would interleave it: resuming one that is + # open elsewhere is refused (forking is fine). + if [ -z "$fork" ]; then + local other + for other in $(jq -rM --arg id "$id" '.sessions | to_entries[] | select(.value.claude_session_id == $id) | .key' "$DEVDESK_REGISTRY" 2>/dev/null); do + if [ "$other" != "$name" ] && dd_tmux has-session -t "=$other" 2>/dev/null; then + dd_die "that conversation is open in session $other: open it there, or fork it" + fi + done + fi + dir=$(jq -rM 'select(.cwd) | .cwd' "$file" 2>/dev/null | head -n1) + [ -n "$dir" ] && [ -d "$dir" ] || dir=$(dd_resolve_dir "$name") + dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {dir: $d, claude_session_id: $id, claude_source: "devdesk-history"} + + (if $fork == "fork" then {fork: true} else {} end))' \ + --arg n "$name" --arg d "$dir" --arg id "$id" --arg fork "$fork" + dd_ensure_server + create_detached "$name" "$dir" +} + +# --presence SESSION on|off (devdesk#26): while a DevDesk tab shows a session, +# its Claude sees a client-presence marker (CLAUDE_CLIENT_PRESENCE_FILE, set by +# devdesk-claude-run per tmux session) and Remote Control holds back phone +# pushes. The browser refreshes it every ~45 s; it disappears ~2 minutes after +# the last refresh, so pushes resume once you walk away. +cmd_presence() { + [ $# -eq 2 ] || dd_die "usage: devdesk attach --presence SESSION on|off" + local session=$1 state=$2 sid marker + dd_valid_name "$session" || dd_die "invalid session name: $session" + dd_server_up || return 0 + sid=$(dd_tmux display -p -t "=$session:" '#{session_id}' 2>/dev/null) || return 0 + marker="$DEVDESK_PRESENCE_DIR/${sid#\$}" + if [ "$state" = off ]; then + rm -f "$marker" + return 0 + fi + [ "$state" = on ] || dd_die "presence is on or off" + mkdir -p "$DEVDESK_PRESENCE_DIR" && touch "$marker" + # Expire it unless refreshed meanwhile. + # (fd 3 closed too: a reaper must not hold a caller's extra descriptors open) + setsid -f bash -c 'sleep 120; [ -n "$(find "$1" -newermt "-110 seconds" 2>/dev/null)" ] || rm -f "$1"' _ "$marker" \ + /dev/null 2>&1 3>&- || true +} diff --git a/libexec/devdesk-lib.sh b/libexec/devdesk-lib.sh index 19b3593..9bcef0d 100644 --- a/libexec/devdesk-lib.sh +++ b/libexec/devdesk-lib.sh @@ -37,6 +37,7 @@ dd_load_env : "${DEVDESK_PUBLIC_URL:=}" : "${DEVDESK_MATRIX_WEBHOOK_FILE:=/etc/devdesk/matrix-webhook}" : "${DEVDESK_MATRIX_DELAY:=30}" +: "${DEVDESK_PRESENCE_DIR:=/run/devdesk/presence}" DEVDESK_REGISTRY="$DEVDESK_STATE_DIR/registry.json" DEVDESK_CONTEXT_DIR="$DEVDESK_STATE_DIR/context" diff --git a/libexec/devdesk-statusline b/libexec/devdesk-statusline index 84b0d11..fba5e82 100755 --- a/libexec/devdesk-statusline +++ b/libexec/devdesk-statusline @@ -1,7 +1,7 @@ #!/bin/bash # Claude Code statusLine command (wired in via claude-settings.json). Claude pipes # its status JSON in on every update; we keep a snapshot per DevDesk session for -# the web UI's Claude Context panel and print a one-line status for Claude's footer. +# the web UI (context, plan usage, PR) and print a one-line status for Claude's footer. # Runs often while Claude streams, so it stays cheap and never fails loudly. input=$(cat) @@ -29,7 +29,12 @@ if [[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]]; then + (.cache_read_input_tokens // 0)) else null end), cost_usd: .cost.total_cost_usd, lines_added: .cost.total_lines_added, - lines_removed: .cost.total_lines_removed + lines_removed: .cost.total_lines_removed, + # devdesk#25: plan usage (account-wide) and the pull request of the branch + rate_limits: (.rate_limits // null | if . then { + five_hour: (.five_hour | if . then {pct: .used_percentage, resets_at} else null end), + seven_day: (.seven_day | if . then {pct: .used_percentage, resets_at} else null end)} else null end), + pr: (.pr // null | if . then {number, url, review_state, kind} else null end) }' <<<"$input" >"$tmp" 2>/dev/null; then mv -f "$tmp" "$dir/$session.json" else diff --git a/tests/attach.bats b/tests/attach.bats index 2dbb096..497d52d 100644 --- a/tests/attach.bats +++ b/tests/attach.bats @@ -785,3 +785,64 @@ JSONL devdesk end beta [ ! -e "$DEVDESK_STATE_DIR/commands/beta.jsonl" ] && [ ! -e "$DEVDESK_STATE_DIR/tags/beta.json" ] } + +# A fake Claude history: one conversation in $1 with id $2. +fake_convo() { + mkdir -p "$CLAUDE_CONFIG_DIR/projects/-p" + jq -cn --arg cwd "$1" --arg id "$2" ' + {type: "user", cwd: $cwd, sessionId: $id, timestamp: "2026-08-01T10:00:00Z", message: {role: "user", content: "Fix the NFS mount on pbs"}}, + {type: "assistant", timestamp: "2026-08-01T10:00:05Z", message: {role: "assistant", content: [{type: "tool_use", name: "Bash", input: {command: "mount -t nfs 10.10.10.9:/backup /mnt/pbs"}}]}}, + {type: "assistant", timestamp: "2026-08-01T10:00:09Z", message: {role: "assistant", content: [{type: "text", text: "The NFS export needed no_root_squash."}]}}' \ + >"$CLAUDE_CONFIG_DIR/projects/-p/$2.jsonl" +} + +@test "search finds past conversations by what was said and run (#24)" { + export CLAUDE_CONFIG_DIR="$TMP/claude" + fake_convo "$DEVDESK_PROJECTS_ROOT/alpha" 11111111-1111-1111-1111-111111111111 + run devdesk attach --search "no_root_squash" + [ "$status" -eq 0 ] + [ "$(jq -r '.results[0] | [.id, .title, .cwd, .matches[0].role] | @tsv' <<<"$output")" = \ + "$(printf '11111111-1111-1111-1111-111111111111\tFix the NFS mount on pbs\t%s\tclaude' "$DEVDESK_PROJECTS_ROOT/alpha")" ] + run devdesk attach --search "MOUNT -t NFS" + [ "$(jq -r '.results[0].matches[0].role' <<<"$output")" = command ] + run devdesk attach --search "nothing like this anywhere" + [ "$(jq -r '.results | length' <<<"$output")" = 0 ] + run devdesk attach --search "x" + [ "$status" -ne 0 ] +} + +@test "resume-convo opens a past conversation in its folder; fork is passed once (#24)" { + export CLAUDE_CONFIG_DIR="$TMP/claude" + id=22222222-2222-2222-2222-222222222222 + fake_convo "$DEVDESK_PROJECTS_ROOT/alpha" "$id" + start_server + run devdesk attach --resume-convo nfs-fix "$id" fork + [ "$status" -eq 0 ] + claude_args nfs-fix >/dev/null + [[ "$(claude_args nfs-fix)" == "--resume $id --fork-session --settings"* ]] + [ "$(tmux -S "$DEVDESK_SOCKET" display -p -t =nfs-fix:claude '#{pane_current_path}')" = "$DEVDESK_PROJECTS_ROOT/alpha" ] + [ "$(jq -r '.sessions["nfs-fix"].fork // "cleared"' "$DEVDESK_STATE_DIR/registry.json")" = cleared ] + # Resuming (not forking) the same conversation while it's open elsewhere is refused. + jq --arg id "$id" '.sessions["nfs-fix"].claude_session_id = $id' "$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json" + run devdesk attach --resume-convo nfs-again "$id" + [ "$status" -ne 0 ] + [[ $output == *"open in session nfs-fix"* ]] + run devdesk attach --resume-convo nfs-fix "$id" fork + [ "$status" -ne 0 ] + [[ $output == *"already running"* ]] +} + +@test "presence marks a session while a tab shows it (#26)" { + export DEVDESK_PRESENCE_DIR="$TMP/presence" + start_server + devdesk new alpha + claude_args alpha >/dev/null + sid=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha: '#{session_id}') + run devdesk attach --presence alpha on + [ "$status" -eq 0 ] + [ -e "$TMP/presence/${sid#\$}" ] + # The Claude window was told where the marker lives. + [ "$(tmux -S "$DEVDESK_SOCKET" show-environment -t =alpha CLAUDE_CLIENT_PRESENCE_FILE 2>/dev/null || true)" = "" ] # set per process, not per session + run devdesk attach --presence alpha off + [ ! -e "$TMP/presence/${sid#\$}" ] +} diff --git a/web/index.template.html b/web/index.template.html index 50ddf3f..0cb4b26 100644 --- a/web/index.template.html +++ b/web/index.template.html @@ -63,6 +63,8 @@ + + @@ -179,6 +181,7 @@ ^⇧Pcommands +