#!/usr/bin/env bash
set -euo pipefail

MUX_CONFIG_DIR="${HOME}/.config/mux"
MUX_PROJECTS="${MUX_CONFIG_DIR}/projects.json"
MUX_NARRATIONS="${MUX_CONFIG_DIR}/seen-narrations.json"
MUX_LIB="${HOME}/.config/mux/muxlib.py"
MUX_PROMPTS_DIR="${MUX_CONFIG_DIR}/pending-prompts"
MUX_DATA_DIR="${HOME}/.local/share/mux"

# --- Helpers ---

die() { printf '\033[31m%s\033[0m\n' "$*" >&2; exit 1; }

require_tmux() {
  command -v tmux >/dev/null 2>&1 || die "tmux is not installed. Install with: brew install tmux"
}

require_python() {
  command -v python3 >/dev/null 2>&1 || die "python3 is required. Install with: brew install python3"
}

require_config() {
  [[ -f "$MUX_PROJECTS" ]] || die "No projects configured. Create ${MUX_PROJECTS} first."
}

in_tmux() { [[ -n "${TMUX:-}" ]]; }

# Absolute path to THIS mux script. tmux hooks run `run-shell` in the tmux
# SERVER's environment, whose PATH is whatever the server was started with —
# a bare `mux` in a hook is a coin flip. Resolve once, at hook-set time.
mux_self() {
  local d
  d=$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd) || return 1
  printf '%s/%s' "$d" "$(basename "${BASH_SOURCE[0]}")"
}

slugify() {
  local input="$*"
  # Flatten to one line FIRST — sed/cut below process input line-by-line,
  # so a multi-paragraph prompt would otherwise yield a multi-line "slug"
  # (one line per input line) that mangles or empties out every caller's
  # branch/directory name instead of collapsing to a single slug.
  input="${input//$'\n'/ }"
  # If prompt has "name. rest", use only the name part
  if [[ "$input" == *". "* ]]; then
    input="${input%%". "*}"
  fi
  echo "$input" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g; s/--*/-/g; s/^-//; s/-$//' | cut -c1-30
}

# Detects the zsh dollar-amount corruption signature in an ALREADY-RECEIVED
# prompt string. This cannot recover the original text — the user's own
# interactive zsh expands `$160,000` as positional parameter 160 (unset)
# BEFORE mux ever sees argv, so `a $160,000 job` arrives here as `a ,000 job`.
# The only thing mux can do is recognize the wreckage and say so: a
# comma-led numeric fragment at a word boundary is not something a person
# would type, and is the exact shape zsh leaves behind.
warn_prompt_dollar_corruption() {
  local prompt="$1"
  if [[ "$prompt" =~ (^|[[:space:]]),[0-9] ]]; then
    echo "⚠ This prompt may have lost a \$ amount to shell expansion (zsh reads" >&2
    echo "  \$160,000 as positional parameter 160, which silently vanishes)." >&2
    echo "  Use single quotes for prompts with dollar amounts: mux <project> '...'" >&2
  fi
}

project_path() {
  local name="$1"
  python3 "$MUX_LIB" project-path "$name"
}

project_names() {
  python3 "$MUX_LIB" project-names
}

project_color() {
  local name="$1"
  python3 "$MUX_LIB" project-color "$name"
}

narrate() {
  local key="$1" msg="$2"
  [[ -f "$MUX_NARRATIONS" ]] || echo '{}' > "$MUX_NARRATIONS"
  local seen
  seen=$(python3 "$MUX_LIB" narrate-check "$key")
  if [[ "$seen" == "no" ]]; then
    printf '\033[2m%s\033[0m\n' "$msg"
    python3 "$MUX_LIB" narrate-mark "$key"
  fi
}

format_age() {
  local seconds="$1"
  if (( seconds < 60 )); then echo "just now"
  elif (( seconds < 3600 )); then echo "$(( seconds / 60 ))m ago"
  elif (( seconds < 86400 )); then echo "$(( seconds / 3600 ))h ago"
  else echo "$(( seconds / 86400 ))d ago"
  fi
}

activity_status() {
  local seconds="$1"
  if (( seconds < 300 )); then echo "active"
  elif (( seconds < 14400 )); then echo "idle"
  else echo "sleeping"
  fi
}

notes_count() {
  local project="$1"
  python3 "$MUX_LIB" notes-count "$project"
}

portal_switch() {
  local project="$1"
  local color
  color=$(python3 "$MUX_LIB" project-color "$project") || color="#1a1a2e"
  [[ -z "$color" ]] && color="#1a1a2e"

  # Update tmux status bar color for this session (may not exist yet)
  tmux set-option -t "=$project" status-style "bg=${color},fg=#cccccc" 2>/dev/null || true

  # Send background color change to the active pane in the target session
  # Uses tmux send-keys with a printf that runs inside the session's terminal,
  # so the OSC reaches the real terminal (not a popup shell that's about to close)
  local active_pane
  active_pane=$(tmux display-message -t "=$project" -p '#{pane_id}' 2>/dev/null) || true
  if [[ -n "$active_pane" ]]; then
    tmux run-shell -t "$active_pane" "printf '\\033]11;${color}\\a'" 2>/dev/null || true
  else
    # Session doesn't exist yet — send directly (new-session path)
    if in_tmux; then
      printf '\ePtmux;\e\e]11;%s\a\e\\' "$color" 2>/dev/null || true
    else
      printf '\e]11;%s\a' "$color" 2>/dev/null || true
    fi
  fi

  # Spoken name (if enabled)
  local speak
  speak=$(python3 "$MUX_LIB" project-setting spoken_name) || speak="False"

  if [[ "$speak" == "True" ]] && command -v say >/dev/null 2>&1; then
    local voice
    voice=$(python3 "$MUX_LIB" project-setting voice) || voice="Samantha"
    [[ -z "$voice" ]] && voice="Samantha"
    local spoken_name
    spoken_name=$(echo "$project" | sed 's/-/ /g')
    say -v "$voice" "$spoken_name" &
    disown
  fi

  # Arrival summary — one-line window inventory when 2+ windows open
  (
    sleep 1
    local wc
    wc=$(tmux list-windows -t "=$project" 2>/dev/null | wc -l | tr -d ' ')
    if [[ "$wc" -gt 1 ]]; then
      local summary
      summary=$(tmux list-windows -t "=$project" -F '#{window_name}' 2>/dev/null | tr '\n' ', ' | sed 's/, $//')
      tmux display-message -t "=$project" "${project}: ${wc} windows — ${summary}"
    fi
  ) &
  disown

  # Show notes inside tmux (popup after attach)
  local has_notes
  has_notes=$(python3 "$MUX_LIB" notes-has "$project") || has_notes="no"
  if [[ "$has_notes" == "yes" ]]; then
    (
      sleep 1
      tmux display-popup -t "$project" -w 50 -h 12 -E "python3 ${HOME}/.config/mux/show-notes.py $project; read"
    ) &
    disown
  fi
}

setup_session_hooks() {
  local project="$1" path="$2"

  # Hook: when any client switches to this session, apply portal color
  # Iterates over all attached clients so multi-client setups get the color
  local color
  color=$(project_color "$project") || color="#1a1a2e"
  [[ -z "$color" ]] && color="#1a1a2e"
  tmux set-hook -t "$project" client-session-changed \
    "run-shell 'for tty in \$(tmux list-clients -t \"=$project\" -F \"#{client_tty}\" 2>/dev/null); do printf \"\\033]11;${color}\\a\" > \"\$tty\" 2>/dev/null || true; done'" 2>/dev/null || true

  # Hook: auto-cleanup worktrees when a window closes.
  # MUST be -g (global). pane-exited is a window-scoped hook, so `set-hook -t
  # "$project"` (a session target) resolves to whatever window is CURRENT at
  # setup_session_hooks() time — window 1 (Main) right after desk creation —
  # and never attaches to any worktree window opened afterward. Registering
  # globally and reading the session at FIRE time via #{session_name} (rather
  # than baking in "$project" at setup time) makes the hook fire on every
  # window, including worktree ones. Verified empirically 2026-08-17: hook
  # present on every window 1, absent on all worktree windows, across three
  # desks — two months of worktree windows closed with no cleanup (327
  # worktrees, 58GB). Calling this repeatedly (once per desk open) is safe:
  # tmux set-hook without -a REPLACES the prior hook of the same name rather
  # than appending, so re-running setup_session_hooks across many desks does
  # not stack duplicate global hooks.
  tmux set-hook -g pane-exited \
    "run-shell '${HOME}/.config/mux/worktree-cleanup.sh \"#{session_name}\" \"#{window_name}\" 2>/dev/null || true'" 2>/dev/null || true

  # Window format: ·wt suffix colored by health (green=healthy, red=issues)
  # on worktree windows; ·N amber QA-handoff badge on the main window (the
  # two are mutually exclusive — @mux_qa is only ever set on the non-worktree
  # window). See the QA handoff dispatch helpers.
  tmux set-option -t "$project" window-status-format \
    '#[fg=#888888] #I:#W#{?@mux_worktree,#{?@wt_healthy,#[fg=#66bbaa],#[fg=#ff6666]}·wt#[default],}#{?@mux_qa,#[fg=#e0af68]·#{@mux_qa}#[default],} ' 2>/dev/null || true
  tmux set-option -t "$project" window-status-current-format \
    '#[fg=#ffffff,bg=#4a4a8a,bold] #I:#W#{?@mux_worktree,#{?@wt_healthy,#[fg=#88ddcc],#[fg=#ff6666]}·wt#[default],}#{?@mux_qa,#[fg=#e0af68]·#{@mux_qa}#[default],} ' 2>/dev/null || true

  # Restore the ·N QA badge from the durable queue on desk open (the tmux
  # window option is ephemeral; the queue dir is the source of truth).
  qa_refresh_indicator "$project" 2>/dev/null || true

  # Hook: re-reconcile the ·N badge on every window switch. mux is not the
  # only writer of the queue dir — the watchtower gate exit DELETES a
  # descriptor when a verdict lands, and markHandoffMerged WRITES one when a
  # merge-pending handoff merges, neither through mux. An event-only badge
  # therefore drifts and stays drifted until the operator happens to run a
  # `mux qa` verb (act:ca6a19a0 — a badge of 2 outlived an emptied queue by
  # ~40 minutes). Reconciling on window switch keeps the projection honest
  # without polling; the refresh is two tmux calls plus a find, and any
  # failure is swallowed so a hook can never interrupt a switch.
  local self
  self=$(mux_self 2>/dev/null) || self="mux"
  tmux set-hook -t "$project" after-select-window \
    "run-shell '\"${self}\" qa refresh \"${project}\" >/dev/null 2>&1 || true'" 2>/dev/null || true

  # Global bindings live in mux.tmux.conf (single source of truth), which
  # ~/.tmux.conf sources at server start. Re-sourcing here applies upgrades
  # to a running server — bindings no longer evaporate on server restart
  # the way inline bind-key calls did.
  tmux source-file "${HOME}/.config/mux/mux.tmux.conf" 2>/dev/null || true
}

MUX_WORKTREES_DIR="${HOME}/.mux/worktrees"
MUX_WORKTREES_JSON="${MUX_CONFIG_DIR}/worktrees.json"

worktree_registry_init() {
  [[ -f "$MUX_WORKTREES_JSON" ]] || echo '{"active":[]}' > "$MUX_WORKTREES_JSON"
}

worktree_registry_add() {
  worktree_registry_init
  python3 "$MUX_LIB" worktree-add "$@"
}

worktree_registry_remove() {
  worktree_registry_init
  python3 "$MUX_LIB" worktree-remove "$@"
}

worktree_is_active() {
  worktree_registry_init
  python3 "$MUX_LIB" worktree-is-active "$@"
}

has_active_claude() {
  local project="$1"
  local count
  count=$(tmux list-windows -t "=$project" -F '#{pane_current_command}' 2>/dev/null \
    | grep -cvE '^(zsh|bash|sh|fish|login)$' || true)
  [[ "$count" -ge 1 ]]
}

