#!/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] [--worktree]
#                                create session NAME (detached) in PROJECT's folder, or the default
#                                folder; --worktree gives it its own git worktree on branch devdesk/NAME
#   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 guests [ID|IP|NAME]  Proxmox CTs/VMs: the node each runs on now, state, IPs
#   devdesk ls                   list sessions
#   devdesk pick                 interactive picker
#
# The web UI calls `devdesk attach --list | --repo NAME | --alerts | --investigate SESSION UID |
# --upload SESSION FILE BYTES | --new NAME [PROJECT] [--worktree] | --rename OLD NEW | --end NAME |
# --last-reply NAME | --unsplit NAME | --fs-* SESSION … | --existing NAME`
# through ttyd; those print JSON.
set -euo pipefail

# shellcheck source=libexec/devdesk-lib.sh
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
# shellcheck source=libexec/devdesk-files.sh
. "$DEVDESK_LIBEXEC/devdesk-files.sh"
# shellcheck source=libexec/devdesk-inbox.sh
. "$DEVDESK_LIBEXEC/devdesk-inbox.sh"
# shellcheck source=libexec/devdesk-history.sh
. "$DEVDESK_LIBEXEC/devdesk-history.sh"
# shellcheck source=libexec/devdesk-ops.sh
. "$DEVDESK_LIBEXEC/devdesk-ops.sh"
# shellcheck source=libexec/devdesk-fsops.sh
. "$DEVDESK_LIBEXEC/devdesk-fsops.sh"

