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:
+1
-1
@@ -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" }]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 "$@" ;;
|
||||||
|
|||||||
Vendored
+15
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Executable
+48
@@ -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
|
||||||
@@ -12,5 +12,10 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"statusLine": {
|
||||||
|
"type": "command",
|
||||||
|
"command": "/usr/lib/devdesk/devdesk-statusline",
|
||||||
|
"padding": 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-3
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user