feat: free-form sessions, rename/end from the panel, live Claude Context (0.2.0)
build-deb / Lint, test, package (push) Successful in 2m0s
build-deb / Notify on failure (push) Skipped

- 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 527cb1fda2
22 changed files with 703 additions and 171 deletions
+5 -1
View File
@@ -7,7 +7,9 @@ set -uo pipefail
# shellcheck source=libexec/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}
SETTINGS="$DEVDESK_SHARE/claude-settings.json"
read -r -a EXTRA <<<"${CLAUDE_ARGS:-}"
@@ -63,8 +65,10 @@ menu() {
printf '\033]2;%s\033\134' "$NAME" # pane title (ST = ESC \\)
mode=resume
while :; do
NAME=$(dd_current_session) # follow renames
if [ "$mode" = new ]; then run_new; else run_resume; fi
rc=$?
NAME=$(dd_current_session)
while :; do
menu "$rc"
IFS= read -rsn1 key || exit 0
+45 -18
View File
@@ -11,6 +11,7 @@ if [ -r "$DEVDESK_ENV" ]; then
fi
: "${DEVDESK_PROJECTS_ROOT:=/root/code}"
: "${DEVDESK_DEFAULT_DIR:=$DEVDESK_PROJECTS_ROOT}"
: "${DEVDESK_DEFAULT_SESSION:=main}"
: "${DEVDESK_SOCKET:=/run/devdesk/tmux.sock}"
: "${DEVDESK_STATE_DIR:=/var/lib/devdesk}"
@@ -19,6 +20,7 @@ fi
: "${DEVDESK_TMUX_UNIT:=devdesk-tmux.service}"
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}$'
dd_die() {
@@ -99,29 +101,30 @@ dd_registry_set_claude() {
dd_registry_remove() {
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_resolve_dir() {
dd_registry_rename() {
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
if [ "$name" = "$DEVDESK_DEFAULT_SESSION" ]; then
dir=$DEVDESK_PROJECTS_ROOT
if [ -d "$DEVDESK_PROJECTS_ROOT/$name" ]; then
dir="$DEVDESK_PROJECTS_ROOT/$name"
else
dir=$(dd_registry_get "$name" dir)
if [ -z "$dir" ] || [ ! -d "$dir" ]; then
dir=""
if [ -d "$DEVDESK_PROJECTS_ROOT/$name" ]; then
dir="$DEVDESK_PROJECTS_ROOT/$name"
else
for d in "$DEVDESK_PROJECTS_ROOT"/*/; do
d=${d%/}
if [ "$(dd_slugify "${d##*/}")" = "$name" ]; then
dir=$d
break
fi
done
for d in "$DEVDESK_PROJECTS_ROOT"/*/; do
d=${d%/}
if [ "$(dd_slugify "${d##*/}")" = "$name" ]; then
dir=$d
break
fi
fi
done
fi
[ -n "$dir" ] || return 1
dir=$(realpath -e -- "$dir" 2>/dev/null) || return 1
@@ -132,6 +135,30 @@ dd_resolve_dir() {
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.
dd_new_session_args() {
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
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" || exit 0
[ -n "${DEVDESK_SESSION:-}" ] || exit 0
dd_valid_name "$DEVDESK_SESSION" || exit 0
session=$(dd_current_session)
[ -n "$session" ] || exit 0
dd_valid_name "$session" || exit 0
input=$(cat)
id=$(jq -r '.session_id // empty' <<<"$input" 2>/dev/null)
source=$(jq -r '.source // empty' <<<"$input" 2>/dev/null)
[ -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
+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