usage() {
    sed -n '2,13s/^# \{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\t%s\n' "$(dd_slugify "${d##*/}")" "$([ -e "$d/.git" ] && echo 1 || echo 0)" "$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")" \
        --argjson tags "$(per_session_json "$DEVDESK_STATE_DIR/tags")" \
        --arg rclive "$(dd_rc_live)" \
        --argjson rcdef "$(dd_rc_default)" \
        --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),
                               worktree: ($known[.name].worktree // 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),
                        worktree: (.value.worktree // null)}))
           ) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null), tags: ($tags[.name].hosts // []),
                         remote_control: (($known[.name].remote_control) as $own | if $own == null then $rcdef else $own end),
                         rc_active: (.name as $n | $rclive | split("\n") | any(.[]; . == $n))}),
           remote_control_default: $rcdef,
           projects: ([{name: $dflt, dir: $root}]
                      + [rows($proj)[] | {name: .[0], git: (.[1] == "1"), dir: (.[2:] | 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() {
    local worktree=0 a args=()
    for a in "$@"; do
        if [ "$a" = --worktree ]; then worktree=1; else args+=("$a"); fi
    done
    set -- "${args[@]}"
    { [ $# -ge 1 ] && [ $# -le 2 ]; } || dd_die "usage: devdesk new NAME [PROJECT] [--worktree]"
    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
        [ "$worktree" = 0 ] || dd_die "--worktree needs a project"
        dir=$(dd_resolve_dir "$1")
    fi
    [ "$worktree" = 0 ] || dir=$(dd_worktree_add "$1" "$2" "$dir")
    dd_ensure_server
    create_detached "$1" "$dir"
}

# A git worktree of PROJECT for session NAME (devdesk#44), so two Claudes on one
# repository never edit the same checkout. It lives in $ROOT/.worktrees (hidden
# from the project list) on branch devdesk/NAME, made from the project's HEAD or
# reused if that branch exists. Prints the folder the session starts in.
dd_worktree_add() {
    local name=$1 project=$2 dir=$3 top prefix wt branch="devdesk/$1" g
    top=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) || dd_die "'$project' is not a git repository"
    prefix=$(git -C "$dir" rev-parse --show-prefix)
    wt="$DEVDESK_PROJECTS_ROOT/.worktrees/$project--$name"
    [ ! -e "$wt" ] || dd_die "$wt already exists"
    mkdir -p "$DEVDESK_PROJECTS_ROOT/.worktrees"
    g=(git -C "$top")
    if "${g[@]}" show-ref -q --verify "refs/heads/$branch"; then
        "${g[@]}" worktree add -q "$wt" "$branch" >&2 || dd_die "git worktree add failed"
    else
        "${g[@]}" worktree add -q -b "$branch" "$wt" HEAD >&2 || dd_die "git worktree add failed"
    fi
    wt=$(realpath -e -- "$wt") || dd_die "git worktree add failed"
    # shellcheck disable=SC2016 # jq variables
    dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {worktree: {path: $w, repo: $r, branch: $b}})' \
        --arg n "$name" --arg w "$wt" --arg r "$top" --arg b "$branch"
    printf '%s\n' "$wt${prefix:+/${prefix%/}}"
}

# Ending a worktree session removes the worktree when nothing in it is uncommitted,
# and its branch only when that is merged (git branch -d). Otherwise both stay.
dd_worktree_end() {
    local wt=$1 repo=$2 branch=$3
    [ -d "$wt" ] || return 0
    if [ -z "$(git -C "$wt" status --porcelain 2>/dev/null)" ]; then
        git -C "$repo" worktree remove "$wt" 2>/dev/null || return 0
        git -C "$repo" branch -d "$branch" >/dev/null 2>&1 || true
    fi
}

# --unsplit NAME (devdesk#52): close the extra panes a split left in the session's
# windows. Each window keeps one pane: Claude's in the claude window, else the
# first. Prints {ok, closed}.
cmd_unsplit() {
    [ $# -eq 1 ] || dd_die "usage: devdesk attach --unsplit NAME"
    dd_valid_name "$1" || dd_die "invalid session name: $1"
    { dd_server_up && dd_tmux has-session -t "=$1" 2>/dev/null; } || dd_die "session '$1' isn't running"
    local win keep pane closed=0
    while read -r win; do
        keep=$(dd_tmux list-panes -t "=$1:$win" -F '#{pane_id} #{pane_start_command}' | awk '/devdesk-claude-run/ { print $1; exit }')
        [ -n "$keep" ] || keep=$(dd_tmux list-panes -t "=$1:$win" -F '#{pane_id}' | head -n 1)
        while read -r pane; do
            [ "$pane" = "$keep" ] && continue
            dd_tmux kill-pane -t "$pane" && closed=$((closed + 1))
        done < <(dd_tmux list-panes -t "=$1:$win" -F '#{pane_id}')
    done < <(dd_tmux list-windows -t "=$1" -F '#{window_index}')
    jq -cMn --argjson c "$closed" '{ok: true, closed: $c}'
}

# Sessions whose running Claude has Remote Control on, read from the processes
# themselves (devdesk#61), one name per line.
dd_rc_live() {
    local session window pid child
    dd_server_up || return 0
    while IFS=$'\t' read -r session window pid; do
        [ "$window" = claude ] || continue
        for child in $(pgrep -P "$pid" 2>/dev/null); do
            if tr '\0' '\n' <"/proc/$child/cmdline" 2>/dev/null | grep -qx -- --remote-control; then
                printf '%s\n' "$session"
                break
            fi
        done
    done < <(dd_tmux list-panes -a -F $'#{session_name}\t#{window_name}\t#{pane_pid}' 2>/dev/null)
}

# The Claude process of a session's claude window, if any.
dd_claude_pid() {
    local pid
    pid=$(dd_tmux list-panes -t "=$1:claude" -F '#{pane_pid}' 2>/dev/null | head -n1)
    [ -n "$pid" ] && pgrep -P "$pid" 2>/dev/null | head -n1
}

# --remote-control NAME on|off (devdesk#61): the session's own choice. A running,
# idle Claude restarts on the same conversation right away; one that is working
# or waiting gets it at its next start. Prints {ok, remote_control, applied}.
cmd_remote_control() {
    [ $# -eq 2 ] || dd_die "usage: devdesk attach --remote-control NAME on|off"
    local name=$1 value applied=saved pid state
    dd_valid_name "$name" || dd_die "invalid session name: $name"
    case $2 in on) value=true ;; off) value=false ;; *) dd_die "on or off" ;; esac
    { session_exists "$name"; } || dd_die "no session named '$name'"
    # shellcheck disable=SC2016 # jq variables
    dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {remote_control: ($v == "true")})' --arg n "$name" --arg v "$value"
    if dd_server_up && dd_tmux has-session -t "=$name" 2>/dev/null && pid=$(dd_claude_pid "$name") && [ -n "$pid" ]; then
        state=$(jq -rM '.state // empty' "$DEVDESK_STATUS_DIR/$name.json" 2>/dev/null) || state="" # no state yet: idle
        if [ "$state" = working ] || [ "$state" = waiting ]; then
            applied=next-start
        else
            mkdir -p "$DEVDESK_STATE_DIR/restart"
            touch "$DEVDESK_STATE_DIR/restart/$name"
            kill -TERM "$pid" 2>/dev/null || true
            applied=now
        fi
    fi
    jq -cMn --argjson v "$value" --arg a "$applied" '{ok: true, remote_control: $v, applied: $a}'
}

# Remote Control default for new sessions: Settings, else DEVDESK_REMOTE_CONTROL. true/false.
dd_rc_default() {
    local def
    def=$(jq -rM '.remote_control_default | if . == null then empty else tostring end' "$DEVDESK_STATE_DIR/settings.json" 2>/dev/null)
    if [ -n "$def" ]; then echo "$def"; elif [ "${DEVDESK_REMOTE_CONTROL:-0}" = 1 ]; then echo true; else echo false; fi
}

# --settings [remote-control-default on|off] (devdesk#61): DevDesk-wide settings
# kept in $DEVDESK_STATE_DIR/settings.json; prints them.
cmd_settings() {
    local file="$DEVDESK_STATE_DIR/settings.json" tmp
    if [ $# -eq 2 ]; then
        [ "$1" = remote-control-default ] || dd_die "unknown setting: $1"
        case $2 in on | off) ;; *) dd_die "on or off" ;; esac
        tmp=$(mktemp "$DEVDESK_STATE_DIR/.settings.XXXXXX")
        jq -cM --argjson v "$([ "$2" = on ] && echo true || echo false)" '. + {remote_control_default: $v}' "$file" 2>/dev/null >"$tmp" ||
            jq -cMn --argjson v "$([ "$2" = on ] && echo true || echo false)" '{remote_control_default: $v}' >"$tmp"
        mv -f "$tmp" "$file"
    elif [ $# -ne 0 ]; then
        dd_die "usage: devdesk attach --settings [remote-control-default on|off]"
    fi
    jq -cMn --argjson d "$(dd_rc_default)" '{ok: true, remote_control_default: $d}'
}

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"
    dd_registry_locked rename_locked "$old" "$new" "$running" || dd_die "could not rename '$old'"
}

# tmux and the registry change together, under the registry lock (see
# devdesk-session-hook).
rename_locked() {
    if [ "$3" = 1 ]; then
        dd_tmux rename-session -t "=$1" "$2" || return 1
        dd_tmux set-environment -t "=$2" DEVDESK_SESSION "$2"
    fi
    dd_registry_rename "$1" "$2"
}

cmd_end() {
    [ $# -eq 1 ] || dd_die "usage: devdesk end NAME"
    dd_valid_name "$1" || dd_die "invalid session name: $1"
    local wt
    wt=$(jq -rM --arg n "$1" '.sessions[$n].worktree // empty | [.path, .repo, .branch] | @tsv' "$DEVDESK_REGISTRY" 2>/dev/null) || wt=""
    # Forget first: killing the session SIGHUPs us when run from inside it.
    dd_registry_remove "$1"
    if [ -n "$wt" ]; then
        IFS=$'\t' read -r -a wt <<<"$wt"
        dd_worktree_end "${wt[@]}"
    fi
    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 changes 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)
    # The first changed files, for the panel to open as diffs (devdesk#46).
    changes='[]'
    [ "$dirty" = 0 ] || changes=$(cmd_git_status "$1" 2>/dev/null | jq -cM '.files[:8] // []' 2>/dev/null) || changes='[]'
    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 changes "${changes:-[]}" --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, changes: $changes,
         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}'
}

