diff --git a/README.md b/README.md index 9c26aae..83d8907 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ The tmux server runs in its own unit, so every Claude process lives in **that** - **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, with when each resets (`↻ 14:30`). If the pace so far would use a window up before it resets, the meter shows `⚠ full ~13:50` instead. A toast warns at 90 %, and Matrix gets one message per window when you're away (`DEVDESK_USAGE_WARN`). Sessions also get a **PR** chip when their branch has a pull request. +- **Settings** (⋯ → Settings…): theme, terminal text size and interface size (per browser), desktop notifications, and **Remote Control per session**. With Remote Control on, a session also shows in the Claude app and at claude.ai/code, and Anthropic stores its transcript to sync devices (30 days, or 5 years if "Help improve Claude" is on). It's **off for new sessions** unless you change the default. Each running session has its own switch, which restarts that Claude on the same conversation when it's idle. Sessions that sync show an **RC** tag. +- **Delete a conversation:** Search past conversations → **Delete…** removes its local transcript (`~/.claude/projects`) for good; a copy synced by Remote Control is deleted at claude.ai/code. - **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, plan usage and cost to `/var/lib/devdesk/context/.json`. The Sessions panel shows it as a bar and Claude's own footer shows a one-line summary. On a Pro/Max plan that summary shows 5-hour plan usage; the dollar figure Claude reports there is an API-price estimate, not a bill, so it appears only for API-key billing. - **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). @@ -91,12 +93,12 @@ The tmux server runs in its own unit, so every Claude process lives in **that** The **☀/◐** button switches light and dark mode (saved per browser, shared with the other web_template apps' `lt_theme` preference). Text size, desktop notifications (with a mute), the keyboard shortcuts sheet and fullscreen are in the **⋯** menu; everything is also in the command palette. In Chromium, fullscreen also captures Ctrl+W/Ctrl+T, which browsers otherwise keep for themselves. ## Configuration -- **Put your changes in `/etc/devdesk/env.local`.** It's read after the packaged `env` and never shipped, so `apt upgrade` never asks about it. Example: `DEVDESK_REMOTE_CONTROL=1`. +- **Put your changes in `/etc/devdesk/env.local`.** It's read after the packaged `env` and never shipped, so `apt upgrade` never asks about it. Example: `DEVDESK_GUARD=0`. - `/etc/devdesk/env` holds the packaged defaults; don't edit it. Its keys: - port, bind address, projects root and `DEVDESK_DEFAULT_DIR` - `DEVDESK_GITEA_URL` / `DEVDESK_GITEA_ALIASES` for the Repo/CI panel. Public repos need no token; for private ones put a `read:repository` token in `/etc/devdesk/gitea-token` - `CLAUDE_BIN` and `CLAUDE_ARGS` - - `DEVDESK_REMOTE_CONTROL=1`, which also starts each session with Claude Remote Control so it can be continued from the Claude app + - `DEVDESK_REMOTE_CONTROL=1`: the Remote Control default for new sessions until one is chosen in Settings (which then wins; each session can also choose its own) - `DEVDESK_PUBLIC_URL`, `DEVDESK_MATRIX_WEBHOOK_FILE`, `DEVDESK_MATRIX_DELAY`, `DEVDESK_MATRIX_QUIET` and `DEVDESK_USAGE_WARN` for the Matrix notifications - `/etc/devdesk/secret.env` is generated on install. It holds the ttyd credential that NPM must inject; see [docs/DEPLOY.md](docs/DEPLOY.md). - `/etc/devdesk/tmux.local.conf` is optional and is sourced after the packaged `tmux.conf`. diff --git a/bin/devdesk b/bin/devdesk index 4659881..cb7bbb5 100755 --- a/bin/devdesk +++ b/bin/devdesk @@ -80,6 +80,8 @@ cmd_list() { --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 rclive "$(dd_rc_live)" \ + --argjson rcdef "$(dd_rc_default)" \ --arg dflt "$DEVDESK_DEFAULT_SESSION" \ --arg root "$DEVDESK_PROJECTS_ROOT" ' def rows($s): $s | split("\n") | map(select(length > 0) | split("\t")); @@ -98,7 +100,10 @@ cmd_list() { activity: null, windows: 0, claude_session_id: (.value.claude_session_id // null), worktree: (.value.worktree // null)})) - ) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null), tags: ($tags[.name].hosts // [])}), + ) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null), tags: ($tags[.name].hosts // []), + remote_control: (($known[.name].remote_control) as $own | if $own == null then $rcdef else $own end), + rc_active: (.name as $n | $rclive | split("\n") | any(.[]; . == $n))}), + remote_control_default: $rcdef, projects: ([{name: $dflt, dir: $root}] + [rows($proj)[] | {name: .[0], git: (.[1] == "1"), dir: (.[2:] | join("\t"))} | select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}' @@ -203,6 +208,78 @@ cmd_unsplit() { jq -cMn --argjson c "$closed" '{ok: true, closed: $c}' } +# Sessions whose running Claude has Remote Control on, read from the processes +# themselves (devdesk#61), one name per line. +dd_rc_live() { + local session window pid child + dd_server_up || return 0 + while IFS=$'\t' read -r session window pid; do + [ "$window" = claude ] || continue + for child in $(pgrep -P "$pid" 2>/dev/null); do + if tr '\0' '\n' <"/proc/$child/cmdline" 2>/dev/null | grep -qx -- --remote-control; then + printf '%s\n' "$session" + break + fi + done + done < <(dd_tmux list-panes -a -F $'#{session_name}\t#{window_name}\t#{pane_pid}' 2>/dev/null) +} + +# The Claude process of a session's claude window, if any. +dd_claude_pid() { + local pid + pid=$(dd_tmux list-panes -t "=$1:claude" -F '#{pane_pid}' 2>/dev/null | head -n1) + [ -n "$pid" ] && pgrep -P "$pid" 2>/dev/null | head -n1 +} + +# --remote-control NAME on|off (devdesk#61): the session's own choice. A running, +# idle Claude restarts on the same conversation right away; one that is working +# or waiting gets it at its next start. Prints {ok, remote_control, applied}. +cmd_remote_control() { + [ $# -eq 2 ] || dd_die "usage: devdesk attach --remote-control NAME on|off" + local name=$1 value applied=saved pid state + dd_valid_name "$name" || dd_die "invalid session name: $name" + case $2 in on) value=true ;; off) value=false ;; *) dd_die "on or off" ;; esac + { session_exists "$name"; } || dd_die "no session named '$name'" + # shellcheck disable=SC2016 # jq variables + dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {remote_control: ($v == "true")})' --arg n "$name" --arg v "$value" + if dd_server_up && dd_tmux has-session -t "=$name" 2>/dev/null && pid=$(dd_claude_pid "$name") && [ -n "$pid" ]; then + state=$(jq -rM '.state // empty' "$DEVDESK_STATUS_DIR/$name.json" 2>/dev/null) || state="" # no state yet: idle + if [ "$state" = working ] || [ "$state" = waiting ]; then + applied=next-start + else + mkdir -p "$DEVDESK_STATE_DIR/restart" + touch "$DEVDESK_STATE_DIR/restart/$name" + kill -TERM "$pid" 2>/dev/null || true + applied=now + fi + fi + jq -cMn --argjson v "$value" --arg a "$applied" '{ok: true, remote_control: $v, applied: $a}' +} + +# Remote Control default for new sessions: Settings, else DEVDESK_REMOTE_CONTROL. true/false. +dd_rc_default() { + local def + def=$(jq -rM '.remote_control_default | if . == null then empty else tostring end' "$DEVDESK_STATE_DIR/settings.json" 2>/dev/null) + if [ -n "$def" ]; then echo "$def"; elif [ "${DEVDESK_REMOTE_CONTROL:-0}" = 1 ]; then echo true; else echo false; fi +} + +# --settings [remote-control-default on|off] (devdesk#61): DevDesk-wide settings +# kept in $DEVDESK_STATE_DIR/settings.json; prints them. +cmd_settings() { + local file="$DEVDESK_STATE_DIR/settings.json" tmp + if [ $# -eq 2 ]; then + [ "$1" = remote-control-default ] || dd_die "unknown setting: $1" + case $2 in on | off) ;; *) dd_die "on or off" ;; esac + tmp=$(mktemp "$DEVDESK_STATE_DIR/.settings.XXXXXX") + jq -cM --argjson v "$([ "$2" = on ] && echo true || echo false)" '. + {remote_control_default: $v}' "$file" 2>/dev/null >"$tmp" || + jq -cMn --argjson v "$([ "$2" = on ] && echo true || echo false)" '{remote_control_default: $v}' >"$tmp" + mv -f "$tmp" "$file" + elif [ $# -ne 0 ]; then + dd_die "usage: devdesk attach --settings [remote-control-default on|off]" + fi + jq -cMn --argjson d "$(dd_rc_default)" '{ok: true, remote_control_default: $d}' +} + cmd_rename() { [ $# -eq 2 ] || dd_die "usage: devdesk rename OLD NEW" local old=$1 new=$2 running=0 @@ -388,7 +465,7 @@ cmd_attach() { dd_linger return ;; - --peek | --search | --last-reply | --unsplit) + --peek | --search | --last-reply | --unsplit | --remote-control | --settings | --delete-convo) local action=${1#--} shift "cmd_${action//-/_}" "$@" diff --git a/debian/changelog b/debian/changelog index bca9f2d..478ff1b 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,22 @@ +devdesk (0.12.0) bookworm; urgency=medium + + * Settings window, from the ⋯ menu or the palette (#61): + - Theme, terminal text size and interface size (per browser). + - Desktop notifications. + - Remote Control and privacy. + The ⋯ menu shrinks to five entries. + * Remote Control per session (#61): + - A default for new sessions and a switch for each running session. + - Switching an idle session restarts its Claude on the same + conversation; a busy one changes at its next start. + - Sessions that sync show an RC tag, read from the running process. + - Server side: --remote-control and --settings. + * Delete a conversation from Search past conversations (#61): its local + transcript and subagent files, for good (--delete-convo). Refused + while a running session uses it. + + -- Jared Vititoe Tue, 03 Nov 2026 21:25:00 -0500 + devdesk (0.11.0) bookworm; urgency=medium * Explorer works like VS Code's (#54): diff --git a/etc/env b/etc/env index 5280454..75374d7 100644 --- a/etc/env +++ b/etc/env @@ -62,5 +62,7 @@ DEVDESK_USAGE_WARN=90 # /etc/devdesk/guard.local, one "regexreason" per line. DEVDESK_GUARD=1 -# 1 = also enable Remote Control so sessions can be continued from the Claude app. +# Remote Control default for new sessions (1 = on: continue them from the Claude app; +# Anthropic then stores the transcript to sync devices). Settings in the web UI +# overrides this, and each session can choose its own (devdesk#61). DEVDESK_REMOTE_CONTROL=0 diff --git a/libexec/devdesk-claude-run b/libexec/devdesk-claude-run index 75b1f13..f062b37 100755 --- a/libexec/devdesk-claude-run +++ b/libexec/devdesk-claude-run @@ -27,7 +27,8 @@ RESET=$'\033[0m' common_args() { printf '%s\0' --settings "$SETTINGS" - if [ "${DEVDESK_REMOTE_CONTROL:-0}" = "1" ]; then + # Per session since devdesk#61 (Settings → Remote Control). + if [ "$(dd_rc_wanted "$NAME")" = 1 ]; then printf '%s\0' --remote-control "devdesk-$NAME" fi if [ ${#EXTRA[@]} -gt 0 ]; then @@ -97,6 +98,15 @@ while :; do if [ "$mode" = new ]; then run_new; else run_resume; fi rc=$? NAME=$(dd_current_session) + # Settings changed Remote Control: Claude was stopped to restart on the same + # conversation with the new setting (devdesk#61), no menu. + restart="$DEVDESK_STATE_DIR/restart/$NAME" + if [ -e "$restart" ]; then + rm -f "$restart" + printf '\n%s◈ Restarting Claude with Remote Control %s…%s\n' "$MUTED" "$([ "$(dd_rc_wanted "$NAME")" = 1 ] && echo on || echo off)" "$RESET" + mode=resume + continue + fi while :; do menu "$rc" IFS= read -rsn1 key || exit 0 diff --git a/libexec/devdesk-history.sh b/libexec/devdesk-history.sh index 5cf8ecf..2b6b391 100644 --- a/libexec/devdesk-history.sh +++ b/libexec/devdesk-history.sh @@ -115,3 +115,30 @@ cmd_presence() { 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 } + +# --delete-convo ID (devdesk#61): permanently delete a conversation's local files +# (the transcript ~/.claude/projects//.jsonl and its / folder of +# subagent transcripts and tool results). Refused while a running session uses +# it. Dormant sessions that would resume it forget it (they start fresh). +# Copies kept by Anthropic (Remote Control, the API) are not touched: those are +# deleted at claude.ai. +cmd_delete_convo() { + [ $# -eq 1 ] || dd_die "usage: devdesk attach --delete-convo ID" + local id=$1 file dir other + [[ $id =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] || dd_die "bad conversation id: $id" + file=$(dd_convo_file "$id") || true + { [ -n "$file" ] && [ -f "$file" ]; } || dd_die "no conversation $id" + case "$file" in "$(dd_projects_dir)"/*/"$id.jsonl") ;; *) dd_die "unexpected path: $file" ;; esac + 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 dd_server_up && dd_tmux has-session -t "=$other" 2>/dev/null; then + dd_die "session $other is using this conversation: end it first" + fi + done + dir=${file%.jsonl} + rm -f -- "$file" + [ ! -d "$dir" ] || rm -rf -- "$dir" + # shellcheck disable=SC2016 # jq variables + dd_registry_update '.sessions |= with_entries(if .value.claude_session_id == $id then .value |= (del(.claude_session_id) | del(.claude_source)) else . end)' \ + --arg id "$id" + jq -cMn --arg id "$id" '{ok: true, deleted: $id}' +} diff --git a/libexec/devdesk-lib.sh b/libexec/devdesk-lib.sh index ee94f3b..f5db278 100644 --- a/libexec/devdesk-lib.sh +++ b/libexec/devdesk-lib.sh @@ -183,6 +183,18 @@ dd_registry_set_dir() { --arg n "$1" --arg d "$2" --arg t "$(date -Is)" } +# Remote Control for session NAME (devdesk#61): its own choice if it made one, +# else the default set in Settings (state settings.json), else +# DEVDESK_REMOTE_CONTROL from the env files. Prints 1 or 0. +dd_rc_wanted() { + local own def + # (not dd_registry_get: its `// empty` would turn false into "unset") + own=$(jq -Mr --arg n "$1" '.sessions[$n].remote_control | if . == null then empty else tostring end' "$DEVDESK_REGISTRY" 2>/dev/null) + case $own in true) echo 1; return ;; false) echo 0; return ;; esac + def=$(jq -rM '.remote_control_default // empty' "$DEVDESK_STATE_DIR/settings.json" 2>/dev/null) + case $def in true) echo 1 ;; false) echo 0 ;; *) [ "${DEVDESK_REMOTE_CONTROL:-0}" = 1 ] && echo 1 || echo 0 ;; esac +} + dd_registry_set_claude() { dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {claude_session_id: $id, claude_source: $src, updated: $t})' \ --arg n "$1" --arg id "$2" --arg src "$3" --arg t "$(date -Is)" diff --git a/tests/attach.bats b/tests/attach.bats index 7e827d9..60169ec 100644 --- a/tests/attach.bats +++ b/tests/attach.bats @@ -25,7 +25,9 @@ setup() { #!/bin/bash printf '%s\n' "$*" >>"$FAKE_LOG/$DEVDESK_SESSION" [ -z "${FAKE_EXIT:-}" ] || exit "$FAKE_EXIT" -exec sleep 300 +# Keeps its arguments in /proc/PID/cmdline (Remote Control is read from there) +# and ends on SIGTERM, like Claude. +exec bash -c 'sleep 300 & wait' fake-claude "$@" EOF chmod +x "$CLAUDE_BIN" } @@ -1338,3 +1340,71 @@ last_json() { tail -n1 <<<"$output"; } run devdesk attach --fs-grep alpha - '' [ "$status" -ne 0 ] } + +@test "Remote Control is per session: Settings default, live state, restart when idle (#61)" { + start_server + devdesk new alpha + run claude_args alpha + [[ $output != *remote-control* ]] + run devdesk attach --list + [ "$(jq -c '[.remote_control_default, (.sessions[] | select(.name=="alpha") | .remote_control, .rc_active)]' <<<"$output")" = '[false,false,false]' ] + + # Default on for new sessions + run devdesk attach --settings remote-control-default on + [ "$(jq -r .remote_control_default <<<"$output")" = true ] + devdesk new beta + run claude_args beta + [[ $output == *"--remote-control devdesk-beta"* ]] + for _ in $(seq 1 30); do + [ "$(devdesk attach --list | jq -r '.sessions[] | select(.name=="beta") | .rc_active')" = true ] && break + sleep 0.1 + done + [ "$(devdesk attach --list | jq -r '.sessions[] | select(.name=="beta") | .rc_active')" = true ] + + # alpha (idle): switching restarts Claude with the flag, no exit menu + run devdesk attach --remote-control alpha on + [ "$(jq -r .applied <<<"$output")" = now ] + for _ in $(seq 1 50); do [ "$(wc -l <"$FAKE_LOG/alpha")" -ge 2 ] && break; sleep 0.1; done + [[ "$(tail -n1 "$FAKE_LOG/alpha")" == *"--remote-control devdesk-alpha"* ]] + [ ! -e "$DEVDESK_STATE_DIR/restart/alpha" ] + + # working: saved for the next start, Claude untouched + mkdir -p "$DEVDESK_STATE_DIR/state" + printf '{"state":"working","since":1}' >"$DEVDESK_STATE_DIR/state/alpha.json" + run devdesk attach --remote-control alpha off + [ "$(jq -r .applied <<<"$output")" = next-start ] + sleep 0.5 + [ "$(wc -l <"$FAKE_LOG/alpha")" -eq 2 ] + [ "$(jq -r .sessions.alpha.remote_control "$DEVDESK_STATE_DIR/registry.json")" = false ] + # an explicit off beats the default on + [ "$(devdesk attach --list | jq -r '.sessions[] | select(.name=="alpha") | .remote_control')" = false ] + + run devdesk attach --remote-control nope on + [ "$status" -ne 0 ] + run devdesk attach --remote-control alpha maybe + [ "$status" -ne 0 ] +} + +@test "delete-convo removes a conversation's local files, never one in use (#61)" { + start_server + export CLAUDE_CONFIG_DIR="$TMP/claude" + id=11111111-2222-3333-4444-555555555555 + other=99999999-2222-3333-4444-555555555555 + d="$CLAUDE_CONFIG_DIR/projects/-x" + mkdir -p "$d/$id/subagents" "$d/$other" + echo '{}' >"$d/$id.jsonl" && echo '{}' >"$d/$id/subagents/a.jsonl" && echo '{}' >"$d/$other.jsonl" + devdesk new alpha + jq --arg id "$id" '.sessions.alpha.claude_session_id = $id | .sessions.dormant = {dir: "/tmp", claude_session_id: $id}' \ + "$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json" + run devdesk attach --delete-convo "$id" + [ "$status" -ne 0 ] && [[ $output == *"session alpha is using"* ]] && [ -f "$d/$id.jsonl" ] + devdesk end alpha + run devdesk attach --delete-convo "$id" + [ "$status" -eq 0 ] + [ ! -e "$d/$id.jsonl" ] && [ ! -e "$d/$id" ] && [ -f "$d/$other.jsonl" ] + [ "$(jq -r '.sessions.dormant.claude_session_id // "none"' "$DEVDESK_STATE_DIR/registry.json")" = none ] + run devdesk attach --delete-convo "$id" + [ "$status" -ne 0 ] + run devdesk attach --delete-convo "../../etc/passwd" + [ "$status" -ne 0 ] +} diff --git a/web/index.template.html b/web/index.template.html index b5d968c..2571867 100644 --- a/web/index.template.html +++ b/web/index.template.html @@ -53,16 +53,7 @@