create_worktree() {
  local project="$1" task_slug="$2"
  local proj_path
  proj_path=$(project_path "$project") || return 1

  local branch_name="mux/${task_slug}"
  local wt_path="${MUX_WORKTREES_DIR}/${project}-${task_slug}"

  mkdir -p "$MUX_WORKTREES_DIR"

  if [[ -d "$wt_path" ]]; then
    echo "A worktree for '${task_slug}' already exists. Pick a different name or run: mux worktree cleanup ${project} ${task_slug}" >&2
    return 1
  fi

  git -C "$proj_path" worktree add "$wt_path" -b "$branch_name" HEAD >/dev/null 2>&1 || {
    # Branch might already exist (parked) — reuse it
    git -C "$proj_path" worktree add "$wt_path" "$branch_name" >/dev/null 2>&1 || {
      echo "Failed to create worktree for '${task_slug}'." >&2
      return 1
    }
  }

  # .claude/ infra is copied into the worktree (not symlinked — a symlink
  # makes CC resolve through to the main repo path, leaking it into every
  # Read/Edit/Write) by the health check below, which runs with --refresh.
  # The authored/infra classification lives in exactly one place —
  # worktree-session-health.sh: ANY tracked .claude/ file is authored project
  # record (plans, methodology, rules, cabinet docs, anything committed) that
  # stays exactly as `git worktree add` checked it out and is never frozen
  # with assume-unchanged, so worktree edits commit; only gitignored infra
  # (skills, agents, settings copies) is copied from main and hidden from
  # status. See .claude/rules/artifacts-of-thought.md.
  for f in .mcp.json .claudeignore; do
    [[ -f "$proj_path/$f" ]] && ln -sf "$proj_path/$f" "$wt_path/$f"
  done
  # Symlink shared state: pib.db (work tracker) and node_modules (MCP deps)
  [[ -f "$proj_path/pib.db" ]] && ln -sf "$proj_path/pib.db" "$wt_path/pib.db"
  [[ -d "$proj_path/node_modules" ]] && ln -sf "$proj_path/node_modules" "$wt_path/node_modules"

  # Symlink Claude Code project identity so memory/settings are shared.
  # CC slugifies paths by replacing / and . with -, so we must match that.
  local main_slug wt_slug projects_dir
  projects_dir="$HOME/.claude/projects"
  main_slug=$(echo "$proj_path" | sed 's|[/.]|-|g')
  wt_slug=$(echo "$wt_path" | sed 's|[/.]|-|g')
  if [[ -d "$projects_dir/$main_slug" ]] && [[ ! -e "$projects_dir/$wt_slug" ]]; then
    ln -sf "$projects_dir/$main_slug" "$projects_dir/$wt_slug"
  fi

  worktree_registry_add "$project" "$task_slug" "$branch_name" "$wt_path"

  # Populate .claude/ infra (--refresh forces sync_claude_infra) and validate
  # the worktree we just created.
  local health_output health_ok
  health_output=$(worktree_health_check "$proj_path" "$wt_path" --refresh 2>&1) && health_ok=1 || health_ok=0
  if [[ "$health_ok" -eq 0 ]]; then
    echo "⚠ Worktree health issues:" >&2
    echo "$health_output" >&2
  fi

  narrate "worktree" "This window is in an isolated worktree — your changes here won't conflict with other sessions. When you're done, mux will offer to merge." >&2

  echo "$wt_path"
}

worktree_health_check() {
  # Extra args pass through (e.g. --refresh forces an infra re-sync).
  local proj_path="$1" wt_path="$2"
  shift 2
  "${HOME}/.config/mux/worktree-session-health.sh" "$proj_path" "$wt_path" "$@" 2>&1
}

# Reap the lane's docker containers BEFORE the worktree dir (the compose
# project name source) disappears. Single implementation in
# worktree-session-health.sh --reap (exact compose-label match, containers
# only, never volumes); the pane-exited worktree-cleanup.sh hook delegates to
# the same mode, so every removal path reaps from one implementation.
# Fail-open: a failed reap reports loudly but never blocks the removal.
worktree_reap_containers() {
  local proj_path="$1" wt_path="$2"
  "${HOME}/.config/mux/worktree-session-health.sh" "$proj_path" "$wt_path" --reap 2>&1 || true
}

worktree_cleanup() {
  local project="$1" task_slug="$2"
  local proj_path
  proj_path=$(project_path "$project") || return 1

  local branch_name="mux/${task_slug}"
  local wt_path="${MUX_WORKTREES_DIR}/${project}-${task_slug}"

  [[ -d "$wt_path" ]] || { echo "Worktree not found: $wt_path"; return 1; }

  # Check for .claude/ drift before cleanup — settings changes would be lost
  if [[ -d "$wt_path/.claude" ]] && [[ ! -L "$wt_path/.claude" ]] && [[ -d "$proj_path/.claude" ]]; then
    local drift_files
    drift_files=$(diff -rq "$wt_path/.claude" "$proj_path/.claude" 2>/dev/null | grep "^Files.*differ$" | head -5) || true
    if [[ -n "$drift_files" ]]; then
      echo "⚠ .claude/ was modified in this worktree:"
      echo "$drift_files" | sed 's|^Files ||; s| differ$||; s| and | → |'
      echo "These changes will be lost. Copy them to the main repo first if needed."
      echo ""
    fi
  fi

  # Shared dirty detection — single source of truth in
  # ~/.config/mux/worktree-dirty-check.sh (the tmux pane-exited cleanup hook
  # delegates to the same helper, so both paths reach the same verdict from
  # one implementation). Fail-DIRTY: a missing or failing helper classifies
  # the worktree dirty — never clean on the strength of a failed check.
  local dirty_check="${MUX_CONFIG_DIR}/worktree-dirty-check.sh"
  local dirty_line="" kv has_commits="?" has_uncommitted="?" verdict="dirty"
  if [[ -x "$dirty_check" ]]; then
    dirty_line=$("$dirty_check" "$wt_path" "$proj_path" 2>/dev/null) || dirty_line=""
  fi
  for kv in $dirty_line; do
    case "$kv" in
      commits=*) has_commits="${kv#commits=}" ;;
      uncommitted=*) has_uncommitted="${kv#uncommitted=}" ;;
      verdict=*) verdict="${kv#verdict=}" ;;
    esac
  done

  if [[ "$verdict" == "clean" ]]; then
    worktree_reap_containers "$proj_path" "$wt_path"
    git -C "$proj_path" worktree remove "$wt_path" 2>/dev/null || rm -rf "$wt_path"
    git -C "$proj_path" branch -d "$branch_name" 2>/dev/null || true
    worktree_registry_remove "$project" "$task_slug"
    rm -f "$HOME/.local/share/mux/wt-health/${project}-${task_slug}"
    echo "Worktree cleaned up (no changes)."
    return 0
  fi

  echo ""
  echo "Session '${task_slug}' has work to merge:"
  # Counts may be "?" when the dirty-check could not determine them (fail-DIRTY).
  [[ "$has_uncommitted" != "0" ]] && echo "  ${has_uncommitted} uncommitted file(s)"
  [[ "$has_commits" != "0" ]] && echo "  ${has_commits} commit(s) ahead"
  echo ""
  echo "What do you want to do?"
  echo "  1) Merge to main branch"
  echo "  2) Keep the branch (park for later)"
  echo "  3) Discard all changes"
  echo ""
  printf 'Choice [1/2/3]: '
  local choice
  read -r choice

  case "$choice" in
    1)
      if [[ "$has_uncommitted" != "0" ]]; then
        git -C "$wt_path" add -A
        git -C "$wt_path" commit -m "mux: work from ${task_slug} session"
      fi
      local main_branch
      main_branch=$(git -C "$proj_path" rev-parse --abbrev-ref HEAD)
      if git -C "$proj_path" merge "$branch_name" --no-edit 2>/dev/null; then
        worktree_reap_containers "$proj_path" "$wt_path"
        git -C "$proj_path" worktree remove "$wt_path" 2>/dev/null || rm -rf "$wt_path"
        git -C "$proj_path" branch -d "$branch_name" 2>/dev/null || true
        worktree_registry_remove "$project" "$task_slug"
        rm -f "$HOME/.local/share/mux/wt-health/${project}-${task_slug}"
        echo "Merged to ${main_branch} and cleaned up."
      else
        echo "Merge conflict! Resolve in ${proj_path}, then run:"
        echo "  git worktree remove ${wt_path}"
        echo "  git branch -d ${branch_name}"
      fi
      ;;
    2)
      if [[ "$has_uncommitted" != "0" ]]; then
        git -C "$wt_path" add -A
        git -C "$wt_path" commit -m "mux: parked work from ${task_slug} session"
      fi
      worktree_reap_containers "$proj_path" "$wt_path"
      git -C "$proj_path" worktree remove "$wt_path" 2>/dev/null || rm -rf "$wt_path"
      worktree_registry_remove "$project" "$task_slug"
      echo "Branch '${branch_name}' parked."
      echo "Resume later with: mux ${project} --branch ${branch_name}"
      ;;
    3)
      printf 'This will PERMANENTLY DELETE all work in this session. Are you sure? (y/N) '
      local confirm
      read -r confirm
      case "$confirm" in
        y|Y)
          worktree_reap_containers "$proj_path" "$wt_path"
          git -C "$proj_path" worktree remove --force "$wt_path" 2>/dev/null || rm -rf "$wt_path"
          git -C "$proj_path" branch -D "$branch_name" 2>/dev/null || true
          worktree_registry_remove "$project" "$task_slug"
          rm -f "$HOME/.local/share/mux/wt-health/${project}-${task_slug}"
          echo "Discarded."
          ;;
        *)
          echo "Cancelled. Branch '${branch_name}' kept."
          ;;
      esac
      ;;
    *)
      echo "Invalid choice. Branch kept. Run 'mux worktree cleanup ${project} ${task_slug}' later."
      ;;
  esac
}

queue_claude_start() {
  local target="$1" prompt="$2" win_path="$3"
  # Resolve window index immediately (names can be unreliable in background)
  local sess win_idx
  sess=$(tmux display-message -t "$target" -p '#{session_name}' 2>/dev/null)
  win_idx=$(tmux display-message -t "$target" -p '#{window_index}' 2>/dev/null)
  local stable="${sess}:${win_idx}"
  tmux set-window-option -t "$stable" @mux_claude 1 2>/dev/null || true

  # No orient injection: /orient is retired. Session-start state loading is
  # the watchtower SessionStart hook's job (ambient injection); mux launches
  # the session and hands it the prompt, nothing more.
  if [[ -n "$prompt" ]]; then
    # Prompt-bearing launch: hand the prompt to the CLI as its INITIAL-PROMPT
    # argument (`claude "<prompt>"` — interactive session, submits on startup).
    # This auto-submits with zero keystroke timing, and is structurally safe:
    # if `cd && claude` fails (path gone, claude off PATH, crash on boot) the
    # prompt is just an unused argument — it is NEVER executed as shell, the
    # way a paste+Enter into a fallen-back shell prompt would be. The prompt
    # is staged in a per-window file and read by the PANE's shell via
    # $(cat …), so multi-line/quote-heavy content never passes through tmux
    # key parsing.
    local seed_dir="${HOME}/.local/share/mux/seed-prompts"
    mkdir -p "$seed_dir" 2>/dev/null || true
    local pf="${seed_dir}/${sess}-${win_idx}.txt"
    printf '%s' "$prompt" > "$pf"
    tmux send-keys -t "$stable" "cd '${win_path}' && claude \"\$(cat '${pf}')\"" Enter
  else
    # Empty-prompt launch (e.g. QA station relaunch): start plain.
    tmux send-keys -t "$stable" "cd '${win_path}' && claude" Enter
  fi
}

queue_claude_resume() {
  local target="$1" session_id="$2" win_path="$3" note="${4:-}"
  local sess win_idx
  sess=$(tmux display-message -t "$target" -p '#{session_name}' 2>/dev/null)
  win_idx=$(tmux display-message -t "$target" -p '#{window_index}' 2>/dev/null)
  local stable="${sess}:${win_idx}"
  tmux set-window-option -t "$stable" @mux_claude 1 2>/dev/null || true
  if [[ -n "$note" ]]; then
    # A resumed session that immediately hits a real failure (a Docker call,
    # say) has no way to know it was JUST restored after a reboot — the
    # transcript it resumes into predates the restart entirely. Same staging
    # pattern queue_claude_start uses for fresh prompts (write to a file,
    # read via `$(cat …)` in the pane's own shell): a note passed inline
    # here would hit the same $-expansion hazard act:f564c7f2 fixed at the
    # OTHER prompt-taking entry point, and this is a second one.
    local seed_dir="${HOME}/.local/share/mux/seed-prompts"
    mkdir -p "$seed_dir" 2>/dev/null || true
    local pf="${seed_dir}/resume-${sess}-${win_idx}.txt"
    printf '%s' "$note" > "$pf"
    tmux send-keys -t "$stable" "cd '${win_path}' && claude --resume ${session_id} \"\$(cat '${pf}')\"" Enter
  else
    tmux send-keys -t "$stable" "cd '${win_path}' && claude --resume ${session_id}" Enter
  fi
}

# --- Desk dispatch: QA handoffs + declared routines (stage 2) ---
#
# The push model, and the desk's SINGLE dispatch path. Two producers feed it:
# /qa-handoff packages a just-merged worktree into an inbox item
# (qa-handoff-protocol.md), and watchtower's routine engine
# (watchtower-routines.mjs) fires declared interactive routines — both then
# call `mux qa dispatch <descriptor>`. We route the prompt to the desk's
# MAIN window (window 1 — the permanent main session that owns post-merge
# QA / publish / deploy and the desk's standing routines):
#
#   verified-idle Claude  → inject the pickup prompt (send-keys)
#   bare shell (no Claude) → launch a fresh Claude with the prompt
#   busy / other / closed  → queue on disk + light the ·N tab badge
#
# Window 1 drains the queue one at a time with `mux qa drain`. mux stays
# DUMB: it routes an opaque, self-contained prompt and never parses handoff
# content — so it's decoupled from the watchtower inbox schema and degrades
# to inbox-only when not installed. The on-disk queue is the durable source
# of truth; the ·N badge is a pure projection of it, recomputed at every
# mutation and on desk open, so a tmux restart can't desync the two.

MUX_QA_DIR="${MUX_QA_DIR:-${HOME}/.local/share/mux/qa-handoff}"

# A descriptor is the small JSON a producer writes per dispatch:
#   { project, project_path, item_id, what, pickup_prompt[, merged_commit] }
# (merged_commit is qa-handoff-only; routine descriptors omit it.) mux only
# reads project / pickup_prompt / item_id / what / merged_commit.
#
# Layout: <desk>/<item_id>.json is queued (counts toward the ·N badge);
# <desk>/in-flight/<item_id>.json is drained-awaiting-verdict (badge-dark,
# visible in `mux qa status`). The gate exit in watchtower-queue.mjs deletes
# the descriptor from either location when a verdict/dismissal lands.

