- Ask Claude: strip control characters, send as bracketed paste (closes #2) - end: forget before kill, so ending from inside a session leaves no ghost (closes #3) - reconnects use `attach --existing` and never resurrect ended/renamed sessions (closes #4) - repo: git --no-optional-locks, no safe.directory override, refresh on connect/bell/60s (closes #5) - uploads: unique names via mktemp, cleanup trap on hangup (closes #6) - clipboard: write-only OSC 52 provider, tmux allow-passthrough off (closes #7) - uploads: size check up front, streamed slices, idle timeout (closes #9) - --list: path last in TSV rows, tolerant numbers (closes #10) - tokens via curl config on stdin; userinfo stripped from remote URLs (closes #11) - ttyd max clients 32 and at most 2 concurrent side-channel calls (#8; BroadcastChannel polling still open) - tmux: fixed terminal-features index (no duplicates on re-source), no status-right clock Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
373 lines
16 KiB
Bash
Executable File
373 lines
16 KiB
Bash
Executable File
#!/bin/bash
|
|
# devdesk — persistent Claude Code sessions in tmux, shared by the browser UI and SSH.
|
|
#
|
|
# devdesk attach [NAME] attach to session NAME, creating it if needed; no NAME opens the picker
|
|
# devdesk new NAME [PROJECT] create session NAME (detached) in PROJECT's folder, or the default folder
|
|
# devdesk rename OLD NEW rename a session (running or dormant)
|
|
# devdesk end NAME stop session NAME and forget it
|
|
# devdesk repo NAME git + Gitea CI status of the session's folder (JSON)
|
|
# devdesk alerts firing/pending Grafana alerts (JSON)
|
|
# devdesk ls list sessions
|
|
# devdesk pick interactive picker
|
|
#
|
|
# The web UI calls `devdesk attach --list | --repo NAME | --alerts | --upload SESSION FILE BYTES |
|
|
# --new NAME [PROJECT] | --rename OLD NEW | --end NAME | --existing NAME`
|
|
# through ttyd; those print JSON.
|
|
set -euo pipefail
|
|
|
|
# shellcheck source=libexec/devdesk-lib.sh
|
|
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
|
|
|
|
usage() {
|
|
sed -n '2,12s/^# \{0,1\}//p' "$0"
|
|
}
|
|
|
|
# Tab-separated running sessions: name, dir, attached clients, last activity, windows.
|
|
running_tsv() {
|
|
dd_server_up || return 0
|
|
# Path last: it may contain anything (tabs included) and is re-joined below.
|
|
dd_tmux list-sessions -F '#{session_name} #{session_attached} #{session_activity} #{session_windows} #{session_path}' 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
|
|
}
|
|
|
|
# {"<session>": {...}} merged from the per-session JSON files in DIR
|
|
# (status-line snapshots, hook states).
|
|
per_session_json() {
|
|
local dir=$1 f
|
|
if [ -d "$dir" ]; then
|
|
for f in "$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() {
|
|
local reg='{"sessions":{}}'
|
|
[ -s "$DEVDESK_REGISTRY" ] && reg=$(cat "$DEVDESK_REGISTRY")
|
|
jq -cMn \
|
|
--argjson reg "$reg" \
|
|
--arg run "$(running_tsv)" \
|
|
--arg proj "$(projects_tsv)" \
|
|
--argjson ctx "$(per_session_json "$DEVDESK_CONTEXT_DIR")" \
|
|
--argjson st "$(per_session_json "$DEVDESK_STATUS_DIR")" \
|
|
--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: (.[4:] | join("\t")), running: true,
|
|
attached: (.[1] | tonumber? // 0), activity: (.[2] | tonumber? // 0),
|
|
windows: (.[3] | tonumber? // 0)}] 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)}))
|
|
) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null)}),
|
|
projects: ([{name: $dflt, dir: $root}]
|
|
+ [rows($proj)[] | {name: .[0], dir: (.[1:] | join("\t"))}
|
|
| 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", "CONTEXT", "DIRECTORY"] | @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'
|
|
}
|
|
|
|
session_exists() {
|
|
{ dd_server_up && dd_tmux has-session -t "=$1" 2>/dev/null; } || [ -n "$(dd_registry_get "$1" dir)" ]
|
|
}
|
|
|
|
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() {
|
|
{ [ $# -ge 1 ] && [ $# -le 2 ]; } || dd_die "usage: devdesk new NAME [PROJECT]"
|
|
dd_valid_name "$1" || dd_die "invalid session name: $1"
|
|
local dir
|
|
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"
|
|
}
|
|
|
|
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"
|
|
# Forget first: killing the session SIGHUPs us when run from inside it.
|
|
dd_registry_remove "$1"
|
|
rm -rf "${DEVDESK_STATE_DIR:?}/uploads/$1"
|
|
if dd_server_up; then
|
|
dd_tmux kill-session -t "=$1" 2>/dev/null || true
|
|
fi
|
|
}
|
|
|
|
# GET $DEVDESK_GITEA_URL/api/v1<path>; token optional (private repos only).
|
|
gitea_get() {
|
|
# The token goes in via a curl config on stdin, never on the command line (ps).
|
|
{
|
|
if [ -r "$DEVDESK_GITEA_TOKEN_FILE" ]; then
|
|
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the token
|
|
printf 'header = "Authorization: token %s"\n' "$(tr -d '[:space:]"\\' <"$DEVDESK_GITEA_TOKEN_FILE")"
|
|
fi
|
|
} | curl -fsS --max-time 4 -K - "$DEVDESK_GITEA_URL/api/v1$1" 2>/dev/null
|
|
}
|
|
|
|
# owner/repo when REMOTE points at our Gitea (https or ssh, any configured host), else nothing.
|
|
gitea_slug() {
|
|
local remote=$1 host hosts
|
|
hosts="${DEVDESK_GITEA_URL#*://} $DEVDESK_GITEA_ALIASES"
|
|
for host in $hosts; do
|
|
host=${host%%/*}
|
|
host=${host%%:*}
|
|
if [[ $remote =~ [@/]${host//./\.}(:[0-9]+)?[:/]+([^/]+/[^/]+)$ ]]; then
|
|
printf '%s\n' "${BASH_REMATCH[2]%.git}"
|
|
return
|
|
fi
|
|
done
|
|
}
|
|
|
|
cmd_repo() {
|
|
[ $# -eq 1 ] || dd_die "usage: devdesk repo NAME"
|
|
dd_valid_name "$1" || dd_die "invalid session name: $1"
|
|
local dir g branch sha="" dirty ahead=null behind=null upstream=false remote slug="" ci=null prs=null
|
|
dir=$(dd_resolve_dir "$1")
|
|
# --no-optional-locks: never take .git/index.lock (Claude's own git would collide).
|
|
# No safe.directory override: repos owned by other users stay untrusted.
|
|
g=(git --no-optional-locks -C "$dir")
|
|
if ! "${g[@]}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
jq -cMn --arg s "$1" --arg dir "$dir" '{session: $s, dir: $dir, git: false}'
|
|
return
|
|
fi
|
|
branch=$("${g[@]}" symbolic-ref --short -q HEAD || echo "(detached)")
|
|
sha=$("${g[@]}" rev-parse -q --verify HEAD 2>/dev/null || true)
|
|
dirty=$("${g[@]}" status --porcelain 2>/dev/null | wc -l)
|
|
if "${g[@]}" rev-parse -q --verify '@{u}' >/dev/null 2>&1; then
|
|
upstream=true
|
|
read -r behind ahead < <("${g[@]}" rev-list --left-right --count '@{u}...HEAD')
|
|
fi
|
|
remote=$("${g[@]}" remote get-url origin 2>/dev/null | sed -E 's#^([a-z+]+://)[^/@]*@#\1#' || true)
|
|
[ -n "$remote" ] && slug=$(gitea_slug "$remote")
|
|
if [ -n "$slug" ] && [ -n "$sha" ]; then
|
|
ci=$(gitea_get "/repos/$slug/commits/$sha/status" |
|
|
jq -cM '{state, total: .total_count,
|
|
url: (.statuses[0]?.target_url | if . then sub("/jobs/[0-9]+$"; "") else null end),
|
|
jobs: [.statuses[]? | {name: .context, status}]}' 2>/dev/null) || ci=null
|
|
prs=$(gitea_get "/repos/$slug/pulls?state=open&limit=50" | jq -M 'length' 2>/dev/null) || prs=null
|
|
fi
|
|
jq -cMn --arg s "$1" --arg dir "$dir" --arg branch "$branch" --arg sha "${sha:0:7}" \
|
|
--argjson dirty "$dirty" --argjson ahead "$ahead" --argjson behind "$behind" \
|
|
--argjson upstream "$upstream" --arg remote "$remote" --arg slug "$slug" \
|
|
--arg web "$DEVDESK_GITEA_URL" --argjson ci "${ci:-null}" --argjson prs "${prs:-null}" '
|
|
{session: $s, dir: $dir, git: true, branch: $branch, sha: $sha, dirty: $dirty,
|
|
ahead: $ahead, behind: $behind, upstream: $upstream, remote: $remote,
|
|
repo: (if $slug == "" then null else $slug end),
|
|
url: (if $slug == "" then null else "\($web)/\($slug)" end),
|
|
ci: (if $ci == null then null else $ci + {url: (if $ci.url then $web + $ci.url else null end)} end),
|
|
prs: $prs}'
|
|
}
|
|
|
|
# Firing and pending Grafana alert rules, most severe first.
|
|
cmd_alerts() {
|
|
if [ -z "$DEVDESK_GRAFANA_URL" ]; then
|
|
jq -cMn '{configured: false, alerts: []}'
|
|
return
|
|
fi
|
|
local raw
|
|
if ! raw=$({
|
|
if [ -r "$DEVDESK_GRAFANA_TOKEN_FILE" ]; then
|
|
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the token
|
|
printf 'header = "Authorization: Bearer %s"\n' "$(tr -d '[:space:]"\\' <"$DEVDESK_GRAFANA_TOKEN_FILE")"
|
|
fi
|
|
} | curl -fsS --max-time 6 -K - "$DEVDESK_GRAFANA_URL/api/prometheus/grafana/api/v1/rules" 2>&1); then
|
|
jq -cMn --arg e "${raw:-no response}" '{configured: true, error: $e, alerts: []}'
|
|
return
|
|
fi
|
|
jq -cM --arg pub "$DEVDESK_GRAFANA_PUBLIC_URL" '
|
|
def num: (tonumber? // .) | tostring;
|
|
{configured: true, checked: (now | floor), alerts: [
|
|
.data.groups[] | .name as $group | .file as $folder | .rules[]
|
|
| select(.state == "firing" or .state == "pending")
|
|
| . as $r
|
|
| ([.alerts[]? | select(.state == "Alerting" or .state == "Pending")]) as $active
|
|
| {name, state, severity: (.labels.severity // null), group: $group, folder: $folder,
|
|
since: (.activeAt // $active[0].activeAt // null),
|
|
summary: ((.annotations.summary // .annotations.description // "")
|
|
| gsub("\\{\\{ *\\$value *\\}\\}"; ($active[0].value // "?" | num))),
|
|
instances: [$active[] | .labels
|
|
| del(.__alert_rule_uid__, .__grafana_origin__, .alertname, .grafana_folder, .__name__, .severity)
|
|
| to_entries | map("\(.key)=\(.value)") | join(" ")
|
|
| select(. != "")],
|
|
url: "\($pub)/alerting/grafana/\(.uid)/view"}
|
|
] | sort_by(if .severity == "critical" then 0 elif .severity == "warning" then 1 else 2 end)}' <<<"$raw"
|
|
}
|
|
|
|
# Receive a file for SESSION over the terminal: the browser waits for the ready
|
|
# line, then sends exactly ceil(BYTES/3)*4 bytes of base64 as input.
|
|
cmd_upload() {
|
|
[ $# -eq 3 ] || dd_die "usage: devdesk attach --upload SESSION FILENAME BYTES"
|
|
local session=$1 name size=$3 dir dest b64len actual
|
|
dd_valid_name "$session" || dd_die "invalid session name: $session"
|
|
{ [[ $size =~ ^[0-9]+$ ]] && [ "$size" -gt 0 ] && [ "$size" -le "$DEVDESK_UPLOAD_MAX" ]; } ||
|
|
dd_die "file is empty or larger than $((DEVDESK_UPLOAD_MAX / 1048576)) MB"
|
|
name=$(dd_safe_filename "$2")
|
|
dir="$DEVDESK_STATE_DIR/uploads/$session"
|
|
mkdir -p "$dir"
|
|
find "$DEVDESK_STATE_DIR/uploads" -type f -mtime +7 -delete 2>/dev/null || true
|
|
# Unique even for same-second uploads of the same name.
|
|
dest=$(mktemp --tmpdir="$dir" --suffix="-$name" "$(date +%Y%m%d-%H%M%S)-XXXX")
|
|
# A dropped connection SIGHUPs us mid-transfer: never leave a partial file.
|
|
trap 'rm -f "$dest"' HUP INT TERM
|
|
# shellcheck disable=SC2017 # integer ceil(size/3)*4: the base64 length, on purpose
|
|
b64len=$(((size + 2) / 3 * 4))
|
|
stty raw -echo 2>/dev/null || true
|
|
printf 'DEVDESK-UPLOAD-READY\r\n'
|
|
head -c "$b64len" | base64 -d >"$dest" 2>/dev/null || true
|
|
stty sane 2>/dev/null || true
|
|
actual=$(stat -c %s "$dest" 2>/dev/null || echo 0)
|
|
if [ "$actual" != "$size" ]; then
|
|
rm -f "$dest"
|
|
dd_die "upload incomplete ($actual of $size bytes)"
|
|
fi
|
|
trap - HUP INT TERM
|
|
chmod 0644 "$dest"
|
|
jq -cMn --arg p "$dest" '{ok: true, path: $p}'
|
|
}
|
|
|
|
ok_json() {
|
|
printf '{"ok":true}\n'
|
|
}
|
|
|
|
cmd_attach() {
|
|
if [ $# -eq 0 ]; then
|
|
exec "$DEVDESK_LIBEXEC/devdesk-picker"
|
|
fi
|
|
case $1 in
|
|
--list)
|
|
[ $# -eq 1 ] || dd_die "--list takes no arguments"
|
|
cmd_list
|
|
return
|
|
;;
|
|
--repo)
|
|
shift
|
|
cmd_repo "$@"
|
|
return
|
|
;;
|
|
--alerts)
|
|
cmd_alerts
|
|
return
|
|
;;
|
|
--upload)
|
|
shift
|
|
cmd_upload "$@"
|
|
return
|
|
;;
|
|
--new | --rename | --end)
|
|
local action=${1#--}
|
|
shift
|
|
"cmd_$action" "$@"
|
|
ok_json
|
|
return
|
|
;;
|
|
--existing)
|
|
# Reconnects: never re-create a session that was ended or renamed meanwhile.
|
|
shift
|
|
[ $# -eq 1 ] || dd_die "attach --existing takes exactly one session name"
|
|
dd_valid_name "$1" || dd_die "invalid session name: $1"
|
|
if ! { dd_server_up && dd_tmux has-session -t "=$1" 2>/dev/null; } && [ -z "$(dd_registry_get "$1" dir)" ]; then
|
|
dd_die "session '$1' no longer exists (it was ended or renamed)"
|
|
fi
|
|
;;
|
|
-*) dd_die "unknown option: $1" ;;
|
|
esac
|
|
[ $# -eq 1 ] || dd_die "attach takes exactly one session name"
|
|
local name=$1 dir args
|
|
dd_valid_name "$name" || dd_die "invalid session name: $name"
|
|
dd_ensure_server
|
|
dir=$(dd_resolve_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=${1:-help}
|
|
[ $# -gt 0 ] && shift
|
|
case $cmd in
|
|
attach | a) cmd_attach "$@" ;;
|
|
new) cmd_new "$@" ;;
|
|
rename | mv) cmd_rename "$@" ;;
|
|
repo) cmd_repo "$@" ;;
|
|
alerts) cmd_alerts ;;
|
|
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
|