#!/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` # 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 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 } # {"": {...}} 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: .[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)})) ) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // 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", "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" if dd_server_up; then dd_tmux kill-session -t "=$1" 2>/dev/null || true fi dd_registry_remove "$1" } # GET $DEVDESK_GITEA_URL/api/v1; token optional (private repos only). gitea_get() { local auth=() if [ -r "$DEVDESK_GITEA_TOKEN_FILE" ]; then auth=(-H "Authorization: token $(tr -d '[:space:]' <"$DEVDESK_GITEA_TOKEN_FILE")") fi curl -fsS --max-time 4 "${auth[@]}" "$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") g=(git -c safe.directory='*' -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 || 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 auth=() raw if [ -r "$DEVDESK_GRAFANA_TOKEN_FILE" ]; then auth=(-H "Authorization: Bearer $(tr -d '[:space:]' <"$DEVDESK_GRAFANA_TOKEN_FILE")") fi if ! raw=$(curl -fsS --max-time 6 "${auth[@]}" "$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 dest="$dir/$(date +%Y%m%d-%H%M%S)-$name" # 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 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 ;; -*) 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