# Count the queued (badge-counted) descriptors for a desk. A desk that has
# never received a handoff has no dir at all — that is ZERO, not an error.
# Without the guard `find` exits non-zero, `set -o pipefail` propagates it out
# of the pipeline, and a plain `n=$(qa_count …)` assignment kills the caller
# under `set -e` — which is exactly how the badge-refresh call added in
# act:ca6a19a0 first took down `mux qa dispatch` on a fresh desk.
qa_count() {
  local dir="${MUX_QA_DIR}/${1}"
  [[ -d "$dir" ]] || { echo 0; return 0; }
  find "$dir" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' '
}

# Same contract for the drained-awaiting-verdict layer (badge-dark, but shown
# by `mux qa status` / `mux qa list`).
qa_inflight_count() {
  local dir="${MUX_QA_DIR}/${1}/in-flight"
  [[ -d "$dir" ]] || { echo 0; return 0; }
  find "$dir" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' '
}

# Resolve a project's MAIN STATION window — where post-merge QA lands.
# Worktree windows (@mux_worktree=1) are never the station. Among the
# non-worktree windows it PREFERS the lowest-index one running a live Claude
# (@mux_claude=1), and otherwise falls back to the lowest-index non-worktree
# window (a bare shell we can launch into). That fallback is the hardening:
# if window 1's Claude died and the live main session moved to another
# non-worktree window, the handoff still finds it. Echoes "<session>:<idx>",
# or fails if the desk isn't open. list-windows is index-ordered.
qa_main_window() {
  local project="$1" idx wt claude fallback=""
  while IFS='|' read -r idx wt claude; do
    [[ "$wt" == "1" ]] && continue
    [[ -z "$fallback" ]] && fallback="${project}:${idx}"
    if [[ "$claude" == "1" ]]; then
      echo "${project}:${idx}"
      return 0
    fi
  done < <(tmux list-windows -t "=$project" -F '#{window_index}|#{@mux_worktree}|#{@mux_claude}' 2>/dev/null)
  [[ -n "$fallback" ]] && { echo "$fallback"; return 0; }
  return 1
}

# Positive Claude-liveness verification for a pane whose @mux_claude marker
# is ABSENT. The marker only exists on windows mux itself launched into
# (v0.44+), so absence is NOT death evidence — a Claude started by hand, or
# before the marker scheme, is live but unmarked. Returns 0 only on positive
# proof: the foreground process looks like Claude's runtime AND the visible
# pane shows Claude Code's UI footer. Anything ambiguous returns 1 — callers
# must then refuse to inject, never launch over the pane.
# Process-name set: claude/node/bun, plus digit-led names — the macOS native
# installer execs a versioned binary, so a REAL Claude pane reports
# pane_current_command as the bare version string (e.g. "2.1.169"; live
# finding, dec-c1dbcd8b QA). The name is only the weak pre-filter; the
# footer match is the positive verification.
#
# The token-count fragment must NOT require a leading "· *" or bare
# "[0-9]+ tokens" — live finding 2026-08-19: this CC version renders the
# busy footer as "✳ Puzzling… (4m 35s · ↓ 8.3k tokens)", where a "↓" sits
# between the "·" and the digits, and the count itself carries a "k"
# suffix ("8.3k", not "8300"). The old anchored pattern matched neither,
# so a genuinely live main-window Claude session (this exact one) was
# refused by ensure_main_station with "can't verify it's Claude or a
# shell". The fragment now matches a bare digit-run (optional decimal,
# optional "k") immediately before "tokens", wherever it sits in the
# captured lines — the token count itself is the stable marker, not its
# surrounding punctuation, which drifts across CC versions.
#
# Enumerating status TEXT is a losing game — live finding 2026-08-20,
# same day, a THIRD state: an interactive menu prompt (e.g. /close's
# commit-confirmation "1. Commit all / 2. Review first / …") carries none
# of the words above at all; its own chrome reads "Enter to select · ↑/↓
# to navigate · Esc to cancel". Rather than add a fourth wording to chase
# (and a fifth, and a sixth, as CC's copy keeps changing), the fallback
# below matches the STRUCTURAL element common to every observed state —
# busy, idle, and mid-menu all render a horizontal box-drawing rule
# (── / U+2500) as part of the persistent bottom-bar chrome, which is far
# less likely to change than any status verb. Two separate runs of 10+
# rule characters is the signature verified against real busy and
# real menu captures; a single short rule is common enough in ordinary
# program output (a markdown table, a progress bar) to risk a false
# positive on a non-Claude pane that merely happens to match the
# claude/node/bun/digit-led process-name pre-filter above.
pane_is_live_claude() {
  local win="$1" cmd snap rule_lines
  cmd=$(tmux display-message -t "=$win" -p '#{pane_current_command}' 2>/dev/null)
  case "$cmd" in
    claude*|node*|bun*|[0-9]*) ;;
    *) return 1 ;;
  esac
  snap=$(tmux capture-pane -t "=$win" -p -S -30 2>/dev/null)
  printf '%s' "$snap" | grep -qiE 'esc to interrupt|[0-9]+(\.[0-9]+)?k? tokens|Running…|Compacting|Thinking…|\? for shortcuts|\? for help' && return 0
  rule_lines=$(printf '%s' "$snap" | grep -cE '─{10,}')
  [[ "$rule_lines" -ge 2 ]]
}

# Classify the main window's pane:
#   claude-idle | claude-busy | shell | other | no-window
# Conservative by design: anything ambiguous resolves to claude-busy or
# other, so we queue instead of injecting (better dark than wrong — the
# indicator and the inject path only fire on a positively verified state).
# The idle/busy markers track Claude Code's footer and may need tuning across
# CC versions; `mux qa status` surfaces the detected state so any drift is
# debuggable, not silent.
qa_pane_state() {
  local project="$1" win cmd is_claude snap
  win=$(qa_main_window "$project") || { echo "no-window"; return; }
  cmd=$(tmux display-message -t "=$win" -p '#{pane_current_command}' 2>/dev/null)
  is_claude=$(tmux show-window-option -t "=$win" -v @mux_claude 2>/dev/null)

  if [[ "$is_claude" != "1" ]]; then
    case "$cmd" in
      zsh|bash|sh|fish|login|-zsh|-bash|-sh) echo "shell"; return ;;
    esac
    # No marker but not a shell either: this may be a live pre-marker Claude
    # (act:ca5ac156 — keystrokes injected into its input box looked like
    # "mux new isn't working"). Verify positively and BACKFILL the marker so
    # every later check sees it; otherwise it's an unknown program — report
    # "other" and never treat the pane as launchable.
    if pane_is_live_claude "$win"; then
      tmux set-window-option -t "=$win" @mux_claude 1 2>/dev/null || true
      is_claude=1
    else
      echo "other"
      return
    fi
  fi

  snap=$(tmux capture-pane -t "=$win" -p -S -30 2>/dev/null)
  if printf '%s' "$snap" | grep -qiE 'esc to interrupt|[0-9]+(\.[0-9]+)?k? tokens|Running…|Compacting|Thinking…'; then
    echo "claude-busy"
  elif printf '%s' "$snap" | grep -qiE '\? for shortcuts|\? for help'; then
    echo "claude-idle"
  else
    echo "claude-busy"
  fi
}

# Recompute the ·N badge from the durable queue and set/clear it on the main
# window, keeping the badge a faithful projection of the queue dir.
#
# Called from FOUR classes of site, and all four are load-bearing:
#   1. after every mux mutation of the queue dir (enqueue / drain / clear),
#   2. at the top of every mux READ verb (status / list / refresh) — mux is
#      not the only writer, so a read is also the moment to reconcile,
#   3. from setup_session_hooks on desk open (the window option is ephemeral,
#      the queue dir is durable),
#   4. from the after-select-window hook that same function installs.
#
# 2 and 4 exist because the watchtower owns the other half of the queue:
# `clearDispatchEntries` deletes a descriptor on every gate exit and
# `writeStage2Dispatch` writes one when a merge-pending handoff merges, both
# fs-only with no tmux involvement. Refreshing only on mux's own mutations
# let the badge outlive the truth it projects (act:ca6a19a0).
qa_refresh_indicator() {
  local project="$1" win count
  win=$(qa_main_window "$project") || return 0
  count=$(qa_count "$project")
  if [[ "$count" -gt 0 ]]; then
    tmux set-window-option -t "=$win" @mux_qa "$count" 2>/dev/null || true
  else
    tmux set-window-option -t "=$win" -u @mux_qa 2>/dev/null || true
  fi
}

qa_enqueue() {
  local project="$1" descriptor="$2" item_id
  mkdir -p "${MUX_QA_DIR}/${project}"
  item_id=$(python3 -c 'import json,sys,re; v=str(json.load(open(sys.argv[1])).get("item_id") or "handoff"); print(re.sub(r"[^A-Za-z0-9._-]","-",v))' "$descriptor" 2>/dev/null)
  [[ -n "$item_id" ]] || item_id="handoff-$(date +%s)"
  cp "$descriptor" "${MUX_QA_DIR}/${project}/${item_id}.json"
  qa_refresh_indicator "$project"
}

# Inject into an idle Claude composer. C-u clears any half-typed input first
# so we never prepend to whatever was sitting in the box. The caller has
# already positively verified this pane is a live, idle Claude (qa_pane_state)
# — this helper does not re-gate; it only delivers the keystrokes.
#
# Delivery is via a tmux paste buffer with bracketed-paste mode (-p), not
# `send-keys -l`: a literal send treats embedded newlines in a multi-line
# pickup prompt as Enter and submits the prompt mid-way. Bracketed paste makes
# the TUI insert the whole block as one unit; the trailing Enter submits it.
# -d deletes the buffer after pasting; the per-call buffer name avoids
# collisions, and any failure path falls back to the literal send.
qa_inject() {
  local win="$1" prompt="$2" buf="mux-inject-$$-$RANDOM"
  tmux send-keys -t "=$win" C-u 2>/dev/null || true
  if printf '%s' "$prompt" | tmux load-buffer -b "$buf" - 2>/dev/null; then
    tmux paste-buffer -p -d -b "$buf" -t "=$win" 2>/dev/null || {
      tmux delete-buffer -b "$buf" 2>/dev/null || true
      tmux send-keys -t "=$win" -l "$prompt"
    }
  else
    tmux send-keys -t "=$win" -l "$prompt"
  fi
  tmux send-keys -t "=$win" Enter
}

# Launch a fresh Claude in a bare-shell main window with the handoff as its
# opening prompt. Reuses queue_claude_start (the same cd→claude→prompt
# sequence mux uses everywhere) — single source of truth for "start a
# Claude session with a prompt". C-u clears the shell line first.
qa_launch_fresh() {
  local win="$1" prompt="$2" project="$3" path
  path=$(project_path "$project" 2>/dev/null) || path="$PWD"
  tmux send-keys -t "=$win" C-u 2>/dev/null || true
  queue_claude_start "=$win" "$prompt" "$path"
}

# Ensure the desk has a live MAIN STATION (window 1 — a Claude on the main
# checkout that receives post-merge QA handoffs). Idempotent, and the keystone
# of the standing-station model: because a clean station is guaranteed, every
# `mux … "prompt"` can safely route its work to a worktree and let the merge
# hand back to the station. Four cases, in order:
#   - the main window holds a live Claude — marked, or unmarked-but-verified
#     (qa_pane_state backfills the marker) → reuse it (no-op)
#   - the main window is a bare shell (window 1's Claude died) → relaunch there
#   - the main window runs some OTHER program → refuse LOUDLY; never type
#     launch keystrokes into a pane that isn't positively a shell (the
#     act:ca5ac156 injection bug — a live pre-marker Claude got the launch
#     sequence typed into its input box)
#   - no non-worktree window at all (defensive) → create window 1, launch there
# Never touches worktree windows. Reuses qa_main_window (live-station-preferring
# resolution), qa_pane_state (the single pane classifier), and qa_launch_fresh
# (the single launch path) — no forked logic.
ensure_main_station() {
  local project="$1" path="$2" station state cmd
  station=$(qa_main_window "$project" 2>/dev/null || true)
  if [[ -n "$station" ]]; then
    state=$(qa_pane_state "$project")
    case "$state" in
      claude-idle|claude-busy)
        return 0 ;;
      shell)
        qa_launch_fresh "$station" "" "$project"
        return 0 ;;
      *)
        cmd=$(tmux display-message -t "=$station" -p '#{pane_current_command}' 2>/dev/null)
        printf "mux: main window %s is running '%s' — can't verify it's Claude or a shell, so no station was launched there (close that program or start claude yourself)\n" \
          "$station" "${cmd:-unknown}" >&2
        return 0 ;;
    esac
  fi
  tmux new-window -t "=$project" -c "$path" 2>/dev/null || true
  station=$(qa_main_window "$project" 2>/dev/null || true)
  [[ -n "$station" ]] && qa_launch_fresh "$station" "" "$project"
  return 0
}

