feat: DevDesk v0.1.0, a persistent browser terminal for Claude Code
build-deb / Lint, test, package (push) Canceled after 1m13s
build-deb / Notify on failure (push) Canceled after 0s

ttyd serves a single-file xterm.js UI styled with the LotusGuild TDS
(web_template base.css). Sessions live in a dedicated tmux server unit
(devdesk-tmux.service), so restarting or upgrading the web unit never
stops Claude. Each named session maps to a project directory. A Claude
SessionStart hook records the conversation ID, so after a reboot the
session resumes that exact conversation.

- bin/devdesk: attach / new / ls / end / pick, with an fzf picker
- ttyd requires a credential that NPM injects; origin check enabled
- debian/: dh 13 with --no-stop-on-upgrade for the tmux unit, bundled
  sha256-verified ttyd 1.7.7, lintian overrides, man page
- CI: lint, test, build, smoke-test, artifact; v* tags publish to the
  Gitea apt registry and create a release

Refs: tinker ticket 887531976

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 23:11:07 -04:00
co-authored by Claude Opus 5.5
commit 07a41bd625
52 changed files with 10769 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
#!/bin/bash
# Runs in each session's "claude" window. Resumes the conversation recorded for
# this session (see devdesk-session-hook), starts a fresh one if there is none,
# and offers a small menu whenever Claude exits so the window never just dies.
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}
CLAUDE_BIN=${CLAUDE_BIN:-claude}
SETTINGS="$DEVDESK_SHARE/claude-settings.json"
read -r -a EXTRA <<<"${CLAUDE_ARGS:-}"
ORANGE=$'\033[38;2;255;107;0m'
CYAN=$'\033[38;2;0;212;255m'
MUTED=$'\033[38;2;62;96;122m'
RESET=$'\033[0m'
common_args() {
printf '%s\0' --settings "$SETTINGS"
if [ "${DEVDESK_REMOTE_CONTROL:-0}" = "1" ]; then
printf '%s\0' --remote-control "devdesk-$NAME"
fi
if [ ${#EXTRA[@]} -gt 0 ]; then
printf '%s\0' "${EXTRA[@]}"
fi
}
run_new() {
local args
mapfile -d '' args < <(common_args)
"$CLAUDE_BIN" -n "$NAME" "${args[@]}"
}
run_resume() {
local id args started rc
id=$(dd_registry_get "$NAME" claude_session_id)
if [ -z "$id" ]; then
run_new
return
fi
mapfile -d '' args < <(common_args)
started=$SECONDS
"$CLAUDE_BIN" --resume "$id" "${args[@]}"
rc=$?
# A resume that fails straight away means the transcript is gone; start fresh.
if [ "$rc" -ne 0 ] && [ $((SECONDS - started)) -lt 10 ]; then
printf '%sCould not resume conversation %s — starting a new one.%s\n' "$MUTED" "$id" "$RESET"
sleep 1
run_new
return
fi
return "$rc"
}
menu() {
printf '\n%s◈ Claude exited%s %s(session %s, code %s)%s\n' "$ORANGE" "$RESET" "$MUTED" "$NAME" "$1" "$RESET"
printf ' %s[⏎]%s resume %s[n]%s new conversation %s[s]%s shell %s[x]%s end session\n' \
"$CYAN" "$RESET" "$CYAN" "$RESET" "$CYAN" "$RESET" "$CYAN" "$RESET"
}
printf '\033]2;%s\033\134' "$NAME" # pane title (ST = ESC \\)
mode=resume
while :; do
if [ "$mode" = new ]; then run_new; else run_resume; fi
rc=$?
while :; do
menu "$rc"
IFS= read -rsn1 key || exit 0
case $key in
"") mode=resume; break ;;
n | N) mode=new; break ;;
s | S)
printf '%sShell in %s — exit to return to this menu.%s\n' "$MUTED" "$PWD" "$RESET"
"${SHELL:-/bin/bash}" -l
;;
x | X)
printf 'End session %s? This stops Claude and forgets the session. [y/N] ' "$NAME"
IFS= read -rsn1 confirm || exit 0
echo
if [ "$confirm" = y ] || [ "$confirm" = Y ]; then
dd_registry_remove "$NAME"
tmux kill-session -t "=$NAME" 2>/dev/null
exit 0
fi
;;
esac
done
done
+138
View File
@@ -0,0 +1,138 @@
# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables ($n, $d, …)
# Shared helpers for the devdesk CLI and its libexec scripts. Sourced, not executed.
#
# Every path can be overridden from the environment so the test suite can run
# against a throwaway tmux server and state directory.
DEVDESK_ENV=${DEVDESK_ENV:-/etc/devdesk/env}
if [ -r "$DEVDESK_ENV" ]; then
# shellcheck source=/dev/null
. "$DEVDESK_ENV"
fi
: "${DEVDESK_PROJECTS_ROOT:=/root/code}"
: "${DEVDESK_DEFAULT_SESSION:=main}"
: "${DEVDESK_SOCKET:=/run/devdesk/tmux.sock}"
: "${DEVDESK_STATE_DIR:=/var/lib/devdesk}"
: "${DEVDESK_LIBEXEC:=/usr/lib/devdesk}"
: "${DEVDESK_SHARE:=/usr/share/devdesk}"
: "${DEVDESK_TMUX_UNIT:=devdesk-tmux.service}"
DEVDESK_REGISTRY="$DEVDESK_STATE_DIR/registry.json"
DEVDESK_NAME_RE='^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$'
dd_die() {
printf 'devdesk: %s\n' "$*" >&2
exit 1
}
# tmux client bound to the DevDesk server. -N: never start a server from here,
# because a server started by a client would live in the caller's cgroup (e.g.
# ttyd's) and die with it.
dd_tmux() {
tmux -N -u -S "$DEVDESK_SOCKET" "$@"
}
dd_valid_name() {
[[ $1 =~ $DEVDESK_NAME_RE ]]
}
# Directory name -> session name: anything outside [A-Za-z0-9_-] becomes '-',
# leading separators are dropped, max 64 chars.
dd_slugify() {
local s=${1//[^A-Za-z0-9_-]/-}
while [[ $s == [-_]* ]]; do s=${s:1}; done
printf '%s' "${s:0:64}"
}
dd_server_up() {
[ -S "$DEVDESK_SOCKET" ] && dd_tmux start-server 2>/dev/null
}
dd_ensure_server() {
dd_server_up && return 0
if [ -z "${DEVDESK_NO_SYSTEMD:-}" ] && [ -d /run/systemd/system ]; then
# ExecStartPost on the unit blocks until the socket answers.
systemctl start "$DEVDESK_TMUX_UNIT" ||
dd_die "could not start $DEVDESK_TMUX_UNIT (see: journalctl -u $DEVDESK_TMUX_UNIT)"
dd_server_up && return 0
fi
dd_die "tmux server is not running on $DEVDESK_SOCKET (start $DEVDESK_TMUX_UNIT)"
}
# --- registry: $DEVDESK_REGISTRY = {"sessions": {"<name>": {dir, claude_session_id, ...}}}
dd_registry_get() {
[ -s "$DEVDESK_REGISTRY" ] || return 0
jq -Mr --arg n "$1" --arg f "$2" '.sessions[$n][$f] // empty' "$DEVDESK_REGISTRY" 2>/dev/null
}
# dd_registry_update JQ_FILTER [jq args...] — read-modify-write under flock.
dd_registry_update() {
local filter=$1
shift
mkdir -p "$DEVDESK_STATE_DIR"
(
flock -w 5 9 || exit 1
[ -s "$DEVDESK_REGISTRY" ] || printf '{"sessions":{}}\n' >"$DEVDESK_REGISTRY"
local tmp
tmp=$(mktemp "$DEVDESK_REGISTRY.XXXXXX") || exit 1
if jq "$@" "$filter" "$DEVDESK_REGISTRY" >"$tmp"; then
chmod 600 "$tmp"
mv -f "$tmp" "$DEVDESK_REGISTRY"
else
rm -f "$tmp"
exit 1
fi
) 9>"$DEVDESK_REGISTRY.lock"
}
dd_registry_set_dir() {
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {dir: $d, updated: $t})' \
--arg n "$1" --arg d "$2" --arg t "$(date -Is)"
}
dd_registry_set_claude() {
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {claude_session_id: $id, claude_source: $src, updated: $t})' \
--arg n "$1" --arg id "$2" --arg src "$3" --arg t "$(date -Is)"
}
dd_registry_remove() {
dd_registry_update 'del(.sessions[$n])' --arg n "$1"
}
# Session name -> absolute project directory (must resolve inside the projects root).
dd_resolve_dir() {
local name=$1 dir="" d root
if [ "$name" = "$DEVDESK_DEFAULT_SESSION" ]; then
dir=$DEVDESK_PROJECTS_ROOT
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
fi
fi
fi
[ -n "$dir" ] || return 1
dir=$(realpath -e -- "$dir" 2>/dev/null) || return 1
root=$(realpath -e -- "$DEVDESK_PROJECTS_ROOT" 2>/dev/null) || return 1
case "$dir/" in
"$root"/*) printf '%s\n' "$dir" ;;
*) return 1 ;;
esac
}
# 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"
}
+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
# Interactive session picker (fzf). Runs as ttyd's child when no ?arg= is given,
# or via `devdesk pick` over SSH. Selecting an entry execs `devdesk attach NAME`.
set -uo pipefail
# shellcheck source=libexec/devdesk-lib.sh
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
DEVDESK_BIN=${DEVDESK_BIN:-devdesk}
entries() {
# Output: NAME<TAB>display line (fzf shows and searches only the display line).
"$DEVDESK_BIN" --list | jq -Mr '
def ago: if . == null then "" else
((now - .) | if . < 3600 then "\(. / 60 | floor)m ago"
elif . < 86400 then "\(. / 3600 | floor)h ago"
else "\(. / 86400 | floor)d ago" end) end;
def pad($n): . + (" " * ([$n - length, 1] | max));
def short: if . == null then "" else sub("^" + ($ENV.HOME // "/root"); "~") end;
def row($mark; $color; $name; $dir; $note):
[$name, "\u001b[38;2;\($color)m\($mark)\u001b[0m \($name | pad(26))\u001b[38;2;62;96;122m\($dir | short | pad(34))\($note)\u001b[0m"];
(.sessions | map(.name)) as $names
| (.sessions | sort_by(.running | not)[]
| if .running then row("●"; "0;255;136"; .name; .dir; "\(.attached) attached · \(.activity | ago)")
else row("○"; "62;96;122"; .name; .dir; "dormant · resumes Claude") end),
(.projects[] | select(.name as $n | $names | index($n) | not)
| row("+"; "0;212;255"; .name; .dir; "new session"))
| @tsv'
}
if [ "${1:-}" = "--entries" ]; then
entries
exit 0
fi
# Tell the web UI (via the terminal title) that the picker is showing.
printf '\033]0;devdesk:picker|Pick a session\007'
while :; do
selection=$(entries | fzf --ansi --layout=reverse --no-multi --print-query \
--delimiter=$'\t' --with-nth=2 \
--prompt='devdesk ▸ ' --pointer='▶' --marker='◈' \
--header=$'Enter: open · type a new name to create it · Ctrl-R: refresh · Esc: quit\n' \
--bind="ctrl-r:reload($0 --entries)" \
--color='bg:#010304,bg+:#0d1520,fg:#c4d9ee,fg+:#ffffff,hl:#FF6B00,hl+:#FF8C2B' \
--color='prompt:#FF6B00,pointer:#00D4FF,marker:#00FF88,header:#3e607a,info:#3e607a,border:#0d1520')
rc=$?
# Esc / Ctrl-C: leave the picker (the web UI then offers to reopen it).
[ "$rc" -eq 130 ] && exit 0
query=$(sed -n '1p' <<<"$selection")
choice=$(sed -n '2p' <<<"$selection" | cut -f1)
if [ -z "$choice" ] && [ -n "$query" ]; then
if dd_valid_name "$query"; then
choice=$query
else
printf '\n Invalid session name: %s\n' "$query"
sleep 1.5
continue
fi
fi
if [ -n "$choice" ]; then
exec "$DEVDESK_BIN" attach "$choice"
fi
done
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# ExecStart for devdesk.service: builds the ttyd command line from
# /etc/devdesk/env and /etc/devdesk/secret.env (loaded by systemd).
set -euo pipefail
if [ -z "${DEVDESK_CREDENTIAL:-}" ]; then
echo "devdesk-serve: DEVDESK_CREDENTIAL is not set (expected in /etc/devdesk/secret.env)" >&2
exit 1
fi
args=(
--port "${DEVDESK_PORT:-7681}"
--writable # ttyd >= 1.7.4 is read-only by default
--url-arg # ?arg=<session> becomes `devdesk attach <session>`
--check-origin # refuse cross-site WebSocket connections
--max-clients "${DEVDESK_MAX_CLIENTS:-8}"
--credential "$DEVDESK_CREDENTIAL" # injected by NPM as an Authorization header
--index "${DEVDESK_INDEX:-/usr/share/devdesk/index.html}"
--debug "${DEVDESK_TTYD_LOG_LEVEL:-3}" # errors+warnings only: notice level would log the credential
)
if [ -n "${DEVDESK_BIND:-}" ]; then
args+=(--interface "$DEVDESK_BIND")
fi
exec "${DEVDESK_TTYD:-/usr/lib/devdesk/bin/ttyd}" "${args[@]}" "${DEVDESK_BIN:-/usr/bin/devdesk}" attach
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# Claude Code SessionStart hook (wired in via claude-settings.json). Records the
# Claude session ID for the DevDesk session this Claude runs in, so that after a
# reboot devdesk-claude-run can `claude --resume` exactly this conversation.
# Fires on startup, resume, /clear and /compact. Must never block Claude.
# 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
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
exit 0
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# ExecStartPost for devdesk-tmux.service: wait until the tmux server answers on
# its socket, so `systemctl start devdesk-tmux` only returns once clients can attach.
SOCK=${DEVDESK_SOCKET:-/run/devdesk/tmux.sock}
for _ in $(seq 1 100); do
if [ -S "$SOCK" ] && tmux -N -S "$SOCK" start-server 2>/dev/null; then
exit 0
fi
sleep 0.1
done
echo "devdesk-tmux-ready: tmux server did not come up on $SOCK" >&2
exit 1