feat: free-form sessions, rename/end from the panel, live Claude Context (0.2.0)

- Sessions no longer need a project folder: any name works and starts in
  DEVDESK_DEFAULT_DIR unless it matches a project; `devdesk new NAME [PROJECT]`.
- Rename (✎) and end/forget (×) sessions from the Sessions panel, plus
  `devdesk rename`. The browser runs them as `devdesk attach --new|--rename|--end`
  through ttyd. Renames keep the running Claude and its recorded conversation:
  the hook, the run-wrapper and the statusline ask tmux for the current name.
- Claude Context panel is live: devdesk-statusline snapshots model,
  context-window use, cost and edits per session, which --list reports;
  Claude's footer gets a one-line summary. The panel now sits under Sessions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 03:03:09 -04:00
co-authored by Claude Opus 5.5
parent 4b153c1960
commit 93958b10c9
22 changed files with 703 additions and 171 deletions
+1 -1
View File
@@ -8,6 +8,6 @@
"no-undef": "error", "no-undef": "error",
"no-empty": "warn", "no-empty": "warn",
"semi": ["error", "always"], "semi": ["error", "always"],
"eqeqeq": "error" "eqeqeq": ["error", "always", { "null": "ignore" }]
} }
} }
+1 -1
View File
@@ -39,7 +39,7 @@ install: all
install -m 0755 bin/devdesk $(DESTDIR)$(PREFIX)/bin/devdesk install -m 0755 bin/devdesk $(DESTDIR)$(PREFIX)/bin/devdesk
install -m 0644 libexec/devdesk-lib.sh $(DESTDIR)$(LIBDIR)/devdesk-lib.sh 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 \ install -m 0755 libexec/devdesk-picker libexec/devdesk-claude-run libexec/devdesk-session-hook \
libexec/devdesk-tmux-ready libexec/devdesk-serve $(DESTDIR)$(LIBDIR)/ libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline $(DESTDIR)$(LIBDIR)/
install -m 0755 build/ttyd $(DESTDIR)$(LIBDIR)/bin/ttyd install -m 0755 build/ttyd $(DESTDIR)$(LIBDIR)/bin/ttyd
install -m 0644 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/ install -m 0644 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/
install -m 0644 etc/env $(DESTDIR)$(SYSCONFDIR)/devdesk/env install -m 0644 etc/env $(DESTDIR)$(SYSCONFDIR)/devdesk/env
+12 -5
View File
@@ -22,12 +22,19 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
- **Only `systemctl stop devdesk-tmux` (or removing the package) ends the sessions.** - **Only `systemctl stop devdesk-tmux` (or removing the package) ends the sessions.**
## Sessions ## Sessions
- **How the session is chosen:** - **Any name works.** Sessions don't have to be repo work:
- `https://dev.lotusguild.org/?arg=<name>` opens session `<name>` and creates it on demand in `$DEVDESK_PROJECTS_ROOT/<name>`. `main` uses the root itself, and folders with spaces are reached by slug (`test code` → `test-code`). - A name matching a project folder (`tinker_tickets`, or `test-code` for `test code`) starts there.
- Anything else (`pbs-upgrade`, `ceph-check`) starts in `$DEVDESK_DEFAULT_DIR`, which is `/root/code` by default.
- `main` is the projects root.
- **Opening one:**
- `https://dev.lotusguild.org/?arg=<name>` opens or creates that session.
- With no `?arg=`, an fzf picker opens, listing running (●), dormant (○) and new (+) sessions. - With no `?arg=`, an fzf picker opens, listing running (●), dormant (○) and new (+) sessions.
- The **Sessions** panel's form takes any name plus an optional project folder.
- **Rename / end:** the ✎ and × buttons on each session in the panel. Renaming keeps the session, its running Claude and its recorded conversation. Ending stops Claude; the conversation stays in Claude's history.
- **Windows:** each session has a `claude` window (0) and a `shell` window (1). - **Windows:** each session has a `claude` window (0) and a `shell` window (1).
- **Conversation tracking:** a Claude `SessionStart` hook records each session's conversation ID in `/var/lib/devdesk/registry.json`. After a reboot the session resumes *that* conversation, even when other Claude instances, such as code-server, use the same directory. - **Conversation tracking:** a Claude `SessionStart` hook records each session's conversation ID in `/var/lib/devdesk/registry.json`. After a reboot the session resumes *that* conversation, even when other Claude instances, such as code-server, use the same directory.
- **SSH:** `devdesk attach <name>`, `devdesk ls`, `devdesk pick`, `devdesk end <name>`. - **Claude Context panel:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use, cost and edits to `/var/lib/devdesk/context/<session>.json`. The panel shows the current session's numbers, and Claude's own footer shows a one-line summary. Sessions started before 0.2.0 need Claude restarted (`/exit`, then ⏎ in the menu) before they report.
- **SSH:** `devdesk attach <name>`, `devdesk new <name> [project]`, `devdesk rename <old> <new>`, `devdesk end <name>`, `devdesk ls`, `devdesk pick`.
## Keys ## Keys
| Key | Action | | Key | Action |
@@ -44,7 +51,7 @@ Use the **Full** button for fullscreen. In Chromium it also captures Ctrl+W/Ctrl
## Configuration ## Configuration
- `/etc/devdesk/env` (conffile): - `/etc/devdesk/env` (conffile):
- port, bind address and projects root - port, bind address, projects root and `DEVDESK_DEFAULT_DIR`
- `CLAUDE_BIN` and `CLAUDE_ARGS` - `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`, which also starts each session with Claude Remote Control so it can be continued from the Claude app
- `/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/secret.env` is generated on install. It holds the ttyd credential that NPM must inject; see [docs/DEPLOY.md](docs/DEPLOY.md).
@@ -65,5 +72,5 @@ CI (`.gitea/workflows/build-deb.yml`):
- A `v*` tag also publishes to `https://code.lotusguild.org/api/packages/LotusGuild/debian` and creates a release. - A `v*` tag also publishes to `https://code.lotusguild.org/api/packages/LotusGuild/debian` and creates a release.
## Roadmap ## Roadmap
- **v1.1:** `devdesk-api` sidecar for the Repo/CI and Claude Context panels, Matrix notifications when you're away, and auto-deploy. - **v1.1:** `devdesk-api` sidecar for the Repo/CI panel, Matrix notifications when you're away, and auto-deploy.
- **v2:** Ops Alerts (Grafana) with "Ask Claude" prompts, and file drop. - **v2:** Ops Alerts (Grafana) with "Ask Claude" prompts, and file drop.
+82 -35
View File
@@ -1,18 +1,22 @@
#!/bin/bash #!/bin/bash
# devdesk — persistent Claude Code sessions in tmux, shared by the browser UI and SSH. # devdesk — persistent Claude Code sessions in tmux, shared by the browser UI and SSH.
# #
# devdesk attach [NAME] attach to session NAME (created on demand); no NAME opens the picker # devdesk attach [NAME] attach to session NAME, creating it if needed; no NAME opens the picker
# devdesk attach --list print sessions/projects as JSON (used by the web UI over ttyd) # devdesk new NAME [PROJECT] create session NAME (detached) in PROJECT's folder, or the default folder
# devdesk new NAME create session NAME detached, if it does not exist # devdesk rename OLD NEW rename a session (running or dormant)
# devdesk ls list sessions # devdesk end NAME stop session NAME and forget it
# devdesk end NAME end session NAME and forget it # devdesk ls list sessions
# devdesk pick interactive picker
#
# The web UI calls `devdesk attach --list | --new NAME [PROJECT] | --rename OLD NEW | --end NAME`
# through ttyd; those print JSON.
set -euo pipefail set -euo pipefail
# shellcheck source=libexec/devdesk-lib.sh # shellcheck source=libexec/devdesk-lib.sh
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" . "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
usage() { usage() {
sed -n '2,9s/^# \{0,1\}//p' "$0" sed -n '2,10s/^# \{0,1\}//p' "$0"
} }
# Tab-separated running sessions: name, dir, attached clients, last activity, windows. # Tab-separated running sessions: name, dir, attached clients, last activity, windows.
@@ -36,6 +40,17 @@ projects_tsv() {
done done
} }
# {"<session>": {model, used_pct, ...}} from the status-line snapshots.
context_json() {
local f
if [ -d "$DEVDESK_CONTEXT_DIR" ]; then
for f in "$DEVDESK_CONTEXT_DIR"/*.json; do
[ -f "$f" ] || continue
jq -cM --arg n "$(basename "$f" .json)" '{($n): .}' "$f" 2>/dev/null || true
done
fi | jq -cMs 'add // {}'
}
cmd_list() { cmd_list() {
local reg='{"sessions":{}}' local reg='{"sessions":{}}'
[ -s "$DEVDESK_REGISTRY" ] && reg=$(cat "$DEVDESK_REGISTRY") [ -s "$DEVDESK_REGISTRY" ] && reg=$(cat "$DEVDESK_REGISTRY")
@@ -43,6 +58,7 @@ cmd_list() {
--argjson reg "$reg" \ --argjson reg "$reg" \
--arg run "$(running_tsv)" \ --arg run "$(running_tsv)" \
--arg proj "$(projects_tsv)" \ --arg proj "$(projects_tsv)" \
--argjson ctx "$(context_json)" \
--arg dflt "$DEVDESK_DEFAULT_SESSION" \ --arg dflt "$DEVDESK_DEFAULT_SESSION" \
--arg root "$DEVDESK_PROJECTS_ROOT" ' --arg root "$DEVDESK_PROJECTS_ROOT" '
def rows($s): $s | split("\n") | map(select(length > 0) | split("\t")); def rows($s): $s | split("\n") | map(select(length > 0) | split("\t"));
@@ -59,7 +75,7 @@ cmd_list() {
| map({name: .key, dir: .value.dir, running: false, attached: 0, | map({name: .key, dir: .value.dir, running: false, attached: 0,
activity: null, windows: 0, activity: null, windows: 0,
claude_session_id: (.value.claude_session_id // null)})) claude_session_id: (.value.claude_session_id // null)}))
), ) | map(. + {context: ($ctx[.name] // null)}),
projects: ([{name: $dflt, dir: $root}] projects: ([{name: $dflt, dir: $root}]
+ [rows($proj)[] | {name: .[0], dir: .[1]} + [rows($proj)[] | {name: .[0], dir: .[1]}
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}' | select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
@@ -68,21 +84,14 @@ cmd_list() {
cmd_ls() { cmd_ls() {
cmd_list | jq -Mr ' cmd_list | jq -Mr '
.sessions | if length == 0 then "no sessions" else .sessions | if length == 0 then "no sessions" else
(["", "SESSION", "CLIENTS", "DIRECTORY"] | @tsv), (["", "SESSION", "CLIENTS", "CONTEXT", "DIRECTORY"] | @tsv),
(.[] | [(if .running then "●" else "○" end), .name, (.attached | tostring), .dir] | @tsv) (.[] | [(if .running then "●" else "○" end), .name, (.attached | tostring),
(if .context.used_pct then "\(.context.used_pct | floor)%" else "-" end), .dir] | @tsv)
end' | column -t -s $'\t' end' | column -t -s $'\t'
} }
# Prints the project directory for NAME, or "" if NAME already runs and has none we can resolve. session_exists() {
session_dir() { { dd_server_up && dd_tmux has-session -t "=$1" 2>/dev/null; } || [ -n "$(dd_registry_get "$1" dir)" ]
local name=$1 dir
if dir=$(dd_resolve_dir "$name"); then
printf '%s\n' "$dir"
elif dd_tmux has-session -t "=$name" 2>/dev/null; then
printf '%s\n' "$DEVDESK_PROJECTS_ROOT"
else
dd_die "no project directory for '$name' under $DEVDESK_PROJECTS_ROOT"
fi
} }
create_detached() { create_detached() {
@@ -96,27 +105,73 @@ create_detached() {
} }
cmd_new() { cmd_new() {
[ $# -eq 1 ] || dd_die "usage: devdesk new NAME" { [ $# -ge 1 ] && [ $# -le 2 ]; } || dd_die "usage: devdesk new NAME [PROJECT]"
dd_valid_name "$1" || dd_die "invalid session name: $1" dd_valid_name "$1" || dd_die "invalid session name: $1"
dd_ensure_server
local dir local dir
dir=$(session_dir "$1") if [ $# -eq 2 ]; then
dd_valid_name "$2" || dd_die "invalid project name: $2"
dir=$(dd_project_dir "$2") || dd_die "no project '$2' under $DEVDESK_PROJECTS_ROOT"
! session_exists "$1" || dd_die "a session named '$1' already exists"
else
dir=$(dd_resolve_dir "$1")
fi
dd_ensure_server
create_detached "$1" "$dir" create_detached "$1" "$dir"
} }
cmd_rename() {
[ $# -eq 2 ] || dd_die "usage: devdesk rename OLD NEW"
local old=$1 new=$2 running=0
dd_valid_name "$old" || dd_die "invalid session name: $old"
dd_valid_name "$new" || dd_die "invalid session name: $new"
[ "$old" != "$new" ] || return 0
if dd_server_up && dd_tmux has-session -t "=$old" 2>/dev/null; then running=1; fi
[ "$running" = 1 ] || [ -n "$(dd_registry_get "$old" dir)" ] || dd_die "no session named '$old'"
! session_exists "$new" || dd_die "a session named '$new' already exists"
if [ "$running" = 1 ]; then
dd_tmux rename-session -t "=$old" "$new"
dd_tmux set-environment -t "=$new" DEVDESK_SESSION "$new"
fi
dd_registry_rename "$old" "$new"
}
cmd_end() {
[ $# -eq 1 ] || dd_die "usage: devdesk end NAME"
dd_valid_name "$1" || dd_die "invalid session name: $1"
if dd_server_up; then
dd_tmux kill-session -t "=$1" 2>/dev/null || true
fi
dd_registry_remove "$1"
}
ok_json() {
printf '{"ok":true}\n'
}
cmd_attach() { cmd_attach() {
if [ $# -eq 0 ]; then if [ $# -eq 0 ]; then
exec "$DEVDESK_LIBEXEC/devdesk-picker" exec "$DEVDESK_LIBEXEC/devdesk-picker"
fi fi
[ $# -eq 1 ] || dd_die "attach takes exactly one session name" case $1 in
if [ "$1" = "--list" ]; then --list)
[ $# -eq 1 ] || dd_die "--list takes no arguments"
cmd_list cmd_list
return return
fi ;;
--new | --rename | --end)
local action=${1#--}
shift
"cmd_$action" "$@"
ok_json
return
;;
-*) dd_die "unknown option: $1" ;;
esac
[ $# -eq 1 ] || dd_die "attach takes exactly one session name"
local name=$1 dir args local name=$1 dir args
dd_valid_name "$name" || dd_die "invalid session name: $name" dd_valid_name "$name" || dd_die "invalid session name: $name"
dd_ensure_server dd_ensure_server
dir=$(session_dir "$name") dir=$(dd_resolve_dir "$name")
# Already inside a DevDesk pane: switch this client instead of nesting. # Already inside a DevDesk pane: switch this client instead of nesting.
local outer=${TMUX:-} local outer=${TMUX:-}
@@ -134,20 +189,12 @@ cmd_attach() {
exec tmux -N -u -S "$DEVDESK_SOCKET" new-session -A "${args[@]}" exec tmux -N -u -S "$DEVDESK_SOCKET" new-session -A "${args[@]}"
} }
cmd_end() {
[ $# -eq 1 ] || dd_die "usage: devdesk end NAME"
dd_valid_name "$1" || dd_die "invalid session name: $1"
if dd_server_up; then
dd_tmux kill-session -t "=$1" 2>/dev/null || true
fi
dd_registry_remove "$1"
}
cmd=${1:-help} cmd=${1:-help}
[ $# -gt 0 ] && shift [ $# -gt 0 ] && shift
case $cmd in case $cmd in
attach | a) cmd_attach "$@" ;; attach | a) cmd_attach "$@" ;;
new) cmd_new "$@" ;; new) cmd_new "$@" ;;
rename | mv) cmd_rename "$@" ;;
ls | list) cmd_ls ;; ls | list) cmd_ls ;;
--list) cmd_list ;; --list) cmd_list ;;
end) cmd_end "$@" ;; end) cmd_end "$@" ;;
+15
View File
@@ -1,3 +1,18 @@
devdesk (0.2.0) bookworm; urgency=medium
* Sessions no longer need a project folder: any name works and starts in
DEVDESK_DEFAULT_DIR unless it matches a project; new NAME [PROJECT].
* Rename and end sessions from the Sessions panel (and devdesk rename).
Renames keep the running Claude and its recorded conversation.
* Claude Context panel is live: a statusLine command snapshots model,
context-window use, cost and edits per session; Claude's footer shows a
one-line summary.
* Drop GANDALF from the Ops Alerts panel.
* CI: bash shell, download retries, Node 22 test invocation,
build-essential, smoke-test pipe fix.
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 03:01:21 -0400
devdesk (0.1.0) bookworm; urgency=medium devdesk (0.1.0) bookworm; urgency=medium
* Initial release: persistent browser terminal for Claude Code * Initial release: persistent browser terminal for Claude Code
+3 -1
View File
@@ -44,7 +44,9 @@ Enable the Proxmox firewall on CT 126 (`firewall=1` on net0) and allow tcp/7681
|---|---| |---|---|
| Sessions | `devdesk ls` | | Sessions | `devdesk ls` |
| Attach over SSH | `devdesk attach <name>` (or `devdesk pick`) | | Attach over SSH | `devdesk attach <name>` (or `devdesk pick`) |
| End a session | `devdesk end <name>` (or `[x]` in the Claude window's exit menu) | | New session (optionally in a project) | `devdesk new <name> [project]`, or the Sessions panel form |
| Rename a session | `devdesk rename <old> <new>`, or ✎ in the Sessions panel |
| End a session | `devdesk end <name>`, × in the Sessions panel, or `[x]` in the Claude window's exit menu |
| Restart the web side (safe) | `systemctl restart devdesk` | | Restart the web side (safe) | `systemctl restart devdesk` |
| **Stop every session** | `systemctl stop devdesk-tmux` | | **Stop every session** | `systemctl stop devdesk-tmux` |
| Logs | `journalctl -u devdesk -u devdesk-tmux` | | Logs | `journalctl -u devdesk -u devdesk-tmux` |
+14 -6
View File
@@ -7,6 +7,10 @@ devdesk \- persistent Claude Code sessions in tmux, shared by the browser and SS
.br .br
.B devdesk new .B devdesk new
.I NAME .I NAME
.RI [ PROJECT ]
.br
.B devdesk rename
.I OLD NEW
.br .br
.B devdesk ls .B devdesk ls
.br .br
@@ -26,16 +30,20 @@ resumes its own conversation.
.SH COMMANDS .SH COMMANDS
.TP .TP
.B attach \fR[\fINAME\fR] .B attach \fR[\fINAME\fR]
Attach to session \fINAME\fR, creating it if needed. The default session Attach to session \fINAME\fR, creating it if needed. Names matching a project
(\fBmain\fR) uses the projects root; other names map to the project directory of folder (or its slug: \(lqtest code\(rq becomes \fBtest-code\fR) start there; any
that name or slug (\(lqtest code\(rq becomes \fBtest-code\fR). Without \fINAME\fR, other name starts in \fBDEVDESK_DEFAULT_DIR\fR. Without \fINAME\fR, opens the picker.
opens the picker.
.TP .TP
.B attach --list .B attach --list
Print sessions and projects as JSON (used by the web UI). Print sessions and projects as JSON (used by the web UI).
.TP .TP
.B new \fINAME\fR .B new \fINAME\fR [\fIPROJECT\fR]
Create session \fINAME\fR detached, if it does not exist. Create session \fINAME\fR detached, in \fIPROJECT\fR's folder if given, otherwise
in the folder its name maps to (a project of that name, or \fBDEVDESK_DEFAULT_DIR\fR).
.TP
.B rename \fIOLD NEW\fR
Rename a running or dormant session; Claude keeps running and its recorded
conversation moves with it.
.TP .TP
.B ls .B ls
List running (\(bu) and dormant (\(ci) sessions. List running (\(bu) and dormant (\(ci) sessions.
+3
View File
@@ -12,6 +12,9 @@ DEVDESK_BIND=
DEVDESK_PROJECTS_ROOT=/root/code DEVDESK_PROJECTS_ROOT=/root/code
DEVDESK_DEFAULT_SESSION=main DEVDESK_DEFAULT_SESSION=main
# Where sessions that are not a project folder start (e.g. ops sessions); empty = projects root.
DEVDESK_DEFAULT_DIR=
# Claude Code binary and extra arguments for every session. # Claude Code binary and extra arguments for every session.
CLAUDE_BIN=/root/.local/bin/claude CLAUDE_BIN=/root/.local/bin/claude
CLAUDE_ARGS= CLAUDE_ARGS=
+5 -1
View File
@@ -7,7 +7,9 @@ set -uo pipefail
# shellcheck source=libexec/devdesk-lib.sh # shellcheck source=libexec/devdesk-lib.sh
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" . "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
NAME=${DEVDESK_SESSION:?DEVDESK_SESSION is not set} NAME=$(dd_current_session)
[ -n "$NAME" ] || dd_die "not running inside a DevDesk session"
CLAUDE_BIN=${CLAUDE_BIN:-claude} CLAUDE_BIN=${CLAUDE_BIN:-claude}
SETTINGS="$DEVDESK_SHARE/claude-settings.json" SETTINGS="$DEVDESK_SHARE/claude-settings.json"
read -r -a EXTRA <<<"${CLAUDE_ARGS:-}" read -r -a EXTRA <<<"${CLAUDE_ARGS:-}"
@@ -63,8 +65,10 @@ menu() {
printf '\033]2;%s\033\134' "$NAME" # pane title (ST = ESC \\) printf '\033]2;%s\033\134' "$NAME" # pane title (ST = ESC \\)
mode=resume mode=resume
while :; do while :; do
NAME=$(dd_current_session) # follow renames
if [ "$mode" = new ]; then run_new; else run_resume; fi if [ "$mode" = new ]; then run_new; else run_resume; fi
rc=$? rc=$?
NAME=$(dd_current_session)
while :; do while :; do
menu "$rc" menu "$rc"
IFS= read -rsn1 key || exit 0 IFS= read -rsn1 key || exit 0
+45 -18
View File
@@ -11,6 +11,7 @@ if [ -r "$DEVDESK_ENV" ]; then
fi fi
: "${DEVDESK_PROJECTS_ROOT:=/root/code}" : "${DEVDESK_PROJECTS_ROOT:=/root/code}"
: "${DEVDESK_DEFAULT_DIR:=$DEVDESK_PROJECTS_ROOT}"
: "${DEVDESK_DEFAULT_SESSION:=main}" : "${DEVDESK_DEFAULT_SESSION:=main}"
: "${DEVDESK_SOCKET:=/run/devdesk/tmux.sock}" : "${DEVDESK_SOCKET:=/run/devdesk/tmux.sock}"
: "${DEVDESK_STATE_DIR:=/var/lib/devdesk}" : "${DEVDESK_STATE_DIR:=/var/lib/devdesk}"
@@ -19,6 +20,7 @@ fi
: "${DEVDESK_TMUX_UNIT:=devdesk-tmux.service}" : "${DEVDESK_TMUX_UNIT:=devdesk-tmux.service}"
DEVDESK_REGISTRY="$DEVDESK_STATE_DIR/registry.json" DEVDESK_REGISTRY="$DEVDESK_STATE_DIR/registry.json"
DEVDESK_CONTEXT_DIR="$DEVDESK_STATE_DIR/context"
DEVDESK_NAME_RE='^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$' DEVDESK_NAME_RE='^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$'
dd_die() { dd_die() {
@@ -99,29 +101,30 @@ dd_registry_set_claude() {
dd_registry_remove() { dd_registry_remove() {
dd_registry_update 'del(.sessions[$n])' --arg n "$1" dd_registry_update 'del(.sessions[$n])' --arg n "$1"
rm -f "$DEVDESK_CONTEXT_DIR/$1.json"
} }
# Session name -> absolute project directory (must resolve inside the projects root). dd_registry_rename() {
dd_resolve_dir() { dd_registry_update 'if .sessions[$old] then .sessions[$new] = .sessions[$old] | del(.sessions[$old]) else . end' \
--arg old "$1" --arg new "$2"
if [ -f "$DEVDESK_CONTEXT_DIR/$1.json" ]; then
mv -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_CONTEXT_DIR/$2.json"
fi
}
# Project name (directory or its slug) -> absolute directory inside the projects root.
dd_project_dir() {
local name=$1 dir="" d root local name=$1 dir="" d root
if [ "$name" = "$DEVDESK_DEFAULT_SESSION" ]; then if [ -d "$DEVDESK_PROJECTS_ROOT/$name" ]; then
dir=$DEVDESK_PROJECTS_ROOT dir="$DEVDESK_PROJECTS_ROOT/$name"
else else
dir=$(dd_registry_get "$name" dir) for d in "$DEVDESK_PROJECTS_ROOT"/*/; do
if [ -z "$dir" ] || [ ! -d "$dir" ]; then d=${d%/}
dir="" if [ "$(dd_slugify "${d##*/}")" = "$name" ]; then
if [ -d "$DEVDESK_PROJECTS_ROOT/$name" ]; then dir=$d
dir="$DEVDESK_PROJECTS_ROOT/$name" break
else
for d in "$DEVDESK_PROJECTS_ROOT"/*/; do
d=${d%/}
if [ "$(dd_slugify "${d##*/}")" = "$name" ]; then
dir=$d
break
fi
done
fi fi
fi done
fi fi
[ -n "$dir" ] || return 1 [ -n "$dir" ] || return 1
dir=$(realpath -e -- "$dir" 2>/dev/null) || return 1 dir=$(realpath -e -- "$dir" 2>/dev/null) || return 1
@@ -132,6 +135,30 @@ dd_resolve_dir() {
esac esac
} }
# Session name -> working directory. Sessions don't need a project: the
# registry wins, then a project of the same name, then DEVDESK_DEFAULT_DIR.
dd_resolve_dir() {
local name=$1 dir
dir=$(dd_registry_get "$name" dir)
if [ -n "$dir" ] && [ -d "$dir" ]; then
printf '%s\n' "$dir"
elif [ "$name" != "$DEVDESK_DEFAULT_SESSION" ] && dir=$(dd_project_dir "$name"); then
printf '%s\n' "$dir"
else
realpath -e -- "$DEVDESK_DEFAULT_DIR" 2>/dev/null || printf '%s\n' "$HOME"
fi
}
# Name of the DevDesk session this process runs in. Asks tmux, so it stays
# right after a rename; falls back to the DEVDESK_SESSION set at creation.
dd_current_session() {
local name=""
if [ -n "${TMUX:-}" ] && [ -n "${TMUX_PANE:-}" ]; then
name=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null)
fi
printf '%s\n' "${name:-${DEVDESK_SESSION:-}}"
}
# Arguments for `tmux new-session` that create session NAME in DIR running Claude. # Arguments for `tmux new-session` that create session NAME in DIR running Claude.
dd_new_session_args() { dd_new_session_args() {
printf '%s\0' -s "$1" -c "$2" -n claude -e "DEVDESK_SESSION=$1" "$DEVDESK_LIBEXEC/devdesk-claude-run" printf '%s\0' -s "$1" -c "$2" -n claude -e "DEVDESK_SESSION=$1" "$DEVDESK_LIBEXEC/devdesk-claude-run"
+4 -3
View File
@@ -7,13 +7,14 @@
# shellcheck source=libexec/devdesk-lib.sh # shellcheck source=libexec/devdesk-lib.sh
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" || exit 0 . "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" || exit 0
[ -n "${DEVDESK_SESSION:-}" ] || exit 0 session=$(dd_current_session)
dd_valid_name "$DEVDESK_SESSION" || exit 0 [ -n "$session" ] || exit 0
dd_valid_name "$session" || exit 0
input=$(cat) input=$(cat)
id=$(jq -r '.session_id // empty' <<<"$input" 2>/dev/null) id=$(jq -r '.session_id // empty' <<<"$input" 2>/dev/null)
source=$(jq -r '.source // empty' <<<"$input" 2>/dev/null) source=$(jq -r '.source // empty' <<<"$input" 2>/dev/null)
[ -n "$id" ] || exit 0 [ -n "$id" ] || exit 0
dd_registry_set_claude "$DEVDESK_SESSION" "$id" "$source" 2>/dev/null dd_registry_set_claude "$session" "$id" "$source" 2>/dev/null
exit 0 exit 0
+48
View File
@@ -0,0 +1,48 @@
#!/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.
# Runs often while Claude streams, so it stays cheap and never fails loudly.
input=$(cat)
session=""
if [ -n "${TMUX:-}" ] && [ -n "${TMUX_PANE:-}" ]; then
session=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null)
fi
session=${session:-${DEVDESK_SESSION:-}}
if [[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]]; then
dir="${DEVDESK_STATE_DIR:-/var/lib/devdesk}/context"
if mkdir -p "$dir" 2>/dev/null && tmp=$(mktemp "$dir/.$session.XXXXXX" 2>/dev/null); then
if jq -cM '{
updated: (now | floor),
claude_session_id: .session_id,
model: .model.display_name,
model_id: .model.id,
effort: .effort.level,
used_pct: .context_window.used_percentage,
window: .context_window.context_window_size,
used_tokens: (.context_window.current_usage
| if . then ((.input_tokens // 0) + (.cache_creation_input_tokens // 0)
+ (.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
}' <<<"$input" >"$tmp" 2>/dev/null; then
mv -f "$tmp" "$dir/$session.json"
else
rm -f "$tmp"
fi
fi
fi
# Footer line, in TDS colours: session · model · context % · cost
jq -rM --arg s "$session" '
def c($rgb; $t): "\u001b[38;2;\($rgb)m\($t)\u001b[0m";
def pct: if . == null then "—" else "\(. | floor)%" end;
[ (if $s != "" then c("255;107;0"; "◈ \($s)") else empty end),
c("0;212;255"; .model.display_name // "Claude"),
c("127;163;191"; "ctx \(.context_window.used_percentage | pct)"),
c("62;96;122"; "$\((.cost.total_cost_usd // 0) * 100 | round / 100)")
] | join(c("62;96;122"; " · "))' <<<"$input" 2>/dev/null || true
+5
View File
@@ -12,5 +12,10 @@
] ]
} }
] ]
},
"statusLine": {
"type": "command",
"command": "/usr/lib/devdesk/devdesk-statusline",
"padding": 0
} }
} }
+107 -3
View File
@@ -46,11 +46,14 @@ claude_args() {
@test "rejects invalid session names" { @test "rejects invalid session names" {
start_server start_server
for bad in ../etc "a b" -x .hidden "$(printf 'x%.0s' {1..70})"; do for bad in ../etc "a b" .hidden "$(printf 'x%.0s' {1..70})"; do
run devdesk attach "$bad" run devdesk attach "$bad"
[ "$status" -ne 0 ] [ "$status" -ne 0 ]
[[ $output == *"invalid session name"* ]] [[ $output == *"invalid session name"* ]]
done done
run devdesk attach -x
[ "$status" -ne 0 ]
[[ $output == *"unknown option"* ]]
run devdesk attach one two run devdesk attach one two
[ "$status" -ne 0 ] [ "$status" -ne 0 ]
} }
@@ -98,12 +101,113 @@ claude_args() {
[ "$output" = "$DEVDESK_PROJECTS_ROOT/test code" ] [ "$output" = "$DEVDESK_PROJECTS_ROOT/test code" ]
} }
@test "sessions without a project start in the default folder" {
start_server
run devdesk new pbs-upgrade
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" display -p -t =pbs-upgrade:claude '#{pane_current_path}'
[ "$output" = "$DEVDESK_PROJECTS_ROOT" ]
}
@test "symlinks may not escape the projects root" { @test "symlinks may not escape the projects root" {
start_server start_server
run devdesk new escape run devdesk new escape
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" display -p -t =escape:claude '#{pane_current_path}'
[ "$output" = "$DEVDESK_PROJECTS_ROOT" ]
run devdesk new other escape
[ "$status" -ne 0 ] [ "$status" -ne 0 ]
[[ $output == *"no project directory"* ]] [[ $output == *"no project 'escape'"* ]]
run devdesk new nonexistent }
@test "new NAME PROJECT starts the session in that project" {
start_server
run devdesk new ops-alpha alpha
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" display -p -t =ops-alpha:claude '#{pane_current_path}'
[ "$output" = "$DEVDESK_PROJECTS_ROOT/alpha" ]
run devdesk new ops-alpha alpha
[ "$status" -ne 0 ]
[[ $output == *"already exists"* ]]
}
@test "rename moves a running session, its registry entry and its context" {
start_server
devdesk new alpha
echo '{"session_id":"abc-123","source":"startup"}' | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook"
mkdir -p "$DEVDESK_STATE_DIR/context" && echo '{"used_pct":12}' >"$DEVDESK_STATE_DIR/context/alpha.json"
run devdesk rename alpha ops
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" has-session -t =ops
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" show-environment -t =ops DEVDESK_SESSION
[ "$output" = "DEVDESK_SESSION=ops" ]
[ "$(jq -r '.sessions.ops.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = abc-123 ]
[ "$(jq -r '.sessions | has("alpha")' "$DEVDESK_STATE_DIR/registry.json")" = false ]
[ -f "$DEVDESK_STATE_DIR/context/ops.json" ]
[ ! -e "$DEVDESK_STATE_DIR/context/alpha.json" ]
}
@test "rename refuses clashes and unknown sessions" {
start_server
devdesk new alpha
devdesk new main
run devdesk rename alpha main
[ "$status" -ne 0 ]
[[ $output == *"already exists"* ]]
run devdesk rename nosuch other
[ "$status" -ne 0 ]
[[ $output == *"no session named"* ]]
run devdesk rename alpha "bad name"
[ "$status" -ne 0 ]
}
@test "rename works for dormant sessions" {
echo '{"session_id":"old-1","source":"resume"}' | DEVDESK_SESSION=gone "$REPO/libexec/devdesk-session-hook"
jq '.sessions.gone.dir = "/tmp"' "$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json"
run devdesk rename gone archived
[ "$status" -eq 0 ]
[ "$(jq -r '.sessions.archived.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = old-1 ]
}
@test "hooks follow the tmux session name after a rename" {
start_server
devdesk new alpha
devdesk rename alpha renamed
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =renamed:shell '#{pane_id}')
echo '{"session_id":"after-rename","source":"clear"}' |
TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook"
[ "$(jq -r '.sessions.renamed.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = after-rename ]
[ "$(jq -r '.sessions | has("alpha")' "$DEVDESK_STATE_DIR/registry.json")" = false ]
}
@test "statusline snapshots context for the session and --list reports it" {
start_server
devdesk new alpha
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
status_json='{"session_id":"s1","model":{"id":"m","display_name":"Opus"},"effort":{"level":"high"},
"context_window":{"context_window_size":1000000,"used_percentage":34.6,
"current_usage":{"input_tokens":1000,"cache_creation_input_tokens":2000,"cache_read_input_tokens":343000}},
"cost":{"total_cost_usd":1.5,"total_lines_added":10,"total_lines_removed":2}}'
run bash -c "echo '$status_json' | TMUX='$DEVDESK_SOCKET,1,0' TMUX_PANE='$pane' '$REPO/libexec/devdesk-statusline'"
[ "$status" -eq 0 ]
[[ $output == *"alpha"* && $output == *"ctx 34%"* ]]
run devdesk attach --list
[ "$(jq -r '.sessions[] | select(.name=="alpha") | .context.used_tokens' <<<"$output")" = 346000 ]
[ "$(jq -r '.sessions[] | select(.name=="alpha") | .context.model' <<<"$output")" = Opus ]
}
@test "web actions run through attach and answer with JSON" {
start_server
run devdesk attach --new web1 alpha
[ "$output" = '{"ok":true}' ]
run devdesk attach --rename web1 web2
[ "$output" = '{"ok":true}' ]
run devdesk attach --end web2
[ "$output" = '{"ok":true}' ]
run tmux -S "$DEVDESK_SOCKET" has-session -t =web2
[ "$status" -ne 0 ]
run devdesk attach --bogus
[ "$status" -ne 0 ] [ "$status" -ne 0 ]
} }
+14 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
Client, FlowControl, decodeServer, encodeBinaryInput, encodeInit, encodeInput, encodeResize, Client, FlowControl, decodeServer, encodeBinaryInput, encodeInit, encodeInput, encodeResize,
parseListOutput, parseTitle, sessionFromSearch, tokenUrl, wsUrl, parseListOutput, parseTitle, sessionFromSearch, slugify, stripAnsi, tokenUrl, wsUrl,
} from '../web/src/protocol.js'; } from '../web/src/protocol.js';
const text = (bytes) => new TextDecoder().decode(bytes); const text = (bytes) => new TextDecoder().decode(bytes);
@@ -34,6 +34,19 @@ test('URLs follow the page location, including a path prefix', () => {
assert.equal(tokenUrl(loc), 'https://dev.lotusguild.org/token'); assert.equal(tokenUrl(loc), 'https://dev.lotusguild.org/token');
const sub = { protocol: 'http:', host: 'localhost:7681', pathname: '/desk/index.html' }; const sub = { protocol: 'http:', host: 'localhost:7681', pathname: '/desk/index.html' };
assert.equal(wsUrl(sub, '--list'), 'ws://localhost:7681/desk/ws?arg=--list'); assert.equal(wsUrl(sub, '--list'), 'ws://localhost:7681/desk/ws?arg=--list');
assert.equal(wsUrl(loc, ['--rename', 'old', 'new']), 'wss://dev.lotusguild.org/ws?arg=--rename&arg=old&arg=new');
});
test('free text becomes a valid session name', () => {
assert.equal(slugify(' PBS upgrade! '), 'PBS-upgrade');
assert.equal(slugify('--../etc'), 'etc');
assert.equal(slugify('tinker_tickets'), 'tinker_tickets');
assert.equal(slugify('!!!'), '');
assert.equal(slugify('x'.repeat(80)).length, 64);
});
test('ANSI colour and title sequences are stripped', () => {
assert.equal(stripAnsi('\x1b[1;31mdevdesk: no\x1b[0m\x1b]0;t\x07'), 'devdesk: no');
}); });
test('only valid session names are taken from the URL', () => { test('only valid session names are taken from the URL', () => {
+24 -18
View File
@@ -59,17 +59,36 @@
</div> </div>
<div class="lt-section-body"> <div class="lt-section-body">
<ul class="dd-session-list" id="sessions-list"></ul> <ul class="dd-session-list" id="sessions-list"></ul>
<div class="dd-new-session"> <form class="dd-new-session" id="new-session-form" autocomplete="off">
<select class="lt-select" id="project-select" aria-label="Project for a new session"></select> <input class="lt-input" id="new-session-name" maxlength="64" placeholder="New session, e.g. pbs-upgrade"
<button type="button" class="lt-btn lt-btn-primary lt-btn-sm" id="project-open">Open</button> aria-label="New session name" spellcheck="false">
</div> <select class="lt-select" id="project-select" aria-label="Start the new session in a project folder (optional)"></select>
<button type="submit" class="lt-btn lt-btn-primary lt-btn-sm">Open</button>
</form>
<div class="dd-widget-foot"> <div class="dd-widget-foot">
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="open-picker">Picker</button> <button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="open-picker">Picker</button>
<span class="dd-muted" id="sessions-status"></span> <span class="dd-muted dd-status" id="sessions-status" role="status"></span>
</div> </div>
</div> </div>
</section> </section>
<section class="lt-frame dd-widget" id="widget-context" data-state="empty">
<div class="lt-section-header">Claude Context</div>
<div class="lt-section-body">
<div class="lt-progress lt-progress--cyan" role="progressbar" aria-label="Context window used" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="lt-progress-bar" style="width:0%"></div>
</div>
<div class="lt-kv-grid dd-context-grid">
<span class="lt-kv-key">Context</span><span class="lt-kv-val" data-field="used">—</span>
<span class="lt-kv-key">Model</span><span class="lt-kv-val" data-field="model">—</span>
<span class="lt-kv-key">Cost</span><span class="lt-kv-val" data-field="cost">—</span>
<span class="lt-kv-key">Edits</span><span class="lt-kv-val" data-field="lines">—</span>
<span class="lt-kv-key">Updated</span><span class="lt-kv-val" data-field="updated">—</span>
</div>
<p class="dd-muted dd-stub-note dd-context-note"></p>
</div>
</section>
<section class="lt-frame dd-widget" id="widget-repo" data-state="stub"> <section class="lt-frame dd-widget" id="widget-repo" data-state="stub">
<div class="lt-section-header">Repo / CI <span class="lt-badge lt-badge-sm dd-soon">v1.1</span></div> <div class="lt-section-header">Repo / CI <span class="lt-badge lt-badge-sm dd-soon">v1.1</span></div>
<div class="lt-section-body"> <div class="lt-section-body">
@@ -99,19 +118,6 @@
</div> </div>
</section> </section>
<section class="lt-frame dd-widget" id="widget-context" data-state="stub">
<div class="lt-section-header">Claude Context <span class="lt-badge lt-badge-sm dd-soon">v1.1</span></div>
<div class="lt-section-body">
<div class="lt-progress lt-progress--cyan" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="lt-progress-bar" style="width:0%"></div>
</div>
<div class="lt-kv-grid dd-context-grid">
<span class="lt-kv-key">Used</span><span class="lt-kv-val" data-field="used">—</span>
<span class="lt-kv-key">Model</span><span class="lt-kv-val" data-field="model">—</span>
</div>
<p class="dd-muted dd-stub-note">Fed by a Claude statusLine hook through devdesk-api.</p>
</div>
</section>
</aside> </aside>
</main> </main>
+21 -11
View File
@@ -1,6 +1,6 @@
import { import {
Server, LIST_ARG, decodeServer, encodeBinaryInput, encodeCommand, encodeInit, Server, LIST_ARG, decodeServer, encodeBinaryInput, encodeCommand, encodeInit,
encodeInput, encodeResize, parseListOutput, tokenUrl, wsUrl, FlowControl, encodeInput, encodeResize, parseListOutput, stripAnsi, tokenUrl, wsUrl, FlowControl,
} from './protocol.js'; } from './protocol.js';
export class AuthExpiredError extends Error {} export class AuthExpiredError extends Error {}
@@ -94,16 +94,17 @@ export class TtydConnection {
} }
} }
// Ask the server for sessions + projects: a short-lived `devdesk attach --list`. // Run a short-lived `devdesk attach <args…>` and return its output as text.
export async function fetchSessionList(timeoutMs = 8000) { // ttyd is the only server-side endpoint, so session actions travel this way.
export async function runCommand(args, timeoutMs = 8000) {
const token = await fetchToken(); const token = await fetchToken();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const socket = new WebSocket(wsUrl(window.location, LIST_ARG), ['tty']); const socket = new WebSocket(wsUrl(window.location, args), ['tty']);
socket.binaryType = 'arraybuffer'; socket.binaryType = 'arraybuffer';
const chunks = []; const chunks = [];
const timer = setTimeout(() => { const timer = setTimeout(() => {
socket.close(); socket.close();
reject(new Error('session list timed out')); reject(new Error('devdesk did not answer'));
}, timeoutMs); }, timeoutMs);
socket.onopen = () => socket.send(encodeInit(token, 240, 24)); socket.onopen = () => socket.send(encodeInit(token, 240, 24));
socket.onmessage = (event) => { socket.onmessage = (event) => {
@@ -113,16 +114,25 @@ export async function fetchSessionList(timeoutMs = 8000) {
socket.onerror = () => {}; socket.onerror = () => {};
socket.onclose = () => { socket.onclose = () => {
clearTimeout(timer); clearTimeout(timer);
try { resolve(new TextDecoder().decode(concat(chunks)));
const text = new TextDecoder().decode(concat(chunks));
resolve(parseListOutput(text));
} catch (err) {
reject(err);
}
}; };
}); });
} }
// Sessions, projects and per-session Claude context (`devdesk attach --list`).
export async function fetchSessionList() {
return parseListOutput(await runCommand([LIST_ARG]));
}
// `devdesk attach --new|--rename|--end …`: resolves on {"ok":true}, otherwise
// rejects with devdesk's own error message.
export async function sessionAction(action, ...args) {
const output = stripAnsi(await runCommand([`--${action}`, ...args])).trim();
if (/"ok"\s*:\s*true/.test(output)) return;
const message = output.split(/\r?\n/).filter(Boolean).pop() || 'no response';
throw new Error(message.replace(/^devdesk:\s*/, ''));
}
function concat(chunks) { function concat(chunks) {
const total = chunks.reduce((n, c) => n + c.length, 0); const total = chunks.reduce((n, c) => n + c.length, 0);
const out = new Uint8Array(total); const out = new Uint8Array(total);
+51
View File
@@ -0,0 +1,51 @@
// Claude Context panel: the current session's model, context-window use, cost
// and edits, from the snapshot devdesk-statusline writes on every Claude update.
import { ago } from './sessions.js';
function compact(tokens) {
if (tokens == null) return '—';
if (tokens >= 1e6) return `${+(tokens / 1e6).toFixed(tokens % 1e6 ? 1 : 0)}M`;
if (tokens >= 1e3) return `${Math.round(tokens / 1e3)}k`;
return String(tokens);
}
export function renderContext(root, session, context) {
const set = (field, text) => {
const node = root.querySelector(`[data-field="${field}"]`);
if (node) node.textContent = text;
};
const bar = root.querySelector('.lt-progress');
const fill = root.querySelector('.lt-progress-bar');
const note = root.querySelector('.dd-context-note');
if (!context) {
root.dataset.state = 'empty';
fill.style.width = '0%';
bar.setAttribute('aria-valuenow', '0');
for (const f of ['used', 'model', 'cost', 'lines', 'updated']) set(f, '—');
note.textContent = session
? 'No data yet: it appears once Claude has redrawn its status line in this session.'
: 'Open a session to see its context.';
return;
}
const pct = context.used_pct;
root.dataset.state = 'live';
fill.style.width = `${Math.min(100, pct ?? 0)}%`;
bar.setAttribute('aria-valuenow', String(Math.round(pct ?? 0)));
bar.classList.remove('lt-progress--cyan', 'lt-progress--red');
if (pct == null || pct < 70) bar.classList.add('lt-progress--cyan');
else if (pct >= 90) bar.classList.add('lt-progress--red');
set('used', pct == null
? `— / ${compact(context.window)}`
: `${compact(context.used_tokens)} / ${compact(context.window)} (${pct.toFixed(pct < 10 ? 1 : 0)}%)`);
set('model', [context.model, context.effort].filter(Boolean).join(' · ') || '—');
set('cost', context.cost_usd == null ? '—' : `$${context.cost_usd.toFixed(2)}`);
set('lines', `+${context.lines_added ?? 0} −${context.lines_removed ?? 0}`);
set('updated', context.updated ? `${ago(context.updated)} ago`.replace('now ago', 'just now') : '—');
note.textContent = pct != null && pct >= 80
? 'Context is filling up; consider /compact or a new conversation.'
: '';
}
+47 -5
View File
@@ -3,6 +3,7 @@ import { createTerminal, loadFonts } from './term.js';
import { TtydConnection, fetchToken, AuthExpiredError, UnauthorizedError } from './connection.js'; import { TtydConnection, fetchToken, AuthExpiredError, UnauthorizedError } from './connection.js';
import { parseTitle, sessionFromSearch } from './protocol.js'; import { parseTitle, sessionFromSearch } from './protocol.js';
import { SessionsPanel, rememberSession } from './sessions.js'; import { SessionsPanel, rememberSession } from './sessions.js';
import { renderContext } from './context.js';
import { bindAskClaude } from './widgets.js'; import { bindAskClaude } from './widgets.js';
import * as notify from './notify.js'; import * as notify from './notify.js';
@@ -47,6 +48,7 @@ const state = {
attempts: 0, attempts: 0,
retryTimer: null, retryTimer: null,
openedAt: 0, openedAt: 0,
endingCurrent: false,
lastActivity: Date.now(), lastActivity: Date.now(),
}; };
@@ -88,6 +90,18 @@ function hideOverlay() {
ui.overlay.hidden = true; ui.overlay.hidden = true;
} }
function showContext() {
const data = sessionsPanel?.data;
const current = data?.sessions?.find((s) => s.name === state.session);
renderContext($('widget-context'), state.session, current?.context ?? null);
}
let refreshTimer = 0;
function refreshSoon(delay = 1500) {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => sessionsPanel?.refresh(), delay);
}
function updateUrl(session) { function updateUrl(session) {
const url = new URL(window.location.href); const url = new URL(window.location.href);
if (session) url.searchParams.set('arg', session); if (session) url.searchParams.set('arg', session);
@@ -103,6 +117,7 @@ function connect(session) {
state.session = session; state.session = session;
updateUrl(session); updateUrl(session);
sessionsPanel?.setCurrent(session); sessionsPanel?.setCurrent(session);
showContext();
ui.sessionChip.textContent = session || 'picker'; ui.sessionChip.textContent = session || 'picker';
hideOverlay(); hideOverlay();
setWsState('connecting', 'connecting'); setWsState('connecting', 'connecting');
@@ -132,6 +147,12 @@ const pickerAction = { label: 'Sessions…', run: () => connect(null) };
function onClosed(code) { function onClosed(code) {
setWsState('disconnected', 'offline'); setWsState('disconnected', 'offline');
if (state.endingCurrent) {
// We just ended the session we were attached to: go back to the picker.
state.endingCurrent = false;
connect(null);
return;
}
// ttyd reports a non-zero exit of `devdesk attach` with an invalid close code, // ttyd reports a non-zero exit of `devdesk attach` with an invalid close code,
// which browsers surface as 1006 — same as a dropped network. A close within a // which browsers surface as 1006 — same as a dropped network. A close within a
// few seconds of opening means the session could not start (the reason is // few seconds of opening means the session could not start (the reason is
@@ -229,6 +250,7 @@ function applyTitle() {
updateUrl(parsed.session); updateUrl(parsed.session);
rememberSession(parsed.session); rememberSession(parsed.session);
sessionsPanel.setCurrent(parsed.session); sessionsPanel.setCurrent(parsed.session);
showContext();
} }
ui.sessionChip.textContent = parsed.session; ui.sessionChip.textContent = parsed.session;
ui.paneTitle.textContent = parsed.pane; ui.paneTitle.textContent = parsed.pane;
@@ -311,7 +333,7 @@ function wireChrome() {
}); });
setInterval(() => { setInterval(() => {
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) sessionsPanel.refresh(); if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) sessionsPanel.refresh();
}, 30 * 1000); }, 15 * 1000);
} }
// ---------------------------------------------------------------- boot // ---------------------------------------------------------------- boot
@@ -332,7 +354,10 @@ async function boot() {
pendingTitle = title; pendingTitle = title;
if (!titleFrame) titleFrame = requestAnimationFrame(applyTitle); if (!titleFrame) titleFrame = requestAnimationFrame(applyTitle);
}); });
term.onBell(() => notify.ring(state.session, ui.paneTitle.textContent)); term.onBell(() => {
notify.ring(state.session, ui.paneTitle.textContent);
refreshSoon(); // Claude finished a turn: context numbers just changed
});
let fitFrame = 0; let fitFrame = 0;
new ResizeObserver(() => { new ResizeObserver(() => {
@@ -347,13 +372,30 @@ async function boot() {
sessionsPanel = new SessionsPanel({ sessionsPanel = new SessionsPanel({
list: $('sessions-list'), list: $('sessions-list'),
form: $('new-session-form'),
nameInput: $('new-session-name'),
select: $('project-select'), select: $('project-select'),
openButton: $('project-open'),
refreshButton: $('sessions-refresh'), refreshButton: $('sessions-refresh'),
pickerButton: $('open-picker'), pickerButton: $('open-picker'),
status: $('sessions-status'), status: $('sessions-status'),
onOpen: (name) => connect(name), hooks: {
onPicker: () => connect(null), onOpen: (name) => connect(name),
onPicker: () => connect(null),
onEnding: () => { state.endingCurrent = true; },
onEnded: (name) => {
if (name !== state.session) state.endingCurrent = false;
},
onEndAborted: () => { state.endingCurrent = false; },
onRenamed: (from, to) => {
if (from !== state.session) return;
state.session = to;
updateUrl(to);
rememberSession(to);
ui.sessionChip.textContent = to;
sessionsPanel.setCurrent(to);
},
onData: () => showContext(),
},
}); });
bindAskClaude($('sidebar'), (text) => { bindAskClaude($('sidebar'), (text) => {
state.conn?.sendInput(text); state.conn?.sendInput(text);
+16 -4
View File
@@ -53,12 +53,25 @@ export function tokenUrl(location) {
return `${location.protocol}//${location.host}${basePath(location)}token`; return `${location.protocol}//${location.host}${basePath(location)}token`;
} }
export function wsUrl(location, arg) { // ttyd --url-arg appends each ?arg= to the command line: `devdesk attach <args…>`.
export function wsUrl(location, args) {
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:'; const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
const query = arg ? `?arg=${encodeURIComponent(arg)}` : ''; const list = Array.isArray(args) ? args : args ? [args] : [];
const query = list.length ? `?${list.map((a) => `arg=${encodeURIComponent(a)}`).join('&')}` : '';
return `${scheme}//${location.host}${basePath(location)}ws${query}`; return `${scheme}//${location.host}${basePath(location)}ws${query}`;
} }
// Session names are [A-Za-z0-9_-]; turn free text ("PBS upgrade!") into one ("PBS-upgrade").
export function slugify(text) {
return String(text || '').trim().replace(/[^A-Za-z0-9_-]+/g, '-')
.replace(/^[-_]+/, '').replace(/-+$/, '').slice(0, 64);
}
export function stripAnsi(text) {
// eslint-disable-next-line no-control-regex
return text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '');
}
export function sessionFromSearch(search) { export function sessionFromSearch(search) {
const value = new URLSearchParams(search).get('arg'); const value = new URLSearchParams(search).get('arg');
return value && SESSION_RE.test(value) ? value : null; return value && SESSION_RE.test(value) ? value : null;
@@ -74,8 +87,7 @@ export function parseTitle(title) {
// Output of `devdesk attach --list` arrives through a pty: CRLF line endings and // Output of `devdesk attach --list` arrives through a pty: CRLF line endings and
// possibly split across frames. Pull the JSON object out of the joined text. // possibly split across frames. Pull the JSON object out of the joined text.
export function parseListOutput(raw) { export function parseListOutput(raw) {
// eslint-disable-next-line no-control-regex const text = stripAnsi(raw); // tools may colorize on a tty
const text = raw.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, ''); // tools may colorize on a tty
const start = text.indexOf('{'); const start = text.indexOf('{');
const end = text.lastIndexOf('}'); const end = text.lastIndexOf('}');
if (start < 0 || end < start) throw new Error('no session list in output'); if (start < 0 || end < start) throw new Error('no session list in output');
+139 -47
View File
@@ -1,7 +1,8 @@
// Sessions panel: server state (via `devdesk attach --list`) plus recently used // Sessions panel: server state (via `devdesk attach --list`), recently used
// sessions remembered in this browser. // sessions remembered in this browser, and session actions (new/rename/end).
import { fetchSessionList } from './connection.js'; import { fetchSessionList, sessionAction } from './connection.js';
import { slugify } from './protocol.js';
const RECENTS_KEY = 'devdesk.recents'; const RECENTS_KEY = 'devdesk.recents';
const MAX_RECENTS = 12; const MAX_RECENTS = 12;
@@ -15,18 +16,27 @@ function readRecents() {
} }
} }
export function rememberSession(name) { function writeRecents(recents) {
if (!name) return;
try { try {
const recents = readRecents().filter((r) => r.name !== name);
recents.unshift({ name, at: Date.now() });
localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, MAX_RECENTS))); localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, MAX_RECENTS)));
} catch { } catch {
// Storage unavailable (private mode): recents are a convenience only. // Storage unavailable (private mode): recents are a convenience only.
} }
} }
function ago(epochSeconds) { export function rememberSession(name) {
if (name) writeRecents([{ name, at: Date.now() }, ...readRecents().filter((r) => r.name !== name)]);
}
function forgetRecent(name) {
writeRecents(readRecents().filter((r) => r.name !== name));
}
function renameRecent(from, to) {
writeRecents(readRecents().map((r) => (r.name === from ? { ...r, name: to } : r)));
}
export function ago(epochSeconds) {
if (!epochSeconds) return ''; if (!epochSeconds) return '';
const s = Math.max(0, Date.now() / 1000 - epochSeconds); const s = Math.max(0, Date.now() / 1000 - epochSeconds);
if (s < 90) return 'now'; if (s < 90) return 'now';
@@ -35,26 +45,30 @@ function ago(epochSeconds) {
return `${Math.floor(s / 86400)}d`; return `${Math.floor(s / 86400)}d`;
} }
function el(tag, attrs = {}, ...children) { export function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag); const node = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) { for (const [key, value] of Object.entries(attrs)) {
if (value === undefined || value === null || value === false) continue;
if (key === 'class') node.className = value; if (key === 'class') node.className = value;
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value); else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
else node.setAttribute(key, value); else node.setAttribute(key, value === true ? '' : value);
} }
node.append(...children.filter((c) => c !== null && c !== undefined)); node.append(...children.filter((c) => c !== null && c !== undefined));
return node; return node;
} }
export class SessionsPanel { export class SessionsPanel {
constructor({ list, select, openButton, refreshButton, pickerButton, status, onOpen, onPicker }) { // hooks: onOpen(name), onPicker(), onEnded(name), onRenamed(from, to), onData(data)
Object.assign(this, { list, select, status, onOpen }); constructor({ list, form, nameInput, select, refreshButton, pickerButton, status, hooks }) {
Object.assign(this, { list, nameInput, select, status, hooks });
this.current = null; this.current = null;
this.data = null; this.data = null;
this.busy = false;
refreshButton.addEventListener('click', () => this.refresh()); refreshButton.addEventListener('click', () => this.refresh());
pickerButton.addEventListener('click', () => onPicker()); pickerButton.addEventListener('click', () => hooks.onPicker());
openButton.addEventListener('click', () => { form.addEventListener('submit', (event) => {
if (select.value) onOpen(select.value); event.preventDefault();
this.createFromForm();
}); });
} }
@@ -63,56 +77,134 @@ export class SessionsPanel {
if (this.data) this.render(); if (this.data) this.render();
} }
setStatus(text, isError = false) {
this.status.textContent = text;
this.status.classList.toggle('is-error', isError);
}
async refresh() { async refresh() {
this.status.textContent = 'refreshing…'; if (this.busy) return;
this.busy = true;
try { try {
this.data = await fetchSessionList(); this.data = await fetchSessionList();
this.status.textContent = ''; if (!this.status.classList.contains('is-error')) this.setStatus('');
} catch (err) { } catch (err) {
this.status.textContent = `list unavailable: ${err.message}`; this.setStatus(`list unavailable: ${err.message}`, true);
} finally {
this.busy = false;
} }
this.render(); this.render();
this.hooks.onData?.(this.data);
}
async act(label, action, ...args) {
this.setStatus(`${label}…`);
try {
await sessionAction(action, ...args);
this.setStatus('');
return true;
} catch (err) {
this.setStatus(err.message, true);
return false;
}
}
async createFromForm() {
const project = this.select.value;
const name = slugify(this.nameInput.value) || project;
if (!name) {
this.setStatus('Type a session name or pick a project.', true);
this.nameInput.focus();
return;
}
// With a project the session starts in that folder; without one, in the default folder.
if (project && !(await this.act('creating', 'new', name, project))) return;
this.nameInput.value = '';
this.select.value = '';
this.hooks.onOpen(name);
}
async rename(name) {
const answer = window.prompt(`Rename session "${name}" to:`, name);
if (answer === null) return;
const next = slugify(answer);
if (!next || next === name) {
if (!next) this.setStatus('Names use letters, numbers, - and _.', true);
return;
}
if (await this.act('renaming', 'rename', name, next)) {
renameRecent(name, next);
this.hooks.onRenamed(name, next);
await this.refresh();
}
}
async end(session) {
const question = session.running
? `End session "${session.name}"?\n\nClaude in this session stops. The conversation stays in Claude's history (claude --resume).`
: `Forget dormant session "${session.name}"?`;
if (!window.confirm(question)) return;
if (session.name === this.current) this.hooks.onEnding?.(session.name);
if (await this.act(session.running ? 'ending' : 'forgetting', 'end', session.name)) {
forgetRecent(session.name);
this.hooks.onEnded(session.name);
await this.refresh();
} else if (session.name === this.current) {
this.hooks.onEndAborted?.();
}
}
renderSession(s) {
const isCurrent = s.name === this.current;
const ctx = s.context?.used_pct != null ? ` · ctx ${Math.floor(s.context.used_pct)}%` : '';
const meta = s.running
? `${s.attached} attached · ${ago(s.activity)}${ctx}`
: 'dormant · resumes Claude';
const iconButton = (label, glyph, onclick, extra = '') =>
el('button', { class: `dd-icon-btn ${extra}`, type: 'button', title: label, 'aria-label': `${label} ${s.name}`, onclick }, glyph);
return el('li', { class: `dd-session-item${isCurrent ? ' is-current' : ''}${s.running ? '' : ' is-dormant'}` },
el('button', { class: 'dd-session-open', type: 'button', title: s.dir || '', onclick: () => this.hooks.onOpen(s.name) },
el('span', { class: `lt-dot ${s.running ? 'lt-dot-up' : 'lt-dot-idle'}` }),
el('span', { class: 'dd-session-name' }, s.name),
el('span', { class: 'dd-session-meta' }, meta)),
el('span', { class: 'dd-session-actions' },
iconButton('Rename', '✎', () => this.rename(s.name)),
el('a', {
class: 'dd-icon-btn', href: `?arg=${encodeURIComponent(s.name)}`, target: '_blank', rel: 'noopener',
title: 'Open in a new tab', 'aria-label': `Open ${s.name} in a new tab`,
}, '↗'),
iconButton(s.running ? 'End session' : 'Forget', '×', () => this.end(s), 'dd-danger')));
} }
render() { render() {
const sessions = this.data?.sessions ?? []; const sessions = this.data?.sessions ?? [];
const known = new Set(sessions.map((s) => s.name)); const known = new Set(sessions.map((s) => s.name));
const recentsOnly = readRecents().filter((r) => !known.has(r.name)).slice(0, 5); const recentsOnly = readRecents().filter((r) => !known.has(r.name)).slice(0, 5);
this.list.replaceChildren();
const ordered = [...sessions].sort((a, b) => const ordered = [...sessions].sort((a, b) =>
(b.running - a.running) || ((b.activity || 0) - (a.activity || 0))); (b.running - a.running) || ((b.activity || 0) - (a.activity || 0)) || a.name.localeCompare(b.name));
for (const s of ordered) { const items = ordered.map((s) => this.renderSession(s));
const isCurrent = s.name === this.current;
const meta = s.running
? `${s.attached} attached · ${ago(s.activity)}`
: 'dormant · resumes Claude';
this.list.append(el('li', { class: `dd-session-item${isCurrent ? ' is-current' : ''}` },
el('button', {
class: 'dd-session-open', type: 'button', title: s.dir || '',
onclick: () => this.onOpen(s.name),
},
el('span', { class: `lt-dot ${s.running ? 'lt-dot-up' : 'lt-dot-idle'}` }),
el('span', { class: 'dd-session-name' }, s.name),
el('span', { class: 'dd-session-meta' }, meta)),
el('a', {
class: 'dd-session-tab', href: `?arg=${encodeURIComponent(s.name)}`,
target: '_blank', rel: 'noopener', title: 'Open in a new tab', 'aria-label': `Open ${s.name} in a new tab`,
}, '↗')));
}
for (const r of recentsOnly) { for (const r of recentsOnly) {
this.list.append(el('li', { class: 'dd-session-item is-recent' }, items.push(el('li', { class: 'dd-session-item is-recent' },
el('button', { class: 'dd-session-open', type: 'button', onclick: () => this.onOpen(r.name) }, el('button', { class: 'dd-session-open', type: 'button', onclick: () => this.hooks.onOpen(r.name) },
el('span', { class: 'lt-dot lt-dot-idle' }), el('span', { class: 'lt-dot lt-dot-idle' }),
el('span', { class: 'dd-session-name' }, r.name), el('span', { class: 'dd-session-name' }, r.name),
el('span', { class: 'dd-session-meta' }, 'recent')))); el('span', { class: 'dd-session-meta' }, 'recent in this browser')),
} el('span', { class: 'dd-session-actions' },
if (!this.list.children.length) { el('button', {
this.list.append(el('li', { class: 'dd-empty' }, 'No sessions yet.')); class: 'dd-icon-btn dd-danger', type: 'button', title: 'Remove from recents',
onclick: () => { forgetRecent(r.name); this.render(); },
}, '×'))));
} }
if (!items.length) items.push(el('li', { class: 'dd-empty' }, 'No sessions yet.'));
this.list.replaceChildren(...items);
const projects = (this.data?.projects ?? []).filter((p) => !known.has(p.name)); const projects = this.data?.projects ?? [];
this.select.replaceChildren(el('option', { value: '' }, projects.length ? 'New session in…' : 'No other projects')); const chosen = this.select.value;
for (const p of projects) this.select.append(el('option', { value: p.name, title: p.dir }, p.name)); this.select.replaceChildren(
el('option', { value: '' }, `No project (${this.data?.root ?? 'default folder'})`),
...projects.filter((p) => p.name !== this.data?.default)
.map((p) => el('option', { value: p.name, title: p.dir }, p.name)));
this.select.value = chosen;
} }
} }
+46 -11
View File
@@ -210,13 +210,47 @@ body.dd-sidebar-open .dd-sidebar {
.dd-session-item.is-recent .dd-session-name { color: var(--text-secondary); font-weight: 400; } .dd-session-item.is-recent .dd-session-name { color: var(--text-secondary); font-weight: 400; }
.dd-session-tab { .dd-session-item.is-dormant .dd-session-name { color: var(--text-secondary); }
.dd-session-actions {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 8px; gap: 2px;
color: var(--text-muted); padding-right: 2px;
opacity: 0.55;
transition: var(--transition-fast);
}
.dd-session-item:hover .dd-session-actions,
.dd-session-item:focus-within .dd-session-actions,
.dd-session-item.is-current .dd-session-actions { opacity: 1; }
.dd-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: none;
border: 1px solid transparent;
color: var(--text-muted);
font-size: 0.8rem;
line-height: 1;
text-decoration: none;
}
.dd-icon-btn:hover,
.dd-icon-btn:focus-visible {
color: var(--accent-cyan);
border-color: var(--accent-cyan-border);
text-shadow: var(--glow-cyan);
text-decoration: none;
}
.dd-icon-btn.dd-danger:hover,
.dd-icon-btn.dd-danger:focus-visible {
color: var(--accent-red);
border-color: rgba(255, 45, 85, 0.4);
text-shadow: var(--glow-red);
} }
.dd-session-tab:hover { color: var(--accent-orange); }
.dd-empty { .dd-empty {
color: var(--text-muted); color: var(--text-muted);
@@ -225,15 +259,15 @@ body.dd-sidebar-open .dd-sidebar {
} }
.dd-new-session { .dd-new-session {
display: flex; display: grid;
grid-template-columns: 1fr auto;
gap: var(--space-sm); gap: var(--space-sm);
margin-bottom: var(--space-sm); margin-bottom: var(--space-sm);
} }
.dd-new-session .lt-select { .dd-new-session .lt-input { grid-column: 1 / -1; font-size: 0.74rem; }
flex: 1 1 auto; .dd-new-session .lt-select { min-width: 0; font-size: 0.72rem; }
min-width: 0;
font-size: 0.72rem; .dd-status.is-error { color: var(--accent-red); }
}
.dd-widget-foot { .dd-widget-foot {
display: flex; display: flex;
@@ -253,7 +287,8 @@ body.dd-sidebar-open .dd-sidebar {
.dd-context-grid { margin-top: var(--space-sm); } .dd-context-grid { margin-top: var(--space-sm); }
[data-state="stub"] .lt-section-body { opacity: 0.75; } [data-state="stub"] .lt-section-body,
#widget-context[data-state="empty"] .lt-kv-grid { opacity: 0.6; }
/* ---- overlay ----------------------------------------------------------------- */ /* ---- overlay ----------------------------------------------------------------- */
.dd-overlay { .dd-overlay {