# Create a worktree for WORK, and never silently fall back to the main
# checkout — dumping work onto the clean station is the exact silent failure
# the standing-station model exists to prevent. On a slug collision with an
# existing worktree, uniquify (slug-2, slug-3, …) so the work always gets its
# own isolated worktree, and surface the rename. Echoes the final slug; fails
# (caller refuses to run on main) only on a genuine, non-collision error.
create_work_worktree() {
  local project="$1" base="$2" slug="$2" n=1
  while (( n <= 20 )); do
    if create_worktree "$project" "$slug" >/dev/null 2>&1; then
      [[ "$slug" != "$base" ]] && \
        printf "worktree '%s' already existed — created '%s' instead\n" "$base" "$slug" >&2
      printf '%s\n' "$slug"
      return 0
    fi
    # A pre-existing dir means a slug collision → uniquify and retry. Anything
    # else is a real failure (bad git state) → don't mask it as main.
    [[ -d "${MUX_WORKTREES_DIR}/${project}-${slug}" ]] || return 1
    (( n++ )); slug="${base}-${n}"
  done
  return 1
}

# Can this desk get an isolated worktree at all? Requires a registered mux
# project whose directory is a git repository. When isolation is impossible
# BY DESIGN (unregistered desk, non-git project dir), callers fall through to
# the main checkout LOUDLY — a visible warning naming why — instead of dying.
# die is reserved for real worktree-creation failures on isolation-capable
# desks. On failure, echoes the human-readable reason; on success, nothing.
worktree_isolation_capable() {
  local project="$1" proj_path
  proj_path=$(project_path "$project" 2>/dev/null) || proj_path=""
  if [[ -z "$proj_path" ]]; then
    echo "this desk isn't a registered mux project"
    return 1
  fi
  if ! git -C "$proj_path" rev-parse --git-dir >/dev/null 2>&1; then
    echo "project directory isn't a git repository: ${proj_path}"
    return 1
  fi
  return 0
}

# --- Subcommands ---

cmd_picker() {
  require_tmux
  require_config

  if ! tmux has-session 2>/dev/null; then
    echo "No desks open. Try: mux <project-name>"
    echo ""
    echo "Available projects:"
    project_names | sed 's/^/  /'
    return 0
  fi

  local lines=()
  while IFS= read -r session; do
    local sess_name
    sess_name=$(echo "$session" | cut -d: -f1)
    while IFS= read -r window; do
      local win_idx win_name win_active
      win_idx=$(echo "$window" | cut -d' ' -f1)
      win_name=$(echo "$window" | cut -d' ' -f2)
      win_active=$(echo "$window" | cut -d' ' -f3)
      local marker=""
      [[ "$win_active" == "1" ]] && marker=" *"
      lines+=("${sess_name}:${win_idx}: ${win_name}${marker}")
    done < <(tmux list-windows -t "$sess_name" -F '#{window_index} #{window_name} #{window_active}' 2>/dev/null)
  done < <(tmux list-sessions -F '#{session_name}' 2>/dev/null)

  if [[ ${#lines[@]} -eq 0 ]]; then
    echo "No desks open. Try: mux <project-name>"
    return 0
  fi

  local selection
  if command -v fzf >/dev/null 2>&1; then
    narrate "picker-fzf" "Fuzzy-find your desk. Type to filter, Enter to select."
    selection=$(printf '%s\n' "${lines[@]}" | fzf --prompt="desk> " --height=40% --reverse) || return 0
  else
    narrate "picker-menu" "Pick a desk by number. Install fzf for fuzzy search: brew install fzf"
    local i=1
    for line in "${lines[@]}"; do
      printf '  %d) %s\n' "$i" "$line"
      (( i++ ))
    done
    printf '\nSelect (1-%d): ' "${#lines[@]}"
    local choice
    read -r choice
    [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#lines[@]} )) || { echo "Cancelled."; return 0; }
    selection="${lines[$((choice - 1))]}"
  fi

  local target_session target_window
  target_session=$(echo "$selection" | cut -d: -f1)
  target_window=$(echo "$selection" | cut -d: -f2)

  portal_switch "$target_session"

  if in_tmux; then
    tmux switch-client -t "${target_session}:${target_window}"
  else
    tmux attach-session -t "${target_session}:${target_window}"
  fi
}

cmd_open() {
  local project="$1"
  shift
  local prompt="${*:-}"
  [[ -n "$prompt" ]] && warn_prompt_dollar_corruption "$prompt"

  require_tmux
  require_config

  local path
  path=$(project_path "$project") || {
    echo "No project called '${project}'."
    echo ""
    echo "Available projects:"
    project_names | sed 's/^/  /'
    return 1
  }

  [[ -d "$path" ]] || die "Project directory does not exist: ${path}"

  portal_switch "$project"

  if tmux has-session -t "=$project" 2>/dev/null; then
    # Ensure project path and hooks are set (may be missing on older sessions)
    tmux set-environment -t "$project" MUX_PROJECT_PATH "$path" 2>/dev/null || true
    setup_session_hooks "$project" "$path"

    if [[ -n "$prompt" ]]; then
      local win_name win_path
      win_name=$(slugify "$prompt")

      # Standing-station model: keep window 1 a clean main station and run the
      # work in an isolated worktree, so the merge produces a real handoff.
      ensure_main_station "$project" "$path"
      # Never fall back to main — that would dump work onto the clean station.
      win_name=$(create_work_worktree "$project" "$win_name") \
        || die "Couldn't create a worktree for '${win_name}' — refusing to run work on the main checkout. Try: mux worktree ls"
      win_path="${MUX_WORKTREES_DIR}/${project}-${win_name}"

      tmux new-window -t "=$project" -n "$win_name" -c "$win_path"
      tmux set-window-option -t "=${project}:${win_name}" @mux_worktree 1 2>/dev/null || true
      tmux set-window-option -t "=${project}:${win_name}" @wt_healthy 1 2>/dev/null || true
      queue_claude_start "=${project}:${win_name}" "$prompt" "$win_path"

      if in_tmux; then
        tmux switch-client -t "=${project}:${win_name}"
      else
        tmux attach-session -t "=${project}:${win_name}"
      fi
    else
      if in_tmux; then
        tmux switch-client -t "=$project"
      else
        tmux attach-session -t "=$project"
      fi
    fi
  else
    # New desk: window 1 is the main checkout. Create it + wire hooks first so
    # the station and any worktree windows inherit the right format/env.
    tmux new-session -d -s "$project" -c "$path"
    tmux set-environment -t "$project" MUX_PROJECT_PATH "$path" 2>/dev/null || true
    setup_session_hooks "$project" "$path"

    if [[ -n "$prompt" ]]; then
      # Work on a fresh desk: window 1 becomes the main station, the work runs
      # in a worktree (window 2). Land the operator on the work.
      local win_name win_path
      win_name=$(slugify "$prompt")
      ensure_main_station "$project" "$path"
      # Never fall back to main — that would dump work onto the clean station.
      win_name=$(create_work_worktree "$project" "$win_name") \
        || die "Couldn't create a worktree for '${win_name}' — refusing to run work on the main checkout. Try: mux worktree ls"
      win_path="${MUX_WORKTREES_DIR}/${project}-${win_name}"
      tmux new-window -t "=$project" -n "$win_name" -c "$win_path"
      tmux set-window-option -t "=${project}:${win_name}" @mux_worktree 1 2>/dev/null || true
      tmux set-window-option -t "=${project}:${win_name}" @wt_healthy 1 2>/dev/null || true
      queue_claude_start "=${project}:${win_name}" "$prompt" "$win_path"
      if in_tmux; then
        tmux switch-client -t "=${project}:${win_name}"
      else
        tmux attach-session -t "=${project}:${win_name}"
      fi
    else
      # No prompt — the lightweight "just drop me into main" escape hatch.
      if in_tmux; then
        tmux switch-client -t "=$project"
      else
        tmux attach-session -t "=$project"
      fi
    fi
  fi
}

cmd_new() {
  local prompt="${*:-}"
  [[ -n "$prompt" ]] && warn_prompt_dollar_corruption "$prompt"
  require_tmux
  in_tmux || die "You need to be in a desk first. Try: mux <project-name>"

  narrate "new" "Created a new window in your current desk."

  local session
  session=$(tmux display-message -p '#{session_name}')
  local path
  path=$(project_path "$session" 2>/dev/null || tmux display-message -p '#{pane_current_path}')

  if [[ -n "$prompt" ]]; then
    local win_name win_path
    win_name=$(slugify "$prompt")

    # Standing-station model: ensure window 1 is a main station, run the work
    # in an isolated worktree so its merge hands back to the station.
    ensure_main_station "$session" "$path"
    # Never fall back to main — that would dump work onto the clean station.
    win_name=$(create_work_worktree "$session" "$win_name") \
      || die "Couldn't create a worktree for '${win_name}' — refusing to run work on the main checkout. Try: mux worktree ls"
    win_path="${MUX_WORKTREES_DIR}/${session}-${win_name}"

    tmux new-window -n "$win_name" -c "$win_path"
    tmux set-window-option -t "=${session}:${win_name}" @mux_worktree 1 2>/dev/null || true
    tmux set-window-option -t "=${session}:${win_name}" @wt_healthy 1 2>/dev/null || true
    queue_claude_start "=${session}:${win_name}" "$prompt" "$win_path"
  else
    if has_active_claude "$session"; then
      local win_name win_path skip_reason
      win_name="window-$(date +%s | tail -c 5)"
      if skip_reason=$(worktree_isolation_capable "$session"); then
        # Never fall back to main — a second session landing next to a live
        # Claude is the exact collision worktrees exist to prevent.
        win_path=$(create_worktree "$session" "$win_name") \
          || die "Couldn't create a worktree for '${win_name}' — refusing to run work on the main checkout. Try: mux worktree ls"
      else
        # Isolation impossible by design — fall through loudly, never silently.
        echo "⚠ No worktree isolation for this window (${skip_reason}) — opening on the main checkout." >&2
        win_path="$path"
      fi
      tmux new-window -n "$win_name" -c "$win_path"
      if [[ "$win_path" == "$MUX_WORKTREES_DIR/"* ]]; then
        tmux set-window-option -t "=${session}:${win_name}" @mux_worktree 1 2>/dev/null || true
        tmux set-window-option -t "=${session}:${win_name}" @wt_healthy 1 2>/dev/null || true
      fi
    else
      tmux new-window -c "$path"
    fi
  fi
}

cmd_ls() {
  require_tmux

  if ! tmux has-session 2>/dev/null; then
    echo "No desks open."
    return 0
  fi

  narrate "ls" "This shows all your open desks. Each project is a desk with its own windows."

  local now
  now=$(date +%s)

  while IFS= read -r session; do
    local sess_name
    sess_name=$(echo "$session" | cut -d'|' -f1)
    local sess_activity
    sess_activity=$(echo "$session" | cut -d'|' -f2)

    local nc
    nc=$(notes_count "$sess_name")
    if [[ "$nc" -gt 0 ]]; then
      printf '\033[1m%s\033[0m \033[33m(%s note%s)\033[0m\n' "$sess_name" "$nc" "$( (( nc != 1 )) && echo 's')"
    else
      printf '\033[1m%s\033[0m\n' "$sess_name"
    fi

    while IFS= read -r window; do
      local win_name win_activity win_active
      win_name=$(echo "$window" | cut -d'|' -f1)
      win_activity=$(echo "$window" | cut -d'|' -f2)
      win_active=$(echo "$window" | cut -d'|' -f3)

      local age=$(( now - win_activity ))
      local status
      status=$(activity_status "$age")
      local age_str
      age_str=$(format_age "$age")

      local marker=""
      [[ "$win_active" == "1" ]] && marker=" \033[32m●\033[0m"

      local status_color=""
      case "$status" in
        active)   status_color="\033[32m" ;;
        idle)     status_color="\033[33m" ;;
        sleeping) status_color="\033[2m" ;;
      esac

      local wt_marker=""
      if [[ "$(worktree_is_active "$sess_name" "$win_name" 2>/dev/null)" == "yes" ]]; then
        wt_marker=" \033[36m(wt)\033[0m"
      fi

      printf "  ${marker} %s${wt_marker} (${status_color}%s\033[0m, %s)\n" "$win_name" "$status" "$age_str"
    done < <(tmux list-windows -t "$sess_name" -F '#{window_name}|#{window_activity}|#{window_active}' 2>/dev/null)
  done < <(tmux list-sessions -F '#{session_name}|#{session_activity}' 2>/dev/null)
}

# cmd_snapshot / cmd_restore — capture "everything currently open" and
# reopen it after a reboot (field report 2026-08-18, act:5c1629a0). `mux ls`
# was read-only and `mux resume <id>` brought back exactly one conversation
# by an id the operator had to already know; neither helped with 8-9
# concurrent sessions across several desks. This adds the missing round trip:
# `mux snapshot` walks every open desk/window, keeps only the ones running a
# LIVE Claude (verified the same way ensure_main_station verifies a main
# station — a dead shell or an unrelated program is not worth capturing), and
# resolves each one's Claude Code session id by reading it back out of the
# transcript Claude Code already writes (muxlib.py resolve_session_id) —
# mux never tracked session ids itself, so this is reconstruction, not a new
# thing to keep in sync. `mux restore` reads that file back and replays the
# same `cd && claude --resume <id>` sequence `mux resume` already uses.
cmd_snapshot() {
  require_tmux
  tmux has-session 2>/dev/null || { echo "No desks open — nothing to snapshot."; return 0; }

  local rows=""
  while IFS= read -r sess; do
    while IFS= read -r win; do
      local win_name="${win%%|*}"
      local win_idx="${win#*|}"
      if pane_is_live_claude "${sess}:${win_idx}"; then
        local cwd
        cwd=$(tmux display-message -t "=${sess}:${win_idx}" -p '#{pane_current_path}' 2>/dev/null)
        [[ -n "$cwd" ]] && rows="${rows}${sess}|${win_name}|${win_idx}|${cwd}"$'\n'
      fi
    done < <(tmux list-windows -t "$sess" -F '#{window_name}|#{window_index}' 2>/dev/null)
  done < <(tmux list-sessions -F '#{session_name}' 2>/dev/null)

  if [[ -z "$rows" ]]; then
    echo "No live Claude sessions found across any desk — nothing to snapshot."
    return 0
  fi

  local summary
  summary=$(printf '%s' "$rows" | python3 "$MUX_LIB" snapshot-build) \
    || die "Failed to write the snapshot."

  local win_count desk_count wt_count resumable_count
  win_count=$(printf '%s\n' "$summary" | grep -c . || true)
  desk_count=$(printf '%s\n' "$summary" | cut -d'|' -f1 | sort -u | grep -c . || true)
  wt_count=$(printf '%s\n' "$summary" | awk -F'|' '$3=="1"' | grep -c . || true)
  resumable_count=$(printf '%s\n' "$summary" | awk -F'|' '$4=="1"' | grep -c . || true)

  echo "Snapshot saved: ${win_count} window(s) across ${desk_count} desk(s) (${wt_count} worktree) → ${MUX_DATA_DIR}/snapshots/latest.json"
  echo ""
  local line desk win_name is_wt has_sid mark
  while IFS='|' read -r desk win_name is_wt has_sid; do
    [[ -n "$desk" ]] || continue
    mark="✓"; [[ "$has_sid" == "0" ]] && mark="⚠ no session id"
    if [[ "$is_wt" == "1" ]]; then
      echo "  ${desk}/${win_name} (worktree) — ${mark}"
    else
      echo "  ${desk}/${win_name} — ${mark}"
    fi
  done <<< "$summary"
  if [[ "$resumable_count" != "$win_count" ]]; then
    echo ""
    echo "⚠ $(( win_count - resumable_count )) window(s) above had no discoverable Claude session id — restore will reopen the desk/window but not the conversation."
  fi
}

cmd_restore() {
  require_tmux
  local snap_file="${MUX_DATA_DIR}/snapshots/latest.json"
  [[ -f "$snap_file" ]] || die "No snapshot found at ${snap_file}. Run 'mux snapshot' first."

  local dry_run=0
  [[ "${1:-}" == "--dry-run" ]] && dry_run=1

  local meta
  meta=$(python3 "$MUX_LIB" snapshot-meta 2>/dev/null)
  local saved_at="${meta%%|*}"
  echo "Restoring from snapshot saved ${saved_at:-at an unknown time}."
  (( dry_run )) && echo "(dry run — no windows will be opened)"

  # Docker Desktop is a full GUI app that takes real time to come up after a
  # reboot (often 30s-2min) — restore itself doesn't touch Docker, but a
  # session resumed right after a restart has no way to know it just
  # restarted: its transcript predates the reboot entirely, so a
  # Docker-dependent call failing reads as a mystery, not "still starting
  # up". Checked ONCE (not per-window — restore shouldn't block on it, and
  # `docker info` is not instant), and only if `docker` is even installed —
  # an operator who doesn't use it shouldn't see a note about it.
  local docker_note=""
  if command -v docker >/dev/null 2>&1 && ! docker info >/dev/null 2>&1; then
    docker_note="Heads up: this session was just resumed by 'mux restore' after the operator restarted their computer. Docker was not responding at restore time — it's a GUI app that takes a little while to start after a reboot (often 30s-2min), not necessarily broken. If a Docker-dependent command fails right now, that's the likely reason: check 'docker info' and retry in a bit rather than assuming something is wrong."
    echo "⚠ Docker isn't responding yet — resumed sessions will be told, in case they hit it."
  fi

  local opened=0 skipped=0
  while IFS= read -r row; do
    [[ -n "$row" ]] || continue
    local desk win_name win_idx cwd is_wt task_slug branch session_id
    IFS='|' read -r desk win_name win_idx cwd is_wt task_slug branch session_id <<< "$row"

    if [[ ! -d "$cwd" ]]; then
      echo "  skip: ${desk}/${win_name} — ${cwd} no longer exists"
      (( skipped++ ))
      continue
    fi
    if [[ "$session_id" == "None" || -z "$session_id" ]]; then
      echo "  skip: ${desk}/${win_name} — no Claude session id was captured for this window"
      (( skipped++ ))
      continue
    fi
    # Same id-shape guard cmd_resume applies before its own send-keys
    # interpolation (act:4d8d87b5 flags that sink as still unquoted) — the
    # snapshot's session_id is machine-read from a transcript filename, not
    # operator input, but validating it here costs nothing and this loop
    # must not be a second, laxer path into the same sink.
    if [[ ! "$session_id" =~ ^[0-9a-fA-F-]{8,}$ ]]; then
      echo "  skip: ${desk}/${win_name} — '${session_id}' doesn't look like a Claude session id"
      (( skipped++ ))
      continue
    fi

    if (( dry_run )); then
      echo "  would restore: ${desk}/${win_name} (${cwd}) → resume ${session_id}"
      (( opened++ ))
      continue
    fi

    if ! tmux has-session -t "=$desk" 2>/dev/null; then
      tmux new-session -d -s "$desk" -c "$cwd"
      setup_session_hooks "$desk" "$cwd" 2>/dev/null || true
      tmux rename-window -t "=${desk}:1" "$win_name" 2>/dev/null || true
    elif tmux list-windows -t "=$desk" -F '#{window_name}' 2>/dev/null | grep -qxF "$win_name"; then
      echo "  skip: ${desk}/${win_name} — a window with this name is already open"
      (( skipped++ ))
      continue
    else
      tmux new-window -t "=$desk" -n "$win_name" -c "$cwd"
    fi

    if [[ "$is_wt" == "1" ]]; then
      tmux set-window-option -t "=${desk}:${win_name}" @mux_worktree 1 2>/dev/null || true
      tmux set-window-option -t "=${desk}:${win_name}" @wt_healthy 1 2>/dev/null || true
    fi
    queue_claude_resume "=${desk}:${win_name}" "$session_id" "$cwd" "$docker_note"
    echo "  restored: ${desk}/${win_name} → resumed ${session_id:0:8}…"
    (( opened++ ))
  done < <(python3 "$MUX_LIB" snapshot-read)

  echo "Done: ${opened} restored, ${skipped} skipped."
}

cmd_rename() {
  local name="${*:-}"
  require_tmux
  in_tmux || die "You need to be in a desk to rename a window."
  [[ -n "$name" ]] || die "Usage: mux rename <name>"

  local new_name
  new_name=$(slugify "$name")
  tmux rename-window "$new_name"
  echo "Renamed to: ${new_name}"
}

_do_close() {
  local project="${1:-}" prefix="$2" suffix="$3"
  require_tmux

  if [[ -n "$project" ]]; then
    tmux has-session -t "=$project" 2>/dev/null || die "No desk called '${project}' is open."
    tmux detach-client -s "=$project" 2>/dev/null || true
    echo "${prefix} ${project}. ${suffix} \`mux ${project}\` to return."
  else
    in_tmux || die "Not in a desk. Specify which to close: mux close <project>"
    local session
    session=$(tmux display-message -p '#{session_name}')
    tmux detach-client
    echo "${prefix} ${session}. ${suffix} \`mux ${session}\` to return."
  fi
}

cmd_close() {
  local project="${1:-}"
  _do_close "$project" "Parked" "Still running in the background."
}

cmd_done() {
  local project="${1:-}"
  _do_close "$project" "Done with" "Everything is saved. See you next time."
}

cmd_kill() {
  local target="${1:-}"
  require_tmux
  [[ -n "$target" ]] || die "Usage: mux kill <project> or mux kill <project>:<window>"

  if [[ "$target" == *:* ]]; then
    local sess="${target%%:*}" win="${target#*:}"
    tmux has-session -t "=$sess" 2>/dev/null || die "No desk called '${sess}' is open."
    # Resolve to window index to avoid name collisions
    local win_idx
    win_idx=$(tmux list-windows -t "=$sess" -F '#{window_index} #{window_name}' 2>/dev/null \
      | awk -v name="$win" '$2 == name { print $1; exit }')
    [[ -n "$win_idx" ]] || die "Window '${win}' not found in desk '${sess}'."
    printf 'This will close the "%s" window in %s. Are you sure? (y/N) ' "$win" "$sess"
    local confirm
    read -r confirm
    case "$confirm" in
      y|Y)
        tmux kill-window -t "=${sess}:${win_idx}" || die "Failed to close window '${win}'."
        echo "Closed window ${win}."
        ;;
      *)
        echo "Cancelled. Nothing was closed."
        ;;
    esac
  else
    tmux has-session -t "=$target" 2>/dev/null || die "No desk called '${target}' is open."
    local win_count
    win_count=$(tmux list-windows -t "=$target" 2>/dev/null | wc -l | tr -d ' ')
    printf 'This will close %s and stop any running Claude sessions (%s window%s). Are you sure? (y/N) ' \
      "$target" "$win_count" "$( (( win_count != 1 )) && echo 's')"
    local confirm
    read -r confirm
    case "$confirm" in
      y|Y)
        tmux kill-session -t "=$target"
        echo "Closed ${target}."
        ;;
      *)
        echo "Cancelled. Nothing was closed."
        ;;
    esac
  fi
}