# 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
        dd_linger
        return
        ;;
    --repo)
        shift
        cmd_repo "$@"
        dd_linger
        return
        ;;
    --alerts)
        cmd_alerts
        dd_linger
        return
        ;;
    --investigate)
        shift
        cmd_investigate "$@"
        dd_linger
        return
        ;;
    --upload)
        shift
        cmd_upload "$@"
        dd_linger
        return
        ;;
    --fs-list | --fs-find | --fs-read | --fs-write | --git-status | --git-show | --asset | \
        --fs-mkdir | --fs-move | --fs-copy | --fs-delete | --fs-restore | --fs-put | --fs-download | --fs-grep)
        local action=${1#--}
        shift
        "cmd_${action//-/_}" "$@"
        dd_linger
        return
        ;;
    --peek | --search | --last-reply | --unsplit | --remote-control | --settings | --delete-convo)
        local action=${1#--}
        shift
        "cmd_${action//-/_}" "$@"
        dd_linger
        return
        ;;
    --resume-convo | --presence)
        local action=${1#--}
        shift
        "cmd_${action//-/_}" "$@"
        ok_json
        dd_linger
        return
        ;;
    --answer | --reply)
        local action=${1#--}
        shift
        "cmd_$action" "$@"
        ok_json
        dd_linger
        return
        ;;
    --new | --rename | --end)
        local action=${1#--}
        shift
        "cmd_$action" "$@"
        ok_json
        dd_linger
        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 ;;
guests) cmd_guests "$@" ;;
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
