feat: DevDesk v0.1.0, a persistent browser terminal for Claude Code
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:
Executable
+160
@@ -0,0 +1,160 @@
|
||||
#!/bin/bash
|
||||
# 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 --list print sessions/projects as JSON (used by the web UI over ttyd)
|
||||
# devdesk new NAME create session NAME detached, if it does not exist
|
||||
# devdesk ls list sessions
|
||||
# devdesk end NAME end session NAME and forget it
|
||||
set -euo pipefail
|
||||
|
||||
# shellcheck source=libexec/devdesk-lib.sh
|
||||
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
|
||||
|
||||
usage() {
|
||||
sed -n '2,9s/^# \{0,1\}//p' "$0"
|
||||
}
|
||||
|
||||
# Tab-separated running sessions: name, dir, attached clients, last activity, windows.
|
||||
running_tsv() {
|
||||
dd_server_up || return 0
|
||||
dd_tmux list-sessions -F '#{session_name} #{session_path} #{session_attached} #{session_activity} #{session_windows}' 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Tab-separated top-level project directories: slug, dir.
|
||||
projects_tsv() {
|
||||
local d real root
|
||||
root=$(realpath -e -- "$DEVDESK_PROJECTS_ROOT" 2>/dev/null) || return 0
|
||||
for d in "$DEVDESK_PROJECTS_ROOT"/*/; do
|
||||
[ -d "$d" ] || continue
|
||||
d=${d%/}
|
||||
case ${d##*/} in node_modules | _* | lost+found) continue ;; esac
|
||||
# Only offer directories that attach would accept (no symlinks out of the root).
|
||||
real=$(realpath -e -- "$d" 2>/dev/null) || continue
|
||||
case "$real/" in "$root"/*) ;; *) continue ;; esac
|
||||
printf '%s\t%s\n' "$(dd_slugify "${d##*/}")" "$d"
|
||||
done
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
local reg='{"sessions":{}}'
|
||||
[ -s "$DEVDESK_REGISTRY" ] && reg=$(cat "$DEVDESK_REGISTRY")
|
||||
jq -cMn \
|
||||
--argjson reg "$reg" \
|
||||
--arg run "$(running_tsv)" \
|
||||
--arg proj "$(projects_tsv)" \
|
||||
--arg dflt "$DEVDESK_DEFAULT_SESSION" \
|
||||
--arg root "$DEVDESK_PROJECTS_ROOT" '
|
||||
def rows($s): $s | split("\n") | map(select(length > 0) | split("\t"));
|
||||
($reg.sessions // {}) as $known
|
||||
| [rows($run)[] | {name: .[0], dir: .[1], running: true,
|
||||
attached: (.[2] | tonumber), activity: (.[3] | tonumber),
|
||||
windows: (.[4] | tonumber)}] as $live
|
||||
| ($live | map(.name)) as $liveNames
|
||||
| {default: $dflt, root: $root,
|
||||
sessions: (
|
||||
($live | map(. + {claude_session_id: ($known[.name].claude_session_id // null)}))
|
||||
+ ($known | to_entries
|
||||
| map(select(.key as $k | $liveNames | index($k) | not))
|
||||
| map({name: .key, dir: .value.dir, running: false, attached: 0,
|
||||
activity: null, windows: 0,
|
||||
claude_session_id: (.value.claude_session_id // null)}))
|
||||
),
|
||||
projects: ([{name: $dflt, dir: $root}]
|
||||
+ [rows($proj)[] | {name: .[0], dir: .[1]}
|
||||
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
|
||||
}
|
||||
|
||||
cmd_ls() {
|
||||
cmd_list | jq -Mr '
|
||||
.sessions | if length == 0 then "no sessions" else
|
||||
(["", "SESSION", "CLIENTS", "DIRECTORY"] | @tsv),
|
||||
(.[] | [(if .running then "●" else "○" end), .name, (.attached | tostring), .dir] | @tsv)
|
||||
end' | column -t -s $'\t'
|
||||
}
|
||||
|
||||
# Prints the project directory for NAME, or "" if NAME already runs and has none we can resolve.
|
||||
session_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() {
|
||||
local name=$1 dir=$2 args
|
||||
dd_tmux has-session -t "=$name" 2>/dev/null && return 0
|
||||
mapfile -d '' args < <(dd_new_session_args "$name" "$dir")
|
||||
# Another client may win the race; "duplicate session" is fine.
|
||||
dd_tmux new-session -d "${args[@]}" 2>/dev/null || dd_tmux has-session -t "=$name" 2>/dev/null ||
|
||||
dd_die "could not create session '$name'"
|
||||
dd_registry_set_dir "$name" "$dir"
|
||||
}
|
||||
|
||||
cmd_new() {
|
||||
[ $# -eq 1 ] || dd_die "usage: devdesk new NAME"
|
||||
dd_valid_name "$1" || dd_die "invalid session name: $1"
|
||||
dd_ensure_server
|
||||
local dir
|
||||
dir=$(session_dir "$1")
|
||||
create_detached "$1" "$dir"
|
||||
}
|
||||
|
||||
cmd_attach() {
|
||||
if [ $# -eq 0 ]; then
|
||||
exec "$DEVDESK_LIBEXEC/devdesk-picker"
|
||||
fi
|
||||
[ $# -eq 1 ] || dd_die "attach takes exactly one session name"
|
||||
if [ "$1" = "--list" ]; then
|
||||
cmd_list
|
||||
return
|
||||
fi
|
||||
local name=$1 dir args
|
||||
dd_valid_name "$name" || dd_die "invalid session name: $name"
|
||||
dd_ensure_server
|
||||
dir=$(session_dir "$name")
|
||||
|
||||
# Already inside a DevDesk pane: switch this client instead of nesting.
|
||||
local outer=${TMUX:-}
|
||||
if [ -n "$outer" ] && [ "${outer%%,*}" = "$DEVDESK_SOCKET" ]; then
|
||||
create_detached "$name" "$dir"
|
||||
exec tmux -N -S "$DEVDESK_SOCKET" switch-client -t "=$name"
|
||||
fi
|
||||
unset TMUX
|
||||
|
||||
if ! dd_tmux has-session -t "=$name" 2>/dev/null; then
|
||||
dd_registry_set_dir "$name" "$dir"
|
||||
fi
|
||||
mapfile -d '' args < <(dd_new_session_args "$name" "$dir")
|
||||
# -A attaches if the session exists, so two tabs racing never create a duplicate.
|
||||
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}
|
||||
[ $# -gt 0 ] && shift
|
||||
case $cmd in
|
||||
attach | a) cmd_attach "$@" ;;
|
||||
new) cmd_new "$@" ;;
|
||||
ls | list) cmd_ls ;;
|
||||
--list) cmd_list ;;
|
||||
end) cmd_end "$@" ;;
|
||||
pick) exec "$DEVDESK_LIBEXEC/devdesk-picker" ;;
|
||||
help | -h | --help) usage ;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user