cmd_status() {
  local project="${1:-}"
  require_tmux

  if ! tmux has-session 2>/dev/null; then
    echo "No desks open."
    return 0
  fi

  local now
  now=$(date +%s)

  if [[ -n "$project" ]]; then
    tmux has-session -t "=$project" 2>/dev/null || die "No desk called '${project}' is open."

    printf '\033[1m%s\033[0m\n' "$project"
    while IFS= read -r window; do
      local win_name win_activity pane_cmd
      win_name=$(echo "$window" | cut -d'|' -f1)
      win_activity=$(echo "$window" | cut -d'|' -f2)
      pane_cmd=$(echo "$window" | cut -d'|' -f3)

      local age=$(( now - win_activity ))
      local status
      status=$(activity_status "$age")
      local age_str
      age_str=$(format_age "$age")

      local claude_marker=""
      local win_idx
      win_idx=$(echo "$window" | cut -d'|' -f4)
      local is_claude_win
      is_claude_win=$(tmux show-window-option -t "=${project}:${win_idx}" -v @mux_claude 2>/dev/null)
      [[ "$is_claude_win" == "1" ]] && claude_marker=" [Claude running]"

      printf '  %s (%s, %s)%s\n' "$win_name" "$status" "$age_str" "$claude_marker"
    done < <(tmux list-windows -t "=$project" -F '#{window_name}|#{window_activity}|#{pane_current_command}|#{window_index}' 2>/dev/null)
  else
    local desk_count
    desk_count=$(tmux list-sessions 2>/dev/null | wc -l | tr -d ' ')
    local window_count
    window_count=$(tmux list-windows -a 2>/dev/null | wc -l | tr -d ' ')

    printf '%s desk%s open, %s window%s total\n' \
      "$desk_count" "$( (( desk_count != 1 )) && echo 's')" \
      "$window_count" "$( (( window_count != 1 )) && echo 's')"
    echo ""

    while IFS= read -r session; do
      local sess_name sess_activity
      sess_name=$(echo "$session" | cut -d'|' -f1)
      sess_activity=$(echo "$session" | cut -d'|' -f2)

      local age=$(( now - sess_activity ))
      local status
      status=$(activity_status "$age")

      local win_ct
      win_ct=$(tmux list-windows -t "$sess_name" 2>/dev/null | wc -l | tr -d ' ')

      printf '  \033[1m%s\033[0m — %s window%s, %s\n' \
        "$sess_name" "$win_ct" "$( (( win_ct != 1 )) && echo 's')" "$status"
    done < <(tmux list-sessions -F '#{session_name}|#{session_activity}' 2>/dev/null)
  fi
}

cmd_where() {
  require_tmux
  in_tmux || die "You're not in a desk."
  local session win_name win_idx win_count pane_cmd
  session=$(tmux display-message -p '#{session_name}')
  win_name=$(tmux display-message -p '#{window_name}')
  win_idx=$(tmux display-message -p '#{window_index}')
  win_count=$(tmux list-windows -t "=$session" 2>/dev/null | wc -l | tr -d ' ')
  pane_cmd=$(tmux display-message -p '#{pane_current_command}')

  local is_shell=true
  echo "$pane_cmd" | grep -qE '^(zsh|bash|sh|fish|login)$' || is_shell=false

  local claude_str=""
  [[ "$is_shell" == "false" ]] && claude_str=" | Claude running"

  printf 'Desk: \033[1m%s\033[0m | Window %s of %s: %s%s\n' \
    "$session" "$win_idx" "$win_count" "$win_name" "$claude_str"
}

cmd_split() {
  require_tmux
  in_tmux || die "You need to be in a desk to split."

  local direction="-h"
  [[ "${1:-}" == "--vertical" || "${1:-}" == "-v" ]] && direction="-v"

  tmux split-window "$direction"
  narrate "split" "Split created. Type \`exit\` in the new pane to close it."
}

cmd_note() {
  local first_arg="${1:-}"
  local helper="${HOME}/.config/mux/manage-notes.py"

  # mux note rm <number>
  if [[ "$first_arg" == "rm" ]]; then
    require_tmux
    in_tmux || die "You need to be in a desk to remove a note."
    local session
    session=$(tmux display-message -p '#{session_name}')
    local num="${2:-}"
    [[ -n "$num" && "$num" =~ ^[0-9]+$ ]] || die "Usage: mux note rm <number>"
    python3 "$helper" rm "$session" "$num"
    return 0
  fi

  # mux note <project> "text" — add note to specific project
  if project_path "$first_arg" >/dev/null 2>&1 && [[ -n "${2:-}" ]]; then
    local project="$first_arg"
    shift
    python3 "$helper" add "$project" "$@"
    return 0
  fi

  # mux note "text" — add note to current project
  if [[ -n "$first_arg" ]]; then
    require_tmux
    in_tmux || die "You need to be in a desk, or specify a project: mux note <project> \"text\""
    local session
    session=$(tmux display-message -p '#{session_name}')
    python3 "$helper" add "$session" "$@"
    return 0
  fi

  # mux note (no args) — show notes for current project
  require_tmux
  in_tmux || die "You need to be in a desk to see notes."
  local session
  session=$(tmux display-message -p '#{session_name}')
  python3 "$helper" list "$session"
}

dx_origin() {
  if in_tmux; then
    tmux display-message -p '#{session_name}' 2>/dev/null && return
  fi
  basename "$(pwd)"
}

cmd_dx() {
  local first_arg="${1:-}"
  local helper="${HOME}/.config/mux/manage-dx.py"
  local origin
  origin=$(dx_origin)

  # mux dx done <number>
  if [[ "$first_arg" == "done" ]]; then
    local num="${2:-}"
    [[ -n "$num" && "$num" =~ ^[0-9]+$ ]] || die "Usage: mux dx done <number>"
    python3 "$helper" done "$origin" "$num"
    return 0
  fi

  # mux dx list — print list to stdout (works everywhere)
  if [[ "$first_arg" == "list" ]]; then
    python3 "$helper" list "$origin"
    return 0
  fi

  # mux dx (no args) — popup if in tmux, print list otherwise
  if [[ -z "$first_arg" ]]; then
    if in_tmux; then
      tmux display-popup -w 60 -h 20 -E "python3 ${HOME}/.config/mux/show-dx.py $origin"
    else
      python3 "$helper" list "$origin"
    fi
    return 0
  fi

  # mux dx "text" — quick add (works everywhere)
  python3 "$helper" add "$origin" "$@"
}

cmd_worktree() {
  local action="${1:-ls}"
  case "$action" in
    ls)
      worktree_registry_init
      local count
      count=$(python3 "$MUX_LIB" worktree-count) || count=0

      if [[ "$count" -eq 0 ]]; then
        echo "No active worktrees."
        return 0
      fi

      python3 "$MUX_LIB" worktree-list
      ;;
    cleanup)
      local project="${2:-}" task="${3:-}"
      [[ -n "$project" && -n "$task" ]] || die "Usage: mux worktree cleanup <project> <task>"
      worktree_cleanup "$project" "$task"
      ;;
    remove)
      local project="${2:-}" task="${3:-}"
      [[ -n "$project" && -n "$task" ]] || die "Usage: mux worktree remove <project> <task>"
      local wt_path="${MUX_WORKTREES_DIR}/${project}-${task}"
      local branch_name="mux/${task}"
      local proj_path
      proj_path=$(project_path "$project") || die "Unknown project: $project"

      printf 'Force-remove worktree %s/%s? (y/N) ' "$project" "$task"
      local confirm
      read -r confirm
      case "$confirm" in
        y|Y)
          worktree_reap_containers "$proj_path" "$wt_path"
          git -C "$proj_path" worktree remove --force "$wt_path" 2>/dev/null || rm -rf "$wt_path"
          git -C "$proj_path" branch -D "$branch_name" 2>/dev/null || true
          worktree_registry_remove "$project" "$task"
          echo "Removed."
          ;;
        *) echo "Cancelled." ;;
      esac
      ;;
    preflight)
      # Verify main's environment BEFORE parallel lane spawns: every declared
      # provision file exists in the main checkout and every declared
      # `check:` command passes (.mux-worktree-provision at the project root).
      # Exit code gates the spawn: 0 = sound, 1 = do not spawn lanes yet.
      local project="${2:-}"
      local proj_path
      if [[ -n "$project" ]]; then
        proj_path=$(project_path "$project") || die "Unknown project: $project"
      else
        proj_path=$(git rev-parse --show-toplevel 2>/dev/null) || die "Usage: mux worktree preflight <project> (or run inside a project)"
      fi
      "${HOME}/.config/mux/worktree-session-health.sh" "$proj_path" --preflight
      ;;
    health)
      local project="${2:-}" task="${3:-}"
      if [[ -n "$project" && -n "$task" ]]; then
        # Check a specific worktree
        local proj_path wt_path
        proj_path=$(project_path "$project") || die "Unknown project: $project"
        wt_path="${MUX_WORKTREES_DIR}/${project}-${task}"
        [[ -d "$wt_path" ]] || die "Worktree not found: $wt_path"
        echo "Checking ${project}/${task}..."
        if worktree_health_check "$proj_path" "$wt_path"; then
          echo "  ✓ All checks passed"
        fi
      else
        # Check all active worktrees
        local any_issues=false
        for wt_dir in "$MUX_WORKTREES_DIR"/*/; do
          [[ -d "$wt_dir" ]] || continue
          local wt_name
          wt_name=$(basename "$wt_dir")
          local wt_proj=""
          for p in $(python3 "$MUX_LIB" project-names 2>/dev/null); do
            if [[ "$wt_name" == "$p"-* ]]; then
              wt_proj="$p"
              break
            fi
          done
          [[ -n "$wt_proj" ]] || continue
          local pp
          pp=$(project_path "$wt_proj" 2>/dev/null) || continue
          echo "Checking ${wt_name}..."
          if ! worktree_health_check "$pp" "${wt_dir%/}"; then
            any_issues=true
          else
            echo "  ✓ All checks passed"
          fi
        done
        $any_issues || echo "All worktrees healthy."
      fi
      ;;
    refresh)
      local project="${2:-}" task="${3:-}"
      [[ -n "$project" && -n "$task" ]] || die "Usage: mux worktree refresh <project> <task>"
      local proj_path wt_path
      proj_path=$(project_path "$project") || die "Unknown project: $project"
      wt_path="${MUX_WORKTREES_DIR}/${project}-${task}"
      [[ -d "$wt_path" ]] || die "Worktree not found: $wt_path"
      [[ -d "$proj_path/.claude" ]] || die "Main repo has no .claude/"

      # Delegate to the shared health script's carve-out-aware sync (the
      # worktree_health_check → worktree-session-health.sh consolidation).
      # Never wholesale `rm -rf .claude/` here — that destroys uncommitted
      # edits to the authored project record (any tracked .claude/ file).
      # See .claude/rules/artifacts-of-thought.md.
      if worktree_health_check "$proj_path" "$wt_path" --refresh; then
        echo "Refreshed .claude/ infra from main repo (authored records preserved)."
      else
        echo "Refreshed .claude/ infra, but health issues remain — see above." >&2
      fi
      ;;
    *)
      echo "Usage: mux worktree [ls|cleanup|remove|health|refresh|preflight] [project] [task]"
      ;;
  esac
}

# EVERY verb here must leave the ·N badge equal to the queue dir when it
# returns — mutating verbs because they changed it, reading verbs because
# something outside mux may have. A new verb that skips the reconcile is a
# new stale-badge bug (act:ca6a19a0), and the qa-badge-reconcile fixture
# fails on any verb in this case list it hasn't been taught to check.
cmd_qa() {
  local action="${1:-list}"
  shift || true
  case "$action" in
    dispatch) qa_cmd_dispatch "$@" ;;
    list)     qa_cmd_list "$@" ;;
    drain)    qa_cmd_drain "$@" ;;
    status)   qa_cmd_status "$@" ;;
    refresh)  qa_cmd_refresh "$@" ;;
    clear)    qa_cmd_clear "$@" ;;
    *) die "Usage: mux qa {dispatch <file>|list|drain|status|refresh|clear} [project]" ;;
  esac
}

# Route one packaged handoff to window 1. Called by /qa-handoff after it
# files the inbox item. Never dies on a closed/absent desk — it queues, so
# the handoff is never lost (it also lives in the inbox regardless).
qa_cmd_dispatch() {
  require_python
  local descriptor="${1:-}"
  [[ -f "$descriptor" ]] || die "mux qa dispatch: descriptor file not found: ${descriptor}"

  local project prompt
  project=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("project",""))' "$descriptor" 2>/dev/null)
  prompt=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("pickup_prompt",""))' "$descriptor" 2>/dev/null)
  [[ -n "$project" ]] || die "mux qa dispatch: descriptor missing 'project'"
  [[ -n "$prompt" ]]  || die "mux qa dispatch: descriptor missing 'pickup_prompt'"

  # `project` should be the mux DESK (tmux session). Desk short-names often
  # differ from the repo dir name (desk `cabinet`, repo `claude-cabinet`), so
  # if it isn't a live session but the descriptor's project_path matches a
  # desk's MUX_PROJECT_PATH, remap to that desk — otherwise a repo-name
  # descriptor would queue to a dead desk and never light the badge.
  if ! tmux has-session -t "=$project" 2>/dev/null; then
    local ppath desk mp
    ppath=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("project_path",""))' "$descriptor" 2>/dev/null)
    if [[ -n "$ppath" ]]; then
      while IFS= read -r desk; do
        mp=$(tmux show-environment -t "$desk" MUX_PROJECT_PATH 2>/dev/null | sed 's/^MUX_PROJECT_PATH=//')
        if [[ "$mp" == "$ppath" ]]; then project="$desk"; break; fi
      done < <(tmux list-sessions -F '#{session_name}' 2>/dev/null)
    fi
  fi

  # Reconcile before branching. Only the queue branch below mutates the queue
  # dir, but the inject / launch / self-dispatch branches all return without
  # touching it — and any of them may be running against a badge the
  # watchtower invalidated. Doing it here makes "every qa verb leaves the
  # badge equal to the queue" true per VERB, not per branch (act:ca6a19a0).
  qa_refresh_indicator "$project"

  local state win
  state=$(qa_pane_state "$project")
  win=$(qa_main_window "$project" 2>/dev/null || true)

  # Self-dispatch guard: if dispatch is invoked FROM the main-station window
  # itself, the work was done on main (no worktree to hand off from) and there
  # is no second window to route to. No-op cleanly instead of queuing a handoff
  # to the very window that wrote it. This is the deliberate work-on-main
  # escape hatch; the standing-station default makes it rare.
  if [[ -n "${TMUX_PANE:-}" && -n "$win" ]]; then
    local here
    here=$(tmux display-message -t "$TMUX_PANE" -p '#{session_name}:#{window_index}' 2>/dev/null)
    if [[ -n "$here" && "$here" == "$win" ]]; then
      echo "• Already on the main station (work was on main, not a worktree) — the handoff is filed in the inbox; act on it here or it ages. No dispatch needed."
      return 0
    fi
  fi

  case "$state" in
    claude-idle)
      qa_inject "$win" "$prompt"
      echo "✓ Handoff pushed to window 1 (injected into the idle main session)."
      ;;
    shell)
      qa_launch_fresh "$win" "$prompt" "$project"
      echo "✓ Handoff pushed to window 1 (launched a fresh main session with it)."
      ;;
    claude-busy|other|no-window)
      qa_enqueue "$project" "$descriptor"
      local n; n=$(qa_count "$project")
      local why; case "$state" in
        claude-busy) why="window 1 is busy" ;;
        other)       why="window 1 is running another program" ;;
        no-window)   why="the ${project} desk isn't open" ;;
      esac
      echo "• Queued — ${why}. ${n} handoff(s) pending; the ·${n} badge is lit. Window 1 drains with: mux qa drain"
      ;;
  esac
}

qa_cmd_list() {
  local project="${1:-$(tmux display-message -p '#{session_name}' 2>/dev/null)}"
  [[ -n "$project" ]] || die "mux qa list: no project (run inside a desk or pass a name)"
  qa_refresh_indicator "$project"
  local n; n=$(qa_count "$project")
  if [[ "$n" -eq 0 ]]; then
    echo "No QA handoffs queued for ${project}."
    return 0
  fi
  echo "${n} QA handoff(s) queued for ${project} (oldest first):"
  local f
  while IFS= read -r f; do
    [[ -n "$f" ]] || continue
    python3 -c 'import json,sys
d=json.load(open(sys.argv[1]))
what=d.get("what","(no summary)")
sha=d.get("merged_commit")
iid=d.get("item_id","?")
tag=f"  [merged {str(sha)[:8]}]" if sha else ""
print(f"  - {what}{tag}  {iid}")' "$f" 2>/dev/null
  done < <(ls -1tr "${MUX_QA_DIR}/${project}"/*.json 2>/dev/null)
  local inflight_n
  inflight_n=$(qa_inflight_count "$project")
  [[ "$inflight_n" -gt 0 ]] && echo "${inflight_n} in flight (drained, awaiting verdict — restored on next drain if no verdict lands)"
  echo ""
  echo "Drain the oldest into this session: mux qa drain"
}

# Cross-check a dispatch descriptor's inbox item against the watchtower
# queue (act:796fe6dc — the dispatch queue and the inbox drift BOTH ways).
# Echoes: pending | not-pending | missing | unknown | no-watchtower.
# "unknown" (unparseable item) errs toward offering the handoff — the
# recipient gate sorts it out; only a positively non-pending item is a ghost.
qa_item_status() {
  local item_id="$1"
  local wt_items="${WATCHTOWER_DIR:-$HOME/.claude-cabinet/watchtower}/queue/items"
  [[ -d "$wt_items" ]] || { echo "no-watchtower"; return; }
  local f="${wt_items}/${item_id}.json"
  [[ -f "$f" ]] || { echo "missing"; return; }
  local st
  st=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("status",""))' "$f" 2>/dev/null || true)
  case "$st" in
    pending) echo "pending" ;;
    "")      echo "unknown" ;;
    *)       echo "not-pending" ;;
  esac
}

qa_descriptor_item_id() {
  python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("item_id") or "")' "$1" 2>/dev/null || true
}

# Offer the oldest queued handoff and print its pickup prompt to stdout. The
# window-1 Claude session runs this (via the Bash tool) and acts on the
# prompt it gets back — the prompt IS the instruction.
#
# Hardened against two-way dispatch/inbox drift (act:796fe6dc):
#   - Descriptors whose inbox item is no longer pending are GHOSTS (the
#     verdict was filed out-of-band) — skipped and removed, never offered.
#   - The offered descriptor moves to in-flight/ instead of rm -f, so a
#     session that dies without filing a verdict doesn't eat the handoff:
#     the next drain restores still-pending in-flight entries to the queue.
#     The gate exit (resolveItem/dismiss/supersede in watchtower-queue.mjs)
#     clears the in-flight entry when the verdict lands.
#   - When the dispatch queue is empty, falls back to walking the inbox for
#     pending dispatched-category items (qa-handoff, routine), so the pull
#     path works even when the push path was never used (or its descriptor
#     was lost).
qa_cmd_drain() {
  local project="${1:-$(tmux display-message -p '#{session_name}' 2>/dev/null)}"
  [[ -n "$project" ]] || die "mux qa drain: no project (run inside a desk or pass a name)"
  local qdir="${MUX_QA_DIR}/${project}" inflight="${MUX_QA_DIR}/${project}/in-flight"
  local f iid st

  # 1. Sweep in-flight: a prior drain parked these. Verdict filed → done,
  #    drop. Still pending (or un-checkable) → restore to the queue so this
  #    drain can re-offer it — drained work can't silently vanish.
  if [[ -d "$inflight" ]]; then
    for f in "$inflight"/*.json; do
      [[ -f "$f" ]] || continue
      iid=$(qa_descriptor_item_id "$f")
      if [[ -z "$iid" ]]; then mv -f "$f" "${qdir}/"; continue; fi
      st=$(qa_item_status "$iid")
      case "$st" in
        not-pending|missing) rm -f "$f" ;;
        *) mv -f "$f" "${qdir}/" ;;
      esac
    done
    # The sweep both restores INTO and deletes FROM the counted layer. Every
    # exit below refreshes too, but reconciling here keeps the invariant
    # local to the mutation instead of depending on what follows it.
    qa_refresh_indicator "$project"
  fi

  # 2. Walk the queue oldest-first, skipping resolved ghosts.
  local oldest
  while :; do
    # `|| true`: under pipefail an empty glob makes ls fail and would
    # silently kill the whole script mid-drain.
    oldest=$(ls -1tr "${qdir}"/*.json 2>/dev/null | head -1 || true)
    [[ -z "$oldest" ]] && break
    iid=$(qa_descriptor_item_id "$oldest")
    if [[ -n "$iid" ]]; then
      st=$(qa_item_status "$iid")
      case "$st" in
        not-pending)
          echo "… skipped ${iid}: already resolved in the inbox (ghost dispatch removed)" >&2
          rm -f "$oldest"; continue ;;
        missing)
          echo "… skipped ${iid}: no matching inbox item (stale dispatch removed)" >&2
          rm -f "$oldest"; continue ;;
      esac
    fi
    python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("pickup_prompt",""))' "$oldest" 2>/dev/null || true
    mkdir -p "$inflight"
    mv -f "$oldest" "${inflight}/"
    qa_refresh_indicator "$project"
    return 0
  done
  qa_refresh_indicator "$project"

  # 3. Dispatch queue is dry — fall back to the inbox itself: any pending
  #    dispatched-category item (qa-handoff or routine) for this desk is real
  #    debt even if no descriptor was ever dispatched (or it was lost). Match
  #    by desk name, project name, or the desk's MUX_PROJECT_PATH. Oldest
  #    first; surface only. qa-handoffs before routines: QA debt outranks a
  #    missed routine.
  local wt_items="${WATCHTOWER_DIR:-$HOME/.claude-cabinet/watchtower}/queue/items"
  if [[ -d "$wt_items" ]]; then
    local ppath
    ppath=$(tmux show-environment -t "=$project" MUX_PROJECT_PATH 2>/dev/null | sed 's/^MUX_PROJECT_PATH=//' || true)
    if python3 -c '
import json, sys, os, glob
items_dir, desk, ppath = sys.argv[1], sys.argv[2], sys.argv[3]
cands = []
for f in glob.glob(os.path.join(items_dir, "*.json")):
    try:
        d = json.load(open(f))
    except Exception:
        continue
    if d.get("category") not in ("qa-handoff", "routine") or d.get("status") != "pending":
        continue
    if not (d.get("desk") == desk or d.get("project") == desk
            or (ppath and d.get("project_path") == ppath)):
        continue
    cands.append(d)
if not cands:
    sys.exit(3)
cands.sort(key=lambda d: (0 if d.get("category") == "qa-handoff" else 1,
                          d.get("filed_at", "")))
d = cands[0]
iid = str(d.get("id", ""))
title = str(d.get("title", ""))
path = os.path.join(items_dir, iid + ".json")
more = ""
if len(cands) > 1:
    more = " (%d more pending in the inbox)" % (len(cands) - 1)
if d.get("category") == "qa-handoff":
    print("Pending QA handoff found in the watchtower inbox with no dispatch entry"
          " (the push path was missed or its descriptor was lost): %s -- %s."
          " Read the full item at %s and run the /qa-drain recipient-gate pickup on it:"
          " verify what the worktree could not, then exit through the gate"
          " (resolveItem with a structured qa_verdict; dismiss/supersede require typed"
          " reasons). It cannot leave the inbox silently.%s" % (iid, title, path, more))
else:
    print("Pending routine found in the watchtower inbox with no dispatch entry"
          " (the push path was missed or its descriptor was lost): %s -- %s."
          " Read the full item at %s, then read the routine script it names"
          " (evidence.script, relative to project_path) and run it as the"
          " conversation script. When done, resolve the item via watchtower-queue"
          " resolveItem (resolution_type acted-on).%s" % (iid, title, path, more))
' "$wt_items" "$project" "$ppath" 2>/dev/null; then
      return 0
    fi
  fi

  echo "No QA handoffs queued for ${project}."
}

qa_cmd_status() {
  local project="${1:-$(tmux display-message -p '#{session_name}' 2>/dev/null)}"
  [[ -n "$project" ]] || die "mux qa status: no project (run inside a desk or pass a name)"
  # Reconcile FIRST: status is what the operator runs when they doubt the
  # badge, so it must never report a true count beside a lying badge.
  qa_refresh_indicator "$project"
  printf 'QA handoff — %s\n' "$project"
  printf '  window 1 pane: %s\n' "$(qa_pane_state "$project")"
  printf '  queued:        %s\n' "$(qa_count "$project")"
  printf '  in-flight:     %s\n' "$(qa_inflight_count "$project")"
}

# Reconcile the badge against the queue dir and say nothing. The named route
# for anything outside mux that just mutated the queue — and what the
# after-select-window hook calls. Silent by design: it runs on every window
# switch, so any output would be tmux message spam.
qa_cmd_refresh() {
  local project="${1:-$(tmux display-message -p '#{session_name}' 2>/dev/null)}"
  [[ -n "$project" ]] || die "mux qa refresh: no project (run inside a desk or pass a name)"
  qa_refresh_indicator "$project"
}

qa_cmd_clear() {
  local project="${1:-$(tmux display-message -p '#{session_name}' 2>/dev/null)}"
  [[ -n "$project" ]] || die "mux qa clear: no project (run inside a desk or pass a name)"
  rm -f "${MUX_QA_DIR}/${project}"/*.json "${MUX_QA_DIR}/${project}/in-flight"/*.json 2>/dev/null || true
  qa_refresh_indicator "$project"
  echo "Cleared queued QA handoffs for ${project} (including in-flight)."
}

# --- Session handoff (session-handoff skill, phase 2) ---
#
# Open the NEXT session in a NEW window on the MAIN checkout, seeded with an
# operator-approved prompt. This is the FORWARD handoff (close one session →
# start the next), distinct from `mux qa dispatch`'s BACKWARD handoff (route a
# merge to the live QA station). /session-handoff persists the seed to disk and
# only calls this on approval, so a launch failure can't lose the seed.
#
# Why a new window on MAIN (not a worktree): acting on drained QA (e2e /
# publish / deploy) needs the main checkout. The window is named by work-context, NEVER
# by the prompt (cmd_new's prompt path slugifies prompt→name + forces a
# worktree — the wrong path; we deliberately don't use it).
#
# Descriptor JSON: { project (mux desk), project_path (repo root),
#                    name (window name), seed_prompt }
cmd_handoff() {
  require_python
  require_tmux
  local descriptor="${1:-}"
  [[ -f "$descriptor" ]] || die "mux handoff: descriptor file not found: ${descriptor}"

  local project ppath name prompt
  project=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("project",""))' "$descriptor" 2>/dev/null)
  ppath=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("project_path",""))' "$descriptor" 2>/dev/null)
  name=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("name",""))' "$descriptor" 2>/dev/null)
  prompt=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("seed_prompt",""))' "$descriptor" 2>/dev/null)
  [[ -n "$prompt" ]] || die "mux handoff: descriptor missing 'seed_prompt'"
  [[ -n "$name" ]]   || name="next-session"

  # The worktree path and git branch are built from this name; spaces or
  # other non-slug chars in the descriptor's 'name' break `git branch` /
  # `git worktree add` and abort the handoff. Slugify for branch/path use;
  # keep $name as the human-readable window label. (act:573f38e1)
  local name_slug; name_slug=$(slugify "$name")
  [[ -n "$name_slug" ]] || name_slug="next-session"

  # `project` should be the mux DESK (tmux session). Desk short-names often
  # differ from the repo dir name, so if it isn't a live session but the
  # descriptor's project_path matches a desk's MUX_PROJECT_PATH, remap to that
  # desk (same fallback as qa dispatch).
  if [[ -z "$project" ]] || ! tmux has-session -t "=$project" 2>/dev/null; then
    if [[ -n "$ppath" ]]; then
      local desk mp
      while IFS= read -r desk; do
        mp=$(tmux show-environment -t "$desk" MUX_PROJECT_PATH 2>/dev/null | sed 's/^MUX_PROJECT_PATH=//')
        if [[ "$mp" == "$ppath" ]]; then project="$desk"; break; fi
      done < <(tmux list-sessions -F '#{session_name}' 2>/dev/null)
    fi
  fi
  [[ -n "$project" ]] && tmux has-session -t "=$project" 2>/dev/null \
    || die "mux handoff: desk '${project:-?}' isn't open — seed not launched (it's saved; open the desk and paste it)."

  # Main checkout path: prefer the descriptor's project_path, else the desk's
  # registered project dir, else the current dir.
  local main_path="$ppath"
  [[ -n "$main_path" ]] || main_path=$(project_path "$project" 2>/dev/null) || main_path="$PWD"

  # Worktree, not main: the seeded session is a SECOND session on this desk,
  # so mux's core isolation rule applies — main stays window 1's standing
  # station. Two sessions sharing the main checkout sweep each other's
  # in-progress edits into unrelated commits (the 1adc5ef incident). Tracked
  # .claude/ files commit normally from worktrees since the 40cc831
  # carve-out, so the authoring work a seed carries is safe there. The
  # seeded session merges to main when done; main-only tail work (dogfood
  # reinstall, propagation, publish) happens post-merge or is dispatched to
  # window 1, consistent with "QA drains belong to window 1".
  local win_path where skip_reason
  if skip_reason=$(worktree_isolation_capable "$project"); then
    win_path=$(create_worktree "$project" "$name_slug") \
      || die "mux handoff: couldn't create a worktree for '${name}' — refusing to seed a second session onto the main checkout. Seed is saved; try: mux worktree ls"
    where="a fresh worktree of ${project}"
  else
    # Isolation impossible by design (non-git / unregistered) — fall through
    # loudly, never silently.
    echo "⚠ No worktree isolation for this handoff (${skip_reason}) — opening on the main checkout." >&2
    win_path="$main_path"
    where="${project}'s main checkout (no isolation: ${skip_reason})"
  fi

  # -P -F gives an unambiguous session:index target (window names can
  # collide — option-setting by NAME hits the first match, act:c6f6bfd1).
  local target
  target=$(tmux new-window -t "=$project" -n "$name" -c "$win_path" -P -F '#{session_name}:#{window_index}')
  [[ -n "$target" ]] || die "mux handoff: couldn't open a window on ${project} — seed is saved; open the desk and paste it."
  if [[ "$win_path" == "$MUX_WORKTREES_DIR/"* ]]; then
    tmux set-window-option -t "$target" @mux_worktree 1 2>/dev/null || true
    tmux set-window-option -t "$target" @wt_healthy 1 2>/dev/null || true
  fi
  queue_claude_start "=$target" "$prompt" "$win_path"
  echo "✓ Next session launched: window '${name}' on ${where}, seeded (watchtower loads its state at session start, then it picks up the seeded work thread; it merges to main when done — QA handoffs and main-only tail work stay with window 1)."
}

cmd_setup() {
  echo "=== mux setup ==="
  echo ""

  # 1. tmux
  if command -v tmux >/dev/null 2>&1; then
    echo "[ok] tmux installed ($(tmux -V))"
  else
    echo "[installing] tmux..."
    if command -v brew >/dev/null 2>&1; then
      brew install tmux || die "Failed to install tmux. Install manually: brew install tmux"
    else
      die "Please install Homebrew first (https://brew.sh), then re-run mux setup."
    fi
  fi

  # 2. fzf
  if command -v fzf >/dev/null 2>&1; then
    echo "[ok] fzf installed"
  else
    echo "[installing] fzf (for fuzzy desk picker)..."
    brew install fzf 2>/dev/null || echo "[skip] fzf install failed — mux will use numbered menu instead"
  fi

  # 3. TPM
  if [[ -d "$HOME/.tmux/plugins/tpm" ]]; then
    echo "[ok] TPM (tmux plugin manager) installed"
  else
    echo "[installing] TPM..."
    git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm 2>/dev/null || die "Failed to install TPM."
  fi

  # 4. tmux.conf
  if [[ -f "$HOME/.tmux.conf" ]]; then
    if grep -q "Mux Config" "$HOME/.tmux.conf" 2>/dev/null; then
      echo "[ok] ~/.tmux.conf (mux config)"
    else
      echo "[skip] ~/.tmux.conf exists but isn't mux-managed. Back up and replace manually if desired."
    fi
  else
    echo "[writing] ~/.tmux.conf..."
    curl -sf "https://raw.githubusercontent.com/orenmagid/mux/main/tmux.conf" -o "$HOME/.tmux.conf" 2>/dev/null || {
      echo "[warn] Could not download tmux.conf — write it manually or re-run mux setup."
    }
  fi

  # 5. Plugins
  if [[ -d "$HOME/.tmux/plugins/tmux-resurrect" ]]; then
    echo "[ok] tmux-resurrect plugin"
  else
    echo "[installing] tmux plugins..."
    "$HOME/.tmux/plugins/tpm/bin/install_plugins" 2>/dev/null || echo "[warn] Plugin install failed — run prefix+I inside tmux to retry."
  fi

  if [[ -d "$HOME/.tmux/plugins/tmux-continuum" ]]; then
    echo "[ok] tmux-continuum plugin"
  fi

  # 6. Help text
  if [[ -f "$MUX_CONFIG_DIR/help.txt" ]]; then
    echo "[ok] help.txt"
  else
    echo "[writing] help.txt..."
    mkdir -p "$MUX_CONFIG_DIR"
    cat > "$MUX_CONFIG_DIR/help.txt" << 'HELPFILE'
╭─────────────── mux help ───────────────╮
│                                        │
│  mux                  Pick a desk      │
│  mux <project>        Open a desk      │
│  mux <project> "..."  Open with prompt │
│  mux new "..."        New Claude here  │
│  mux resume <id>      Resume session   │
│  mux ls               List all desks   │
│  mux note "..."       Leave a note     │
│  mux dx "..."         DX idea capture  │
│  mux copy             Copy w/o wraps   │
│  mux close / done     Park this desk   │
│  mux where            Where am I?      │
│  mux help             This screen      │
│                                        │
│  Keys (no prefix needed):              │
│  F1    Help (this screen)              │
│  F2    Dashboard (switch desks)        │
│  F3    Quick shell popup               │
│  F4    Session trail                   │
│  F5    Cross-session status            │
│  F6    DX captures                     │
│                                        │
│  Ctrl-Space shortcuts:                 │
│  d     Detach (leave tmux)             │
│  s     Session picker                  │
│  1/2/3 Switch windows                  │
│                                        │
│  Press Enter to close                  │
╰────────────────────────────────────────╯
HELPFILE
  fi

  # 7. projects.json
  if [[ -f "$MUX_PROJECTS" ]]; then
    local count
    count=$(python3 "$MUX_LIB" project-names | wc -l | tr -d ' ')
    echo "[ok] projects.json (${count} projects)"
  elif [[ -f "$HOME/.claude/cc-registry.json" ]]; then
    echo "[writing] projects.json from cc-registry..."
    python3 -c "
import json, os
reg = json.load(open(os.path.expanduser('~/.claude/cc-registry.json')))
if isinstance(reg, list): reg = {'projects': reg}  # tolerate shape drift
colors = ['#1a2744','#1a3a2a','#3a2a1a','#2a1a3a','#1a1a2e','#2a3a1a','#3a1a2a']
projects = {}
for i, p in enumerate(reg.get('projects', [])):
    slug = os.path.basename(p['path'])
    projects[slug] = {'path': p['path'], 'color': colors[i % len(colors)]}
os.makedirs(os.path.expanduser('~/.config/mux'), exist_ok=True)
with open(os.path.expanduser('~/.config/mux/projects.json'), 'w') as f:
    json.dump({'projects': projects}, f, indent=2)
print(f'Created projects.json with {len(projects)} projects')
" 2>/dev/null
  else
    echo "[skip] No cc-registry.json found. Create ~/.config/mux/projects.json manually."
  fi

  # 8. Shell integration
  if grep -q "# --- mux" "$HOME/.zshrc" 2>/dev/null; then
    echo "[ok] shell integration in .zshrc"
  else
    echo "[writing] shell integration to .zshrc..."
    cat >> "$HOME/.zshrc" << 'SHELL_INTEGRATION'

# --- mux (Claude Code project manager) ---
export PATH="$HOME/.local/bin:$PATH"
if command -v tmux &>/dev/null && [ -z "$TMUX" ]; then
  tmux ls &>/dev/null 2>&1 && echo "You have open desks. Type 'mux' to pick one."
fi
# --- end mux ---
SHELL_INTEGRATION
  fi

  echo ""
  echo "Setup complete! Try these now:"
  echo "  mux ls              — see your desks"
  local first_project
  first_project=$(python3 "$MUX_LIB" project-names 2>/dev/null | head -1)
  if [[ -n "$first_project" ]]; then
    echo "  mux ${first_project}$(printf '%*s' $((18 - ${#first_project})) '')— open your first desk"
  fi
  echo "  mux help            — see all commands"
}

cmd_portal() {
  local action="${1:-}"
  case "$action" in
    on)
      python3 "$MUX_LIB" project-setting-set spoken_name true
      echo "Portal voice: on"
      ;;
    off)
      python3 "$MUX_LIB" project-setting-set spoken_name false
      echo "Portal voice: off"
      ;;
    *)
      local speak
      speak=$(python3 "$MUX_LIB" project-setting spoken_name) || speak="True"
      if [[ "$speak" == "True" ]]; then
        echo "Portal voice is on. Use: mux portal on/off"
      else
        echo "Portal voice is off. Use: mux portal on/off"
      fi
      ;;
  esac
}

cmd_help() {
  if ! in_tmux; then
    echo "You're not in a desk. Try: mux <project-name>"
    echo ""
    echo "Available projects:"
    require_config
    project_names | sed 's/^/  /'
    return 0
  fi

  local pane_count
  pane_count=$(tmux list-panes 2>/dev/null | wc -l | tr -d ' ')

  if (( pane_count > 1 )); then
    echo "You have a split screen. Type \`exit\` in the pane you don't need."
    echo ""
  fi

  local pane_cmd
  pane_cmd=$(tmux display-message -p '#{pane_current_command}')
  local is_claude
  is_claude=$(tmux display-message -p '#{@mux_claude}' 2>/dev/null)
  if [[ "$is_claude" != "1" ]]; then
    echo "Claude isn't running in this window. Start it with: claude"
    echo ""
  fi

  local session
  session=$(tmux display-message -p '#{session_name}')
  printf '\033[1mCurrent desk:\033[0m %s\n\n' "$session"

  local help_file="${HOME}/.config/mux/help.txt"
  if [[ -f "$help_file" ]]; then
    cat "$help_file"
  else
    # Fallback if help.txt not installed
    echo "Run 'mux setup' to install help files."
  fi
}

cmd_copy() {
  # Copy text to the system clipboard with hard-wrap removal — prose
  # produced in a Claude pane lands paste-ready for Gmail, D2L, docs.
  # Input: stdin when piped, otherwise the most recent tmux buffer
  # (i.e., the last copy-mode selection).
  local input
  if [[ ! -t 0 ]]; then
    input=$(cat)
  elif in_tmux; then
    input=$(tmux show-buffer 2>/dev/null) || die "Nothing to copy — select text first, or pipe: cmd | mux copy"
  else
    die "Pipe text in: some-command | mux copy"
  fi
  [[ -n "$input" ]] || die "Nothing to copy."

  command -v pbcopy >/dev/null 2>&1 || die "pbcopy not found — mux copy requires macOS."
  printf '%s' "$input" | "${HOME}/.config/mux/unwrap-copy.py" | pbcopy

  local chars
  chars=$(pbpaste | wc -c | tr -d ' ')
  printf '\033[32m✓\033[0m copied %s chars to clipboard (hard wraps removed)\n' "$chars"
}

cmd_resume() {
  local session_id="${1:-}"
  require_tmux
  in_tmux || die "You need to be in a desk first. Try: mux <project-name>"
  [[ -n "$session_id" ]] || die "Usage: mux resume <session-id>"
  # The raw id flows into a branch name, a worktree path, and a send-keys
  # line — refuse anything that isn't id-shaped before it gets there.
  [[ "$session_id" =~ ^[0-9a-fA-F-]{8,}$ ]] \
    || die "That doesn't look like a Claude session id: '${session_id}'. Expected hex-and-dashes (8+ chars), e.g. a UUID."

  local session
  session=$(tmux display-message -p '#{session_name}')
  local path
  path=$(project_path "$session" 2>/dev/null || tmux display-message -p '#{pane_current_path}')

  local win_name win_path wt_path skip_reason
  win_name="resume-$(echo "$session_id" | cut -c1-8)"

  if has_active_claude "$session"; then
    if skip_reason=$(worktree_isolation_capable "$session"); then
      wt_path="${MUX_WORKTREES_DIR}/${session}-${win_name}"
      if [[ -d "$wt_path" ]]; then
        # The resume-<id8> slug is deterministic — a second resume of the
        # same session REUSES its existing worktree (it holds that session's
        # prior work). Only an invalid leftover at that path is an error.
        if git -C "$wt_path" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
          win_path="$wt_path"
        else
          die "Worktree path ${wt_path} already exists but isn't a valid git worktree — refusing to resume on the main checkout. Try: rm -rf '${wt_path}' && mux resume ${session_id}"
        fi
      else
        # Never fall back to main — that would dump the resumed session onto
        # the clean station next to a live Claude.
        win_path=$(create_worktree "$session" "$win_name") \
          || die "Couldn't create a worktree for '${win_name}' — refusing to run work on the main checkout. Try: mux worktree ls"
      fi
    else
      # Isolation impossible by design — fall through loudly, never silently.
      echo "⚠ No worktree isolation for this resume (${skip_reason}) — opening on the main checkout." >&2
      win_path="$path"
    fi
  else
    # No active Claude on this desk — resuming on the main checkout is the
    # intended landing (nothing to isolate from). By design, not a fallback.
    win_path="$path"
  fi

  tmux new-window -n "$win_name" -c "$win_path"
  if [[ "$win_path" == "$MUX_WORKTREES_DIR/"* ]]; then
    tmux set-window-option -t "=${session}:${win_name}" @mux_worktree 1 2>/dev/null || true
    tmux set-window-option -t "=${session}:${win_name}" @wt_healthy 1 2>/dev/null || true
  fi
  queue_claude_resume "=${session}:${win_name}" "$session_id" "$win_path"
}

# --- Main dispatch ---

main() {
  require_python
  local cmd="${1:-}"

  case "$cmd" in
    "")        cmd_picker ;;
    ls)        cmd_ls ;;
    new)       shift; cmd_new "$@" ;;
    resume)    shift; cmd_resume "$@" ;;
    snapshot)  cmd_snapshot ;;
    restore)   shift; cmd_restore "$@" ;;
    rename)    shift; cmd_rename "$@" ;;
    close)     shift; cmd_close "${1:-}" ;;
    done)      shift; cmd_done "${1:-}" ;;
    kill)      shift; cmd_kill "${1:-}" ;;
    status)    shift; cmd_status "${1:-}" ;;
    split)     shift; cmd_split "${1:-}" ;;
    where)     cmd_where ;;
    note)      shift; cmd_note "$@" ;;
    copy)      shift; cmd_copy "$@" ;;
    dx)        shift; cmd_dx "$@" ;;
    portal)    shift; cmd_portal "${1:-}" ;;
    worktree)  shift; cmd_worktree "$@" ;;
    qa)        shift; cmd_qa "$@" ;;
    handoff)   shift; cmd_handoff "$@" ;;
    setup)     cmd_setup ;;
    help)      cmd_help ;;
    -h|--help) cmd_help ;;
    *)
      if project_path "$cmd" >/dev/null 2>&1; then
        shift
        cmd_open "$cmd" "$@"
      else
        echo "No project called '${cmd}'."
        echo ""
        echo "Available projects:"
        require_config
        project_names | sed 's/^/  /'
        echo ""
        echo "Run \`mux help\` for all commands."
        exit 1
      fi
      ;;
  esac
}

main "$@"
