#!/bin/bash
# K2 CLI — Agent interface to K2 workspace orchestration
# This script communicates with the K2 daemon via HTTP.
# Works inside K2 terminals (auto-configured) and external terminals
# (auto-discovers connection from ~/.k2/heartbeat.{port,token}).

K2_CLI_VERSION="0.40.2"

set -euo pipefail

# Connection details (set by K2 when creating terminal sessions)
# Falls back to ~/.k2/heartbeat.{port,token} for external terminals
PORT="${K2_PORT:-${K2SO_PORT:-}}"
TOKEN="${K2_HOOK_TOKEN:-${K2SO_HOOK_TOKEN:-}}"

# Daemon home: ~/.k2 post-0.40 (the migration leaves ~/.k2so as a compat
# symlink, so either resolves to the same files — prefer the new name,
# fall back for a not-yet-migrated machine).
K2_HOME="${HOME}/.k2"
[ -d "$K2_HOME" ] || K2_HOME="${HOME}/.k2so"

# Workspace dot-dir for a project root: .k2/ preferred when present,
# .k2so/ is the 0.x default (Q2 dual-compat; full flip lands pre-1.0.0).
_ws_dot_dir() {
    if [ -d "$1/.k2" ]; then echo "$1/.k2"; else echo "$1/.k2so"; fi
}

# Resolve project root: if K2_PROJECT_PATH is set, use it directly.
# Otherwise walk up from CWD to find a .k2/ or .k2so/ directory, skipping $HOME.
# Also handles git worktrees: if CWD is inside a worktree, resolve back to
# the main repo root where the workspace dot-dir lives.
PROJECT="${K2_PROJECT_PATH:-${K2SO_PROJECT_PATH:-$(pwd)}}"
if [ -z "${K2_PROJECT_PATH:-${K2SO_PROJECT_PATH:-}}" ]; then
    _dir="$(pwd)"
    _found=""
    while [ "$_dir" != "/" ]; do
        if { [ -d "$_dir/.k2so" ] || [ -d "$_dir/.k2" ]; } && [ "$_dir" != "$HOME" ]; then
            _found="$_dir"
            break
        fi
        _dir="$(dirname "$_dir")"
    done
    if [ -z "$_found" ]; then
        # May be inside a git worktree — resolve to main repo root.
        # `|| true` is REQUIRED: from a non-git CWD, `git rev-parse` exits 128,
        # and under `set -euo pipefail` that failing command substitution would
        # abort the entire script before it ever reads the daemon port — a
        # silent exit 128 (GH #16). Swallow it so a non-git CWD just leaves
        # _main_root empty and we fall back to $PWD as PROJECT.
        _main_root=$(git -C "$(pwd)" rev-parse --path-format=absolute --git-common-dir 2>/dev/null | sed 's|/.git$||' || true)
        if [ -n "$_main_root" ] && { [ -d "$_main_root/.k2so" ] || [ -d "$_main_root/.k2" ]; }; then
            _found="$_main_root"
        fi
    fi
    PROJECT="${_found:-$PROJECT}"
    unset _dir _found _main_root
fi

# Auto-discover connection from the daemon home if not in a K2 terminal
if [ -z "$PORT" ] || [ -z "$TOKEN" ]; then
    K2SO_HOME="$K2_HOME"
    if [ -f "${K2SO_HOME}/heartbeat.port" ] && [ -f "${K2SO_HOME}/heartbeat.token" ]; then
        PORT="${PORT:-$(cat "${K2SO_HOME}/heartbeat.port" 2>/dev/null)}"
        TOKEN="${TOKEN:-$(cat "${K2SO_HOME}/heartbeat.token" 2>/dev/null)}"
    fi
fi

# DAEMON_INSTALL_EARLY_BYPASS — `k2 daemon install` provisions the
# standalone daemon on a FRESH headless box where no daemon is running
# yet, so it cannot satisfy the PORT/TOKEN connection gate below (and
# doesn't need to: it talks only to GitHub + the filesystem). Skip the
# connection gate for it; the normal router dispatches it to
# cmd_daemon_install (which is defined before the router runs).
_skip_conn_gate=0
[ "${1:-}" = "daemon" ] && [ "${2:-}" = "install" ] && _skip_conn_gate=1

if [ "$_skip_conn_gate" != "1" ] && { [ -z "$PORT" ] || [ -z "$TOKEN" ]; }; then
    echo "Error: Cannot connect to K2SO." >&2
    echo "Either run this from a K2SO terminal, or ensure K2SO is running." >&2
    echo "(Connection details: ~/.k2/heartbeat.port and ~/.k2/heartbeat.token)" >&2
    exit 1
fi
unset _skip_conn_gate

BASE_URL="http://127.0.0.1:${PORT}"

# HTTP helper
cli_request() {
    local endpoint="$1"
    shift
    local params="token=${TOKEN}&project=$(urlencode "$PROJECT")"
    for param in "$@"; do
        params="${params}&${param}"
    done
    curl -sG "${BASE_URL}${endpoint}" \
        --connect-timeout 5 --max-time 30 \
        -d "$params" 2>/dev/null
}

# POST a JSON body. Used by /cli/awareness/publish (Phase 3).
# Token rides in the query string (the daemon parses it there
# before looking at the body). Caller supplies the full endpoint
# and the JSON body on stdin via a pipe, or as the second arg.
cli_post_json() {
    local endpoint="$1"
    local body="${2:-}"
    if [ -z "$body" ]; then
        body="$(cat)"
    fi
    curl -s -X POST "${BASE_URL}${endpoint}?token=${TOKEN}" \
        --connect-timeout 5 --max-time 30 \
        -H "Content-Type: application/json" \
        --data-raw "$body" 2>/dev/null
}

urlencode() {
    python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$1" 2>/dev/null || echo "$1"
}

# POST a form-encoded body (0.39.45, GH #35/#37/#29). Token + project
# ride the query string (the daemon's auth gate parses the query);
# everything else — message text, inbox title/body — rides the POST
# body so long payloads dodge the daemon's 16KB request-head cap that
# used to silently truncate query-string values at ~2.7KB. Callers pass
# pre-urlencoded `key=value` pairs, same contract as cli_request.
cli_post_form() {
    local endpoint="$1"
    shift
    local body=""
    for param in "$@"; do
        body="${body}${body:+&}${param}"
    done
    curl -s -X POST "${BASE_URL}${endpoint}?token=${TOKEN}&project=$(urlencode "$PROJECT")" \
        --connect-timeout 5 --max-time 30 \
        -H "Content-Type: application/x-www-form-urlencoded" \
        --data-raw "$body" 2>/dev/null
}

# 0.39.0f Phase 2.1b: format_agents / format_work removed. Their
# only callers (cmd_agents_list / cmd_agents_work / cmd_work_inbox)
# are also gone; the inbox surface uses format_inbox instead.

# ── Commands ─────────────────────────────────────────────────────────

# 0.39.0f Phase 2.1b: cmd_agents_list / cmd_agents_work /
# cmd_agents_status / cmd_agents_create removed. The dispatch arms
# now route to fail_deprecated; the function bodies are gone for good.

cmd_agent_update() {
    local name="" field="" value=""
    while [ $# -gt 0 ]; do
        case "$1" in
            --name) name="$2"; shift 2 ;;
            --field) field="$2"; shift 2 ;;
            --value) value="$2"; shift 2 ;;
            *) [ -z "$name" ] && name="$1"; shift ;;
        esac
    done
    if [ -z "$name" ] || [ -z "$field" ] || [ -z "$value" ]; then
        echo "Usage: k2 agent update --name <agent> --field <field> --value \"...\"" >&2
        echo "  field: frontmatter key (role, type) or section name (Work Sources, Capabilities)" >&2
        exit 1
    fi
    cli_request "/cli/agent/update" \
        "agent=$(urlencode "$name")" \
        "field=$(urlencode "$field")" \
        "value=$(urlencode "$value")" \
        | python3 -c "
import json, sys
try:
    d = json.loads(sys.stdin.read())
    if d.get('success'):
        print(f'Updated {\"$field\"} for agent {\"$name\"}')
    else:
        print(f'Error: {d}', file=sys.stderr)
except:
    print('Error: failed to parse response', file=sys.stderr)
" 2>/dev/null
}

cmd_heartbeat_noop() {
    local agent=""
    while [ $# -gt 0 ]; do
        case "$1" in
            --agent) agent="$2"; shift 2 ;;
            *) [ -z "$agent" ] && agent="$1"; shift ;;
        esac
    done
    if [ -z "$agent" ]; then
        echo "Usage: k2 heartbeat noop --agent <name>" >&2
        echo "  Report that the agent woke up but had nothing to do." >&2
        echo "  This triggers auto-backoff and prunes the session transcript." >&2
        exit 1
    fi
    cli_request "/cli/agents/heartbeat/noop" "agent=$(urlencode "$agent")" > /dev/null
    echo "No-op recorded for agent $agent"
}

cmd_heartbeat_action() {
    local agent=""
    while [ $# -gt 0 ]; do
        case "$1" in
            --agent) agent="$2"; shift 2 ;;
            *) [ -z "$agent" ] && agent="$1"; shift ;;
        esac
    done
    if [ -z "$agent" ]; then
        echo "Usage: k2 heartbeat action --agent <name>" >&2
        echo "  Report that the agent took action (resets no-op counter)." >&2
        exit 1
    fi
    cli_request "/cli/agents/heartbeat/action" "agent=$(urlencode "$agent")" > /dev/null
    echo "Action recorded for agent $agent"
}

cmd_terminal_spawn() {
    local agent="" command="" title="" wait="" cwd=""
    while [ $# -gt 0 ]; do
        case "$1" in
            --title) title="$2"; shift 2 ;;
            --command) command="$2"; shift 2 ;;
            --agent) agent="$2"; shift 2 ;;
            --cwd) cwd="$2"; shift 2 ;;
            --wait) wait="1"; shift ;;
            *) shift ;;
        esac
    done
    if [ -z "$command" ]; then
        echo "Usage: k2 terminal spawn --command \"...\" [--title \"...\"] [--agent <name>] [--wait]" >&2
        exit 1
    fi
    local params="command=$(urlencode "$command")"
    [ -n "$title" ] && params="${params}&title=$(urlencode "$title")"
    [ -n "$agent" ] && params="${params}&agent=$(urlencode "$agent")"
    [ -n "$cwd" ] && params="${params}&cwd=$(urlencode "$cwd")"
    [ -n "$wait" ] && params="${params}&wait=1"
    cli_request "/cli/terminal/spawn" "$params"
    echo "Terminal spawned: ${title:-$command}"
}

# 0.39.0f Phase 2.1b: cmd_work_create removed; dispatch arm now
# routes `work create` straight to fail_deprecated.

# 0.39.0f Phase 2.1b: cmd_agentic / cmd_state / cmd_delegate removed.
# The dispatch arms for `agentic`, `state`, `delegate` already
# fail_deprecated; the function bodies were dead code carried over
# from 2.1a. The settings surface (`k2 settings --agentic`,
# `k2 settings --state`) owns these knobs now.

cmd_commit() {
    local message=""
    while [ $# -gt 0 ]; do
        case "$1" in
            -m|--message) message="$2"; shift 2 ;;
            *) shift ;;
        esac
    done
    local params=""
    [ -n "$message" ] && params="message=$(urlencode "$message")"
    echo "Requesting AI Commit..."
    cli_request "/cli/commit" "$params"
}

cmd_commit_merge() {
    local message=""
    while [ $# -gt 0 ]; do
        case "$1" in
            -m|--message) message="$2"; shift 2 ;;
            *) shift ;;
        esac
    done
    local params=""
    [ -n "$message" ] && params="message=$(urlencode "$message")"
    echo "Requesting AI Commit & Merge..."
    cli_request "/cli/commit-merge" "$params"
}

# 0.39.0f Phase 2.1b: cmd_agents_delete / cmd_agents_lock /
# cmd_agents_unlock / cmd_agents_triage / cmd_agents_profile /
# cmd_work_move removed. Every dispatch arm exits via
# fail_deprecated; route handlers in the daemon also return HTTP 410.

# 0.39.0f Phase 2.1b: cmd_heartbeat orphan removed. The `heartbeat`
# dispatch arm's fallback is cmd_heartbeat_toggle (on/off the global
# heartbeat); the bare cmd_heartbeat triage trigger had no caller.

# ── Multi-heartbeat (agent_heartbeats) CRUD ────────────────────────────
# Parse frequency/day/time flags into spec JSON that matches the scheduler
# logic in should_project_fire.
#   --daily --time 07:00
#   --weekly --days mon,tue --time 07:00
#   --monthly --days 1,15 --time 07:00
#   --yearly --months jan,apr --days 1 --time 07:00
#   --hourly --every 30 --unit minutes --start 09:00 --end 17:00
hb_parse_spec() {
    local frequency=""
    local time=""
    local days=""
    local months=""
    local every=""
    local unit="minutes"
    local start=""
    local end=""
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --daily)   frequency="daily"; shift ;;
            --weekly)  frequency="weekly"; shift ;;
            --monthly) frequency="monthly"; shift ;;
            --yearly)  frequency="yearly"; shift ;;
            --hourly)  frequency="hourly"; shift ;;
            --time)    time="$2"; shift 2 ;;
            --days)    days="$2"; shift 2 ;;
            --months)  months="$2"; shift 2 ;;
            --every)   every="$2"; shift 2 ;;
            --unit)    unit="$2"; shift 2 ;;
            --start)   start="$2"; shift 2 ;;
            --end)     end="$2"; shift 2 ;;
            *) shift ;;
        esac
    done
    [ -z "$frequency" ] && { echo "Error: must specify one of --daily/--weekly/--monthly/--yearly/--hourly" >&2; exit 1; }
    # Build JSON spec
    python3 -c "
import json
spec = {'frequency': '$frequency'}
if '$time': spec['time'] = '$time'
if '$days':
    d = '$days'.split(',')
    if '$frequency' in ('monthly','yearly'):
        spec['days_of_month'] = [int(x) for x in d if x.strip().isdigit()]
    else:
        spec['days'] = [x.strip().lower() for x in d]
if '$months': spec['months'] = '$months'.split(',')
if '$every': spec['every_seconds'] = int('$every') * (60 if '$unit' == 'minutes' else 3600 if '$unit' == 'hours' else 1)
if '$start': spec['start'] = '$start'
if '$end': spec['end'] = '$end'
print(json.dumps(spec))
print('FREQ:' + '$frequency')
"
}

cmd_heartbeat_add() {
    local name=""
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --name) name="$2"; shift 2 ;;
            *) break ;;
        esac
    done
    [ -z "$name" ] && { echo "Usage: k2 heartbeat add --name <name> --daily|--weekly|... [--time HH:MM] [--days ...]" >&2; exit 1; }
    local spec_output
    spec_output=$(hb_parse_spec "$@")
    local spec_json
    spec_json=$(echo "$spec_output" | head -1)
    local freq
    freq=$(echo "$spec_output" | grep '^FREQ:' | sed 's/^FREQ://')
    local params="name=$(urlencode "$name")&frequency=$freq&spec=$(urlencode "$spec_json")"
    cli_request "/cli/heartbeat/add" "$params" | python3 -c "
import json, sys
try:
    d = json.loads(sys.stdin.read())
    if 'error' in d:
        print(f'Error: {d[\"error\"]}', file=sys.stderr); sys.exit(1)
    print(f'Created heartbeat: {d.get(\"name\")}')
    print(f'Wakeup file: {d.get(\"wakeupAbs\")}')
    print()
    print('Edit the wakeup.md to define what runs on this schedule.')
except Exception as e:
    print(f'Error: {e}', file=sys.stderr); sys.exit(1)
"
}

cmd_heartbeat_list() {
    cli_request "/cli/heartbeat/list" | python3 -c "
import json, sys
try:
    rows = json.loads(sys.stdin.read())
    if not rows:
        print('No heartbeats configured for this workspace.'); sys.exit(0)
    print(f'{\"Name\":<20} {\"Frequency\":<10} {\"Schedule\":<30} {\"Last Fired\":<22} Enabled')
    print('-' * 100)
    for r in rows:
        spec = json.loads(r.get('specJson', '{}'))
        sched_parts = []
        if 'time' in spec: sched_parts.append(f'at {spec[\"time\"]}')
        if 'days' in spec: sched_parts.append(f'on {\", \".join(spec[\"days\"])}')
        if 'days_of_month' in spec: sched_parts.append(f'day(s) {\",\".join(str(d) for d in spec[\"days_of_month\"])}')
        if 'months' in spec: sched_parts.append(f'in {\",\".join(spec[\"months\"])}')
        sched = ' '.join(sched_parts) or '-'
        last = (r.get('lastFired') or 'never')[:19].replace('T', ' ')
        en = 'yes' if r.get('enabled') else 'no'
        print(f'{r.get(\"name\",\"?\"):<20} {r.get(\"frequency\",\"?\"):<10} {sched:<30} {last:<22} {en}')
except Exception as e:
    print(f'Error: {e}', file=sys.stderr); sys.exit(1)
"
}

cmd_heartbeat_remove() {
    local name=""
    local purge=false
    while [ $# -gt 0 ]; do
        case "$1" in
            --purge) purge=true; shift ;;
            -*) shift ;;
            *) [ -z "$name" ] && name="$1"; shift ;;
        esac
    done
    [ -z "$name" ] && { echo "Usage: k2 heartbeat remove <name> [--purge]" >&2; exit 1; }

    if $purge; then
        cli_request "/cli/heartbeat/remove" "name=$(urlencode "$name")" | NAME="$name" python3 -c "
import json, os, sys
try:
    d = json.loads(sys.stdin.read())
    n = os.environ['NAME']
    if d.get('success'): print(f'Purged heartbeat: {n} (row deleted, folder removed)')
    else: print(f'Error: {d.get(\"error\", \"unknown\")}', file=sys.stderr); sys.exit(1)
except Exception as e:
    print(f'Error: {e}', file=sys.stderr); sys.exit(1)
"
    else
        # Default: soft-archive (matches Settings UI's Archive button).
        # Stops firing on schedule, hidden from `heartbeat list`, but
        # the WAKEUP.md and chat history stay readable via
        # `heartbeat list-archived` and the sidebar's Archived section.
        cli_request "/cli/heartbeat/archive" "name=$(urlencode "$name")" | NAME="$name" python3 -c "
import json, os, sys
try:
    d = json.loads(sys.stdin.read())
    n = os.environ['NAME']
    if d.get('success'): print(f'Archived heartbeat: {n}')
    else: print(f'Error: {d.get(\"error\", \"unknown\")}', file=sys.stderr); sys.exit(1)
except Exception as e:
    print(f'Error: {e}', file=sys.stderr); sys.exit(1)
"
    fi
}

cmd_heartbeat_unarchive() {
    local name="${1:-}"
    [ -z "$name" ] && { echo "Usage: k2 heartbeat unarchive <name>" >&2; exit 1; }
    cli_request "/cli/heartbeat/unarchive" "name=$(urlencode "$name")" | NAME="$name" python3 -c "
import json, os, sys
try:
    d = json.loads(sys.stdin.read())
    n = os.environ['NAME']
    if d.get('success'): print(f'Unarchived heartbeat: {n}')
    else: print(f'Error: {d.get(\"error\", \"unknown\")}', file=sys.stderr); sys.exit(1)
except Exception as e:
    print(f'Error: {e}', file=sys.stderr); sys.exit(1)
"
}

cmd_heartbeat_list_archived() {
    cli_request "/cli/heartbeat/list-archived" | python3 -c "
import json, sys
try:
    rows = json.loads(sys.stdin.read())
    if not rows:
        print('No archived heartbeats in this workspace.'); sys.exit(0)
    print(f'{\"NAME\":<20} {\"FREQ\":<10} {\"ARCHIVED\":<22} {\"LAST FIRED\":<22}')
    for r in rows:
        archived = (r.get('archivedAt') or '')[:19].replace('T',' ') or '—'
        last = (r.get('lastFired') or '')[:19].replace('T',' ') or '—'
        print(f'{r.get(\"name\",\"?\"):<20} {r.get(\"frequency\",\"?\"):<10} {archived:<22} {last:<22}')
except Exception as e:
    print(f'Error: {e}', file=sys.stderr); sys.exit(1)
"
}

# Manually fire one specific heartbeat (skips the schedule window
# but respects the agent lock so this never double-spawns).
cmd_heartbeat_fire() {
    local name="${1:-}"
    [ -z "$name" ] && { echo "Usage: k2 heartbeat fire <name>" >&2; exit 1; }
    cli_request "/cli/heartbeat/fire" "name=$(urlencode "$name")" | python3 -c "
import json, sys
try:
    d = json.loads(sys.stdin.read())
    decision = d.get('decision', '?')
    name = d.get('name', '?')
    agent = d.get('agent', '?')
    if d.get('success'):
        tid = d.get('terminalId', '?')
        print(f'Fired heartbeat: {name} (agent: {agent})')
        print(f'  terminal: {tid}')
        print(f'  audit:    decision=fired (manual fire via CLI)')
    else:
        reason = d.get('reason', 'unknown')
        print(f'Did not fire \"{name}\": {decision}', file=sys.stderr)
        print(f'  reason: {reason}', file=sys.stderr)
        sys.exit(1)
except SystemExit:
    raise
except Exception as e:
    print(f'Error: {e}', file=sys.stderr); sys.exit(1)
"
}

cmd_heartbeat_enable_disable() {
    local enabled="$1"; shift
    local name="${1:-}"
    [ -z "$name" ] && { echo "Usage: k2 heartbeat enable|disable <name>" >&2; exit 1; }
    cli_request "/cli/heartbeat/enable" "name=$(urlencode "$name")&enabled=$enabled" > /dev/null
    echo "Heartbeat '$name' ${enabled}d"
}

# 0.37.8 — flip the per-heartbeat opt-in to deliver WAKEUP.md into the
# workspace's pinned chat session (instead of the heartbeat's own saved
# session). When enabled, the heartbeat's last_session_id stays in the
# DB but is no longer targeted on new fires; the prompt routes through
# `workspace_msg::deliver_live` (same primitive as `k2 msg --wake`).
cmd_heartbeat_use_pinned_session() {
    local enabled="$1"; shift
    local name="${1:-}"
    [ -z "$name" ] && { echo "Usage: k2 heartbeat use-pinned-session [on|off] <name>" >&2; exit 1; }
    cli_request "/cli/heartbeat/set-use-workspace-session" \
        "name=$(urlencode "$name")&enabled=$enabled" | python3 -c "
import json, sys
try:
    d = json.loads(sys.stdin.read())
    if d.get('success'):
        state = 'enabled' if '$enabled' == 'true' else 'disabled'
        print(f'Pinned-session delivery {state} for heartbeat: $name')
    else:
        print(f\"Error: {d.get('error', 'unknown')}\", file=sys.stderr); sys.exit(1)
except Exception as e:
    print(f'Error: {e}', file=sys.stderr); sys.exit(1)
"
}

cmd_heartbeat_rename() {
    local from="${1:-}"
    local to="${2:-}"
    if [ -z "$from" ] || [ -z "$to" ]; then
        echo "Usage: k2 heartbeat rename <old-name> <new-name>" >&2
        exit 1
    fi
    cli_request "/cli/heartbeat/rename" "from=$(urlencode "$from")&to=$(urlencode "$to")" | python3 -c "
import json, sys
try:
    d = json.loads(sys.stdin.read())
    if d.get('success'): print(f'Renamed heartbeat: $from -> $to')
    else: print(f\"Error: {d.get('error', 'unknown')}\", file=sys.stderr); sys.exit(1)
except Exception as e:
    print(f'Error: {e}', file=sys.stderr); sys.exit(1)
"
}

# Change an existing heartbeat's schedule. Mirrors the flag surface of
# `cmd_heartbeat_add` — the NAME is positional-or-flag, all other args
# go through hb_parse_spec. The heartbeat's folder/wakeup.md are left
# alone; only the spec/frequency change.
cmd_heartbeat_edit() {
    local name=""
    # Support both `edit <name>` and `edit --name <name>` to stay
    # consistent with add/remove/rename argument styles.
    if [[ $# -gt 0 && "$1" != --* ]]; then
        name="$1"; shift
    fi
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --name) name="$2"; shift 2 ;;
            *) break ;;
        esac
    done
    [ -z "$name" ] && { echo "Usage: k2 heartbeat edit <name> --daily|--weekly|... [--time HH:MM] [--days ...]" >&2; exit 1; }
    local spec_output
    spec_output=$(hb_parse_spec "$@")
    local spec_json
    spec_json=$(echo "$spec_output" | head -1)
    local freq
    freq=$(echo "$spec_output" | grep '^FREQ:' | sed 's/^FREQ://')
    local params="name=$(urlencode "$name")&frequency=$freq&spec=$(urlencode "$spec_json")"
    cli_request "/cli/heartbeat/edit" "$params" | python3 -c "
import json, sys
try:
    d = json.loads(sys.stdin.read())
    if d.get('success'):
        print(f'Updated heartbeat: $name ($freq)')
    else:
        print(f\"Error: {d.get('error', 'unknown')}\", file=sys.stderr); sys.exit(1)
except Exception as e:
    print(f'Error: {e}', file=sys.stderr); sys.exit(1)
"
}

# Print a single heartbeat's full details. Default output is human-readable
# (key: value per line); --json dumps the raw row for scripting/piping.
# Implemented client-side off /cli/heartbeat/list to avoid adding a new
# backend endpoint for a simple filter.
cmd_heartbeat_show() {
    local name=""
    local as_json=0
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --json) as_json=1; shift ;;
            -*) shift ;;
            *) if [ -z "$name" ]; then name="$1"; fi; shift ;;
        esac
    done
    [ -z "$name" ] && { echo "Usage: k2 heartbeat show <name> [--json]" >&2; exit 1; }
    cli_request "/cli/heartbeat/list" | NAME="$name" AS_JSON="$as_json" python3 -c "
import json, os, sys
name = os.environ['NAME']
as_json = os.environ['AS_JSON'] == '1'
try:
    rows = json.loads(sys.stdin.read())
    row = next((r for r in rows if r.get('name') == name), None)
    if not row:
        print(f'No heartbeat named \"{name}\" in this workspace.', file=sys.stderr)
        sys.exit(1)
    if as_json:
        print(json.dumps(row, indent=2))
        sys.exit(0)
    spec = json.loads(row.get('specJson') or '{}')
    enabled = 'yes' if row.get('enabled') else 'no'
    last = row.get('lastFired') or 'never'
    created = row.get('createdAt') or '?'
    pinned = 'yes' if row.get('useWorkspaceSession') else 'no'
    print(f'Name:           {row.get(\"name\")}')
    print(f'Frequency:      {row.get(\"frequency\")}')
    print(f'Enabled:        {enabled}')
    print(f'Pinned chat:    {pinned}')
    print(f'Last fired:     {last}')
    print(f'Wakeup file:    {row.get(\"wakeupPath\")}')
    print(f'Created:        {created}')
    print(f'Spec:')
    for k, v in spec.items():
        print(f'  {k}: {v}')
except SystemExit:
    raise
except Exception as e:
    print(f'Error: {e}', file=sys.stderr); sys.exit(1)
"
}

# Read or edit the wakeup.md that fires for this heartbeat.
#   k2 heartbeat wakeup <name>              # cat the file
#   k2 heartbeat wakeup <name> --path-only  # print absolute path only (for scripting)
#   k2 heartbeat wakeup <name> --edit       # open $EDITOR on the file
#
# The wakeup.md path is workspace-relative in the DB; we resolve it
# against the workspace root (K2SO_PROJECT_PATH or $PWD).
cmd_heartbeat_wakeup() {
    local name=""
    local mode="cat"
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --path-only) mode="path"; shift ;;
            --edit)      mode="edit"; shift ;;
            -*)          shift ;;
            *) if [ -z "$name" ]; then name="$1"; fi; shift ;;
        esac
    done
    [ -z "$name" ] && { echo "Usage: k2 heartbeat wakeup <name> [--path-only|--edit]" >&2; exit 1; }

    local rel_path
    rel_path=$(cli_request "/cli/heartbeat/list" | NAME="$name" python3 -c "
import json, os, sys
try:
    rows = json.loads(sys.stdin.read())
    row = next((r for r in rows if r.get('name') == os.environ['NAME']), None)
    if not row:
        print(f'No heartbeat named \"{os.environ[\"NAME\"]}\" in this workspace.', file=sys.stderr)
        sys.exit(1)
    print(row.get('wakeupPath') or '')
except SystemExit:
    raise
except Exception as e:
    print(f'Error: {e}', file=sys.stderr); sys.exit(1)
")
    local exit_code=$?
    if [ $exit_code -ne 0 ]; then exit $exit_code; fi
    [ -z "$rel_path" ] && { echo "Heartbeat '$name' has no wakeup path on file." >&2; exit 1; }

    local base="${K2_PROJECT_PATH:-${K2SO_PROJECT_PATH:-$PWD}}"
    local abs_path="$base/$rel_path"

    case "$mode" in
        path)
            echo "$abs_path"
            ;;
        edit)
            if [ ! -f "$abs_path" ]; then
                echo "Note: $abs_path does not exist yet; $EDITOR will create it."
            fi
            exec "${EDITOR:-vi}" "$abs_path"
            ;;
        cat)
            if [ ! -f "$abs_path" ]; then
                echo "(wakeup.md not present yet — $abs_path)" >&2
                exit 0
            fi
            echo "# $abs_path"
            echo
            cat "$abs_path"
            ;;
    esac
}

cmd_heartbeat_status() {
    local name=""
    local limit="10"
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --limit|-n) limit="$2"; shift 2 ;;
            *) if [ -z "$name" ]; then name="$1"; fi; shift ;;
        esac
    done
    [ -z "$name" ] && { echo "Usage: k2 heartbeat status <name> [--limit N]" >&2; exit 1; }
    cli_request "/cli/heartbeat/status" "name=$(urlencode "$name")&limit=$limit" | python3 -c "
import json, sys
try:
    rows = json.loads(sys.stdin.read())
    if not rows:
        print(f'No fire history for heartbeat \"$name\" yet.'); sys.exit(0)
    print(f'Last {len(rows)} fire(s) for heartbeat \"$name\":')
    print(f'{\"Time\":<22} {\"Decision\":<22} Reason')
    print('-' * 90)
    for r in rows:
        t = (r.get('firedAt') or '')[:19].replace('T', ' ')
        print(f'{t:<22} {r.get(\"decision\",\"?\"):<22} {r.get(\"reason\",\"\") or \"\"}')
except Exception as e:
    print(f'Error: {e}', file=sys.stderr); sys.exit(1)
"
}

# Show the recent heartbeat decision log for this workspace.
# Every tick writes one row per agent — fired OR skipped — with the
# reason. This makes "why didn't my agent wake?" directly answerable.
cmd_heartbeat_log() {
    local limit="50"
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --limit) limit="$2"; shift 2 ;;
            -n) limit="$2"; shift 2 ;;
            *) shift ;;
        esac
    done
    cli_request "/cli/heartbeat-log" "limit=$limit" | python3 -c "
import json, sys
try:
    rows = json.loads(sys.stdin.read())
    if not rows:
        print('No heartbeat activity recorded yet.')
        sys.exit(0)
    print(f'{'Fired At':<22} {'Agent':<20} {'Decision':<22} {'Inbox':<8} Reason')
    print('-' * 100)
    for r in rows:
        fired = r.get('firedAt', '?')[:19].replace('T', ' ')
        agent = r.get('agentName') or '(project)'
        decision = r.get('decision', '?')
        inbox_count = r.get('inboxCount')
        inbox_priority = r.get('inboxPriority') or ''
        inbox = f'{inbox_count or 0}{\" \" + inbox_priority if inbox_priority else \"\"}'
        reason = r.get('reason') or ''
        print(f'{fired:<22} {agent:<20} {decision:<22} {inbox:<8} {reason}')
except Exception as e:
    print(f'Error parsing response: {e}', file=sys.stderr)
    sys.exit(1)
" 2>/dev/null || cat
}

cmd_hooks_status() {
    local limit="20"
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --limit|-n) limit="$2"; shift 2 ;;
            --json) local as_json=1; shift ;;
            *) shift ;;
        esac
    done
    local raw
    raw=$(cli_request "/cli/hooks/status" "limit=$limit")
    if [ "${as_json:-0}" = "1" ]; then
        echo "$raw"
        return 0
    fi
    echo "$raw" | python3 -c "
import json, sys
data = json.loads(sys.stdin.read())
inj = data.get('injections', {})
script = inj.get('notify_script', {})
print(f\"K2 hook server port: {data.get('port')}\")
print(f\"Notify script: {script.get('path','?')}  exists={script.get('exists',False)}\")
print()
print('Config injections:')
for key in ('claude', 'cursor', 'gemini'):
    c = inj.get(key, {})
    tag = 'OK ' if c.get('injected') else ('NO ' if c.get('exists') else '-- ')
    print(f\"  [{tag}] {key:<8} {c.get('path','?')}\")
print()
events = data.get('recent_events', [])
cap = data.get('recent_events_cap', 50)
if not events:
    print(f'Recent events (0 of last {cap}): none received yet — launch a Claude session and run a prompt.')
else:
    print(f'Recent events (showing last {len(events)} of {cap}):')
    print(f\"{'Time':<20} {'Canonical':<12} {'Raw':<28} {'Pane':<18} Tab\")
    print('-' * 100)
    for e in events:
        ts = (e.get('timestamp') or '')[:19].replace('T',' ')
        canon = e.get('canonical') or '(unmatched)'
        raw = e.get('raw_event') or ''
        pane = (e.get('pane_id') or '')[:17]
        tab = (e.get('tab_id') or '')
        print(f'{ts:<20} {canon:<12} {raw:<28} {pane:<18} {tab}')
" 2>/dev/null || echo "$raw"
}

# ── Daemon lifecycle ─────────────────────────────────────────────────
# These verbs wrap `launchctl` directly because the daemon might not be
# running when they're invoked (so we can't go through its HTTP surface).
# `install` / `uninstall` live in the Tauri app's Settings pane — we do
# NOT duplicate that wiring here; the CLI only does start/stop/restart
# against an already-installed plist.

# dev.k2.daemon post-0.40; fall back to the pre-rename label when only
# the old plist is installed (machine not yet migrated).
_daemon_label="dev.k2.daemon"
_daemon_plist="$HOME/Library/LaunchAgents/${_daemon_label}.plist"
if [ ! -f "$_daemon_plist" ] && [ -f "$HOME/Library/LaunchAgents/com.k2so.k2so-daemon.plist" ]; then
    _daemon_label="com.k2so.k2so-daemon"
    _daemon_plist="$HOME/Library/LaunchAgents/${_daemon_label}.plist"
fi
_daemon_port_file="$K2_HOME/daemon.port"
_daemon_token_file="$K2_HOME/daemon.token"
_daemon_stdout_log="$K2_HOME/daemon.stdout.log"

cmd_daemon_status() {
    local as_json=0
    [ "${1:-}" = "--json" ] && as_json=1

    local installed="no"
    [ -f "$_daemon_plist" ] && installed="yes"

    # launchctl `print` returns info on the loaded service, including
    # the PID when it's running. Extract just the PID for a clean one-
    # line summary.
    local uid pid="" running="no" last_exit_code=""
    uid=$(id -u)
    local print_output
    print_output=$(launchctl print "gui/${uid}/${_daemon_label}" 2>/dev/null || true)
    if [ -n "$print_output" ]; then
        # POSIX classes, not \s — macOS ships BWK awk where \s never
        # matches, which made "Running: no" while the daemon was up.
        pid=$(printf '%s\n' "$print_output" | awk -F'=' '/^[[:space:]]*pid[[:space:]]*=/{gsub(/ /,"",$2); print $2; exit}')
        last_exit_code=$(printf '%s\n' "$print_output" | awk -F'=' '/^[[:space:]]*last exit code[[:space:]]*=/{gsub(/^[ \t]+|[ \t]+$/,"",$2); print $2; exit}')
        [ -n "$pid" ] && [ "$pid" != "0" ] && running="yes"
    fi

    local port="" token_present="no"
    [ -f "$_daemon_port_file" ] && port=$(cat "$_daemon_port_file" 2>/dev/null | tr -d '[:space:]')
    [ -f "$_daemon_token_file" ] && [ -s "$_daemon_token_file" ] && token_present="yes"

    if [ "$as_json" = "1" ]; then
        printf '{"installed":"%s","running":"%s","pid":"%s","port":"%s","token":"%s","last_exit_code":"%s","plist":"%s","log":"%s"}\n' \
            "$installed" "$running" "${pid:-}" "${port:-}" "$token_present" "${last_exit_code:-}" "$_daemon_plist" "$_daemon_stdout_log"
        return 0
    fi

    echo "K2 Daemon"
    echo "  Installed:     $installed  ($_daemon_plist)"
    echo "  Running:       $running$([ "$running" = "yes" ] && echo "  (PID $pid)")"
    if [ "$running" = "yes" ]; then
        [ -n "$port" ] && echo "  Port:          $port"
        echo "  Auth token:    $([ "$token_present" = "yes" ] && echo "present" || echo "missing")"
    fi
    if [ -n "$last_exit_code" ] && [ "$last_exit_code" != "0" ]; then
        echo "  Last exit code: $last_exit_code  (launchd restarted after a non-zero exit)"
    fi
    echo "  Log file:      $_daemon_stdout_log"
    if [ "$installed" = "no" ]; then
        echo ""
        echo "  Daemon plist is not installed. Open K2 → Settings → General → K2 Daemon"
        echo "  to install the launch agent (dev builds require K2SO_INSTALL_DAEMON=1)."
    fi
}

cmd_daemon_start() {
    if [ ! -f "$_daemon_plist" ]; then
        echo "Daemon plist not installed at $_daemon_plist" >&2
        echo "Open K2 → Settings → General → K2 Daemon → Install." >&2
        exit 1
    fi
    # `launchctl load -w` is persistent across reboots. On an already-
    # loaded plist it returns a harmless "service already loaded" which
    # we swallow.
    local err
    err=$(launchctl load -w "$_daemon_plist" 2>&1) || true
    if echo "$err" | grep -q "service already loaded\|Load failed: 37"; then
        echo "Daemon already running."
        return 0
    fi
    if [ -n "$err" ]; then
        echo "launchctl load: $err" >&2
    fi
    echo "Daemon started."
}

cmd_daemon_stop() {
    if [ ! -f "$_daemon_plist" ]; then
        echo "Daemon plist not installed; nothing to stop."
        return 0
    fi
    local err
    err=$(launchctl unload -w "$_daemon_plist" 2>&1) || true
    if echo "$err" | grep -qi "Could not find\|No such"; then
        echo "Daemon was not running."
        return 0
    fi
    if [ -n "$err" ]; then
        echo "launchctl unload: $err" >&2
    fi
    echo "Daemon stopped."
}

# Poll <base>/boot-status until phase=ready (or timeout). On success prints
# "Daemon back up on <version>" and returns 0; on timeout prints a message
# and returns 1. boot-status is UNAUTHENTICATED, so no token is needed.
# Args: $1 = base URL (no trailing slash). Polls every ~2s up to ~60s.
_daemon_wait_ready() {
    local base="$1"
    local attempt=0 max_attempts=30 status_json phase version
    # Give the supervisor a moment to tear down the old process before the
    # first poll, so we don't immediately see the OUTGOING daemon as ready.
    sleep 2
    while [ "$attempt" -lt "$max_attempts" ]; do
        status_json=$(curl -s "${base}/boot-status" \
            --connect-timeout 3 --max-time 5 2>/dev/null) || true
        case "$status_json" in
            *'"phase":"ready"'*|*'"phase": "ready"'*)
                version=$(printf '%s' "$status_json" \
                    | grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' \
                    | head -1 \
                    | sed 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')
                if [ -n "$version" ]; then
                    echo "Daemon back up on ${version}"
                else
                    echo "Daemon back up (version unknown)"
                fi
                return 0
                ;;
        esac
        attempt=$((attempt + 1))
        sleep 2
    done
    echo "daemon restart --wait: timed out after ~60s waiting for ${base} to report phase=ready" >&2
    return 1
}

cmd_daemon_restart() {
    # Two modes:
    #   LOCAL  (default): launchctl kickstart the local launch agent.
    #   REMOTE (--host):  POST /cli/daemon/restart to a remote K2 daemon
    #                     (e.g. over K2 Connect at https://<sub>.k2.dev),
    #                     no GUI required. The remote route is supervisor-
    #                     agnostic — it triggers graceful shutdown and the
    #                     remote supervisor (launchd/systemd) respawns it.
    local host="" token="" wait=0
    while [ $# -gt 0 ]; do
        case "$1" in
            --host)  host="${2:-}";  shift 2 ;;
            --host=*) host="${1#--host=}"; shift ;;
            --token) token="${2:-}"; shift 2 ;;
            --token=*) token="${1#--token=}"; shift ;;
            --wait) wait=1; shift ;;
            *) echo "daemon restart: unknown argument: $1" >&2; exit 2 ;;
        esac
    done

    # ── REMOTE restart ────────────────────────────────────────────────
    if [ -n "$host" ]; then
        # Default the owner token from ~/.k2/daemon.token when --token
        # is omitted (works when targeting the local daemon's own base URL).
        if [ -z "$token" ]; then
            if [ -f "$_daemon_token_file" ] && [ -s "$_daemon_token_file" ]; then
                token=$(cat "$_daemon_token_file" 2>/dev/null | tr -d '[:space:]')
            fi
        fi
        if [ -z "$token" ]; then
            echo "daemon restart --host: no owner token (pass --token <tok> or populate $_daemon_token_file)" >&2
            exit 1
        fi
        # Strip a trailing slash so we don't emit a double-slash URL.
        local base="${host%/}"
        # OWNER-ONLY route: token rides the query string (the daemon's
        # require_owner reads it there), no body.
        local resp
        resp=$(curl -s -X POST "${base}/cli/daemon/restart?token=${token}" \
            --connect-timeout 5 --max-time 30 2>/dev/null) || true
        if [ -z "$resp" ]; then
            echo "daemon restart --host: no response from ${base} (unreachable, or the daemon exited before flushing)" >&2
            exit 1
        fi
        # Friendly parse: success when the JSON carries "restarting":true.
        case "$resp" in
            *'"restarting":true'*)
                echo "Remote daemon at ${base} is restarting."
                echo "$resp"
                if [ "$wait" = "1" ]; then
                    _daemon_wait_ready "$base" || exit 1
                fi
                return 0
                ;;
            *'"error"'*|*'403'*|*'405'*)
                echo "daemon restart --host: rejected by ${base}: $resp" >&2
                exit 1
                ;;
            *)
                echo "daemon restart --host: unexpected response from ${base}: $resp" >&2
                exit 1
                ;;
        esac
    fi

    # ── LOCAL restart (unchanged behavior) ────────────────────────────
    local uid
    uid=$(id -u)
    # `launchctl kickstart -k` sends SIGTERM and respawns (thanks to
    # KeepAlive=true). Cleaner than unload+load because the plist
    # config stays untouched.
    local err
    err=$(launchctl kickstart -k "gui/${uid}/${_daemon_label}" 2>&1) || true
    if echo "$err" | grep -qi "Could not find"; then
        # Fall through to a plain start for users who called `restart`
        # before the daemon was ever loaded.
        cmd_daemon_start
        return $?
    fi
    if [ -n "$err" ]; then
        echo "launchctl kickstart: $err" >&2
    fi
    echo "Daemon restarted."
    if [ "$wait" = "1" ]; then
        # Local daemon's own base URL (PORT is resolved at CLI startup).
        _daemon_wait_ready "$BASE_URL" || exit 1
    fi
}

cmd_daemon_log() {
    local tail_n="200"
    local follow=0
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --tail|-n) tail_n="$2"; shift 2 ;;
            --follow|-f) follow=1; shift ;;
            *) shift ;;
        esac
    done
    if [ ! -f "$_daemon_stdout_log" ]; then
        echo "No daemon log yet at $_daemon_stdout_log"
        return 0
    fi
    if [ "$follow" = "1" ]; then
        tail -n "$tail_n" -f "$_daemon_stdout_log"
    else
        tail -n "$tail_n" "$_daemon_stdout_log"
    fi
}

cmd_daemon_uninstall() {
    local force=0
    [ "${1:-}" = "--yes" ] || [ "${1:-}" = "-y" ] && force=1
    if [ "$force" != "1" ]; then
        echo "This will remove the K2 daemon launch agent and stop the daemon."
        echo "Your workspace data (~/.k2/k2so.db, agent files) is preserved."
        printf "Proceed? [y/N] "
        read -r answer
        case "$answer" in
            y|Y|yes|YES) ;;
            *) echo "Cancelled."; return 1 ;;
        esac
    fi
    if [ -f "$_daemon_plist" ]; then
        launchctl unload -w "$_daemon_plist" 2>/dev/null || true
        rm -f "$_daemon_plist"
    fi
    rm -f "$_daemon_port_file" "$_daemon_token_file"
    echo "Daemon uninstalled."
}

# ── P2: headless standalone-daemon installer ─────────────────────────
#
# `k2 daemon install` fetches the per-OS standalone daemon binary
# published by the release pipeline (P1), minisign-verifies it against
# the SAME pubkey the Tauri updater uses (embedded literal below), and
# drops it into a bin dir + writes a supervisor unit so a crash/restart
# respawns the process.
#
# This command MUST run on a fresh box with NO daemon running, so the
# top-level router intercepts it BEFORE the connection gate (search for
# DAEMON_INSTALL_EARLY_BYPASS). It uses no cli_request()/HTTP-to-daemon.
#
# Minisign verify pubkey — literal string from
# plugins.updater.pubkey in src-tauri/tauri.conf.json. If you rotate
# the updater key, rotate this too (and scripts/install-daemon.sh).
_DAEMON_MINISIGN_PUBKEY="dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU5MTExNDQ2RjY1RUJCMDUKUldRRnUxNzJSaFFSNlFCcXptaWoyRTlidERHaERXbXBkSCthaDEvTTRQbXVIUElOVVd2S0xmNm8K"

_DAEMON_DEFAULT_LATEST_URL="https://github.com/Alakazam-211/K2SO/releases/latest/download/daemon-latest.json"
_DAEMON_VERSION_URL_TMPL="https://github.com/Alakazam-211/K2SO/releases/download/v%s/daemon-latest.json"

# Map uname → manifest artifact key (e.g. "macos-aarch64"). Echoes the
# key on stdout; on an unknown platform prints an error and returns 1.
_daemon_platform_key() {
    local os arch
    case "$(uname -s)" in
        Darwin) os="macos" ;;
        Linux)  os="linux" ;;
        *) echo "Unsupported OS for standalone daemon: $(uname -s) (expected Darwin or Linux)" >&2; return 1 ;;
    esac
    case "$(uname -m)" in
        arm64|aarch64) arch="aarch64" ;;
        x86_64|amd64)  arch="x86_64" ;;
        *) echo "Unsupported CPU arch for standalone daemon: $(uname -m) (expected arm64/aarch64 or x86_64)" >&2; return 1 ;;
    esac
    printf '%s-%s\n' "$os" "$arch"
    return 0
}

# Extract one field for a platform key from a daemon-latest.json blob.
# Args: <json> <platform-key> <field>  (field ∈ url|sig|sha256)
# Pure sed/grep — no python. Isolates the platform object first so a
# field from a sibling platform can't bleed in.
_daemon_manifest_field() {
    local json="$1" key="$2" field="$3"
    # Collapse newlines so the object lives on a single logical line,
    # then carve out from "<key>": { ... } up to the closing brace.
    local obj
    obj=$(printf '%s' "$json" | tr '\n' ' ' \
        | sed -n "s/.*\"${key}\"[[:space:]]*:[[:space:]]*{\([^}]*\)}.*/\1/p")
    [ -n "$obj" ] || return 1
    printf '%s' "$obj" \
        | sed -n "s/.*\"${field}\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p"
}

# Top-level "version" field from the manifest (informational).
_daemon_manifest_version() {
    printf '%s' "$1" | tr '\n' ' ' \
        | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p'
}

# Compute sha256 hex of a file using whichever tool is present.
_daemon_sha256() {
    local f="$1"
    if command -v sha256sum >/dev/null 2>&1; then
        sha256sum "$f" | awk '{print $1}'
    elif command -v shasum >/dev/null 2>&1; then
        shasum -a 256 "$f" | awk '{print $1}'
    else
        echo "Neither sha256sum nor shasum found — cannot verify checksum." >&2
        return 1
    fi
}

cmd_daemon_install() {
    local version="" bin_dir="" manifest_url="" no_service=0 dry_run=0
    while [ $# -gt 0 ]; do
        case "$1" in
            --version)      version="${2:-}"; shift 2 ;;
            --bin-dir)      bin_dir="${2:-}"; shift 2 ;;
            --manifest-url) manifest_url="${2:-}"; shift 2 ;;
            --no-service)   no_service=1; shift ;;
            --dry-run)      dry_run=1; shift ;;
            -h|--help)
                echo "Usage: k2 daemon install [--version <x.y.z>] [--bin-dir <dir>]"
                echo "                           [--manifest-url <url>] [--no-service] [--dry-run]"
                echo ""
                echo "  Install the STANDALONE (headless, no-GUI) K2SO daemon binary"
                echo "  from a signed GitHub release. Minisign-verifies the binary"
                echo "  against the embedded updater pubkey before installing."
                echo ""
                echo "  --version       install a specific release tag (default: latest)."
                echo "  --bin-dir       install dir (default: ~/.local/bin)."
                echo "  --manifest-url  override the daemon-latest.json URL"
                echo "                  (supports file:// for local testing)."
                echo "  --no-service    skip writing the systemd/launchd supervisor unit."
                echo "  --dry-run       print resolved platform/artifact/commands; no download."
                return 0
                ;;
            *) echo "k2 daemon install: unknown argument: $1" >&2; return 2 ;;
        esac
    done

    [ -n "$bin_dir" ] || bin_dir="$HOME/.local/bin"

    # Resolve manifest URL: explicit override > version tag > latest.
    local resolved_manifest_url
    if [ -n "$manifest_url" ]; then
        resolved_manifest_url="$manifest_url"
    elif [ -n "$version" ]; then
        resolved_manifest_url=$(printf "$_DAEMON_VERSION_URL_TMPL" "$version")
    else
        resolved_manifest_url="$_DAEMON_DEFAULT_LATEST_URL"
    fi

    # Platform detect.
    local platform
    platform=$(_daemon_platform_key) || return 1

    # Fetch manifest. file:// is read directly; http(s) via curl.
    local manifest
    case "$resolved_manifest_url" in
        file://*)
            local mpath="${resolved_manifest_url#file://}"
            if [ ! -f "$mpath" ]; then
                echo "Manifest not found: $mpath" >&2
                return 1
            fi
            manifest=$(cat "$mpath")
            ;;
        *)
            if ! command -v curl >/dev/null 2>&1; then
                echo "curl is required to fetch the daemon manifest." >&2
                return 1
            fi
            manifest=$(curl -fsSL "$resolved_manifest_url") || {
                echo "Failed to fetch daemon manifest: $resolved_manifest_url" >&2
                return 1
            }
            ;;
    esac

    local mver url sig sha256
    mver=$(_daemon_manifest_version "$manifest")
    url=$(_daemon_manifest_field "$manifest" "$platform" "url") || true
    sig=$(_daemon_manifest_field "$manifest" "$platform" "sig") || true
    sha256=$(_daemon_manifest_field "$manifest" "$platform" "sha256") || true

    if [ -z "$url" ] || [ -z "$sig" ] || [ -z "$sha256" ]; then
        echo "Manifest has no complete artifact for platform '$platform'." >&2
        echo "  url=${url:-<missing>} sig=${sig:+<present>}${sig:-<missing>} sha256=${sha256:-<missing>}" >&2
        echo "  (manifest version: ${mver:-<unknown>})" >&2
        return 1
    fi

    local bin_path="$bin_dir/k2so-daemon"

    # Service unit targets (computed for dry-run + real install).
    local svc_kind svc_path
    if [ "$(uname -s)" = "Darwin" ]; then
        svc_kind="launchd"
        svc_path="$HOME/Library/LaunchAgents/${_daemon_label}.plist"
    else
        svc_kind="systemd"
        svc_path="$HOME/.config/systemd/user/k2so-daemon.service"
    fi

    if [ "$dry_run" = "1" ]; then
        echo "k2 daemon install — DRY RUN (nothing downloaded or written)"
        echo "  platform key:   $platform"
        echo "  manifest url:   $resolved_manifest_url"
        echo "  manifest ver:   ${mver:-<unknown>}"
        echo "  requested ver:  ${version:-<latest>}"
        echo "  binary url:     $url"
        echo "  sha256:         $sha256"
        echo "  sig (minisign): ${sig%% *}..."
        echo "  install path:   $bin_path"
        echo "  minisign verify: minisign -Vm <downloaded-bin> -P <embedded-pubkey>"
        if [ "$no_service" = "1" ]; then
            echo "  service:        (skipped — --no-service)"
        else
            echo "  service unit:   $svc_kind → $svc_path"
            if [ "$svc_kind" = "systemd" ]; then
                echo "  enable cmds:    systemctl --user daemon-reload && systemctl --user enable --now k2so-daemon"
            else
                echo "  enable cmds:    launchctl bootstrap gui/\$(id -u) $svc_path"
            fi
        fi
        return 0
    fi

    # ── Real install ─────────────────────────────────────────────────
    # Minisign is MANDATORY. Never install an unverified binary.
    if ! command -v minisign >/dev/null 2>&1; then
        echo "minisign is required to verify the daemon binary, but it was not found." >&2
        echo "Install it and re-run:" >&2
        echo "  macOS:  brew install minisign" >&2
        echo "  Debian: sudo apt-get install minisign" >&2
        echo "  Other:  https://jedisct1.github.io/minisign/" >&2
        echo "Refusing to install an unverified binary." >&2
        return 1
    fi

    local tmp
    tmp=$(mktemp -d "${TMPDIR:-/tmp}/k2so-daemon-install.XXXXXX") || {
        echo "Failed to create temp dir." >&2; return 1
    }
    # shellcheck disable=SC2064
    trap "rm -rf '$tmp'" EXIT

    local dl_bin="$tmp/k2so-daemon" dl_sig="$tmp/k2so-daemon.minisig"

    echo "Downloading daemon binary ($platform, manifest ver ${mver:-?})..."
    case "$url" in
        file://*) cp "${url#file://}" "$dl_bin" ;;
        *) curl -fsSL "$url" -o "$dl_bin" ;;
    esac || { echo "Failed to download binary: $url" >&2; return 1; }

    # The .sig string is embedded in the manifest; write it to a file
    # for minisign. (We trust the manifest only insofar as minisign then
    # cryptographically verifies the binary against the embedded pubkey.)
    printf '%s\n' "$sig" > "$dl_sig"

    echo "Verifying minisign signature..."
    if ! minisign -Vm "$dl_bin" -P "$_DAEMON_MINISIGN_PUBKEY" -x "$dl_sig" >/dev/null 2>&1; then
        echo "MINISIGN VERIFICATION FAILED for $url" >&2
        echo "Refusing to install. The binary may be corrupt or tampered with." >&2
        return 1
    fi

    echo "Verifying sha256..."
    local got_sha
    got_sha=$(_daemon_sha256 "$dl_bin") || return 1
    if [ "$got_sha" != "$sha256" ]; then
        echo "SHA256 MISMATCH for $url" >&2
        echo "  expected: $sha256" >&2
        echo "  got:      $got_sha" >&2
        echo "Refusing to install." >&2
        return 1
    fi

    echo "Verified. Installing to $bin_path ..."
    mkdir -p "$bin_dir"
    cp "$dl_bin" "$bin_path"
    chmod +x "$bin_path"

    if [ "$no_service" != "1" ]; then
        if [ "$svc_kind" = "systemd" ]; then
            mkdir -p "$(dirname "$svc_path")"
            cat > "$svc_path" <<EOF
[Unit]
Description=K2SO standalone daemon
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=$bin_path
Restart=always
RestartSec=2

[Install]
WantedBy=default.target
EOF
            echo "Wrote systemd user unit: $svc_path"
            echo "Enable + start it with:"
            echo "  systemctl --user daemon-reload && systemctl --user enable --now k2-daemon"
        else
            mkdir -p "$(dirname "$svc_path")"
            cat > "$svc_path" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>${_daemon_label}</string>
    <key>ProgramArguments</key>
    <array>
        <string>$bin_path</string>
    </array>
    <key>KeepAlive</key>
    <true/>
    <key>RunAtLoad</key>
    <true/>
    <key>StandardOutPath</key>
    <string>$HOME/.k2/daemon.stdout.log</string>
    <key>StandardErrorPath</key>
    <string>$HOME/.k2/daemon.stderr.log</string>
</dict>
</plist>
EOF
            echo "Wrote launchd plist: $svc_path"
            echo "Bootstrap + start it with:"
            echo "  launchctl bootstrap gui/\$(id -u) $svc_path"
        fi
    fi

    echo ""
    echo "Standalone K2SO daemon installed: $bin_path"
    echo "NOTE: pairing this headless box to a K2 Connect account from the"
    echo "      CLI is a follow-up — token bootstrap is still an open PRD"
    echo "      question, so this installer does NOT pair the daemon. Once"
    echo "      the daemon is running, pair it via the documented K2 Connect"
    echo "      flow."
    return 0
}

# 0.39.0f Phase 2.1c: `k2 daemon companion <start|stop|status>` —
# wired to existing /cli/companion/{start,stop,status} routes (Unit 1
# + Unit 7c). Replaces the top-level `k2 companion` verb cut by
# Phase 2.1b. The daemon owns the companion (ngrok tunnel + server),
# so the verb lives under `k2 daemon`.
cmd_daemon_companion() {
    local sub="${1:-status}"
    shift || true
    case "$sub" in
        start)
            # /cli/companion/start is a GET in the dispatch table (no body).
            # Returns {"ok":true,"url":"<tunnel-url>"} on success or
            # {"error":"..."} on failure.
            cli_request "/cli/companion/start" | python3 -c '
import json, sys
try:
    data = json.loads(sys.stdin.read())
    if data.get("ok"):
        url = data.get("url") or ""
        suffix = (" Tunnel: " + url) if url else ""
        print(f"Companion started.{suffix}")
    else:
        msg = data.get("error") or "unknown error"
        print(f"Failed to start companion: {msg}", file=sys.stderr)
        sys.exit(1)
except Exception as e:
    print(f"Failed to parse daemon response: {e}", file=sys.stderr)
    sys.exit(1)
'
            ;;
        stop)
            cli_request "/cli/companion/stop" | python3 -c '
import json, sys
try:
    data = json.loads(sys.stdin.read())
    if data.get("ok"):
        print("Companion stopped.")
    else:
        msg = data.get("error") or "unknown error"
        print(f"Failed to stop companion: {msg}", file=sys.stderr)
        sys.exit(1)
except Exception as e:
    print(f"Failed to parse daemon response: {e}", file=sys.stderr)
    sys.exit(1)
'
            ;;
        status)
            local as_json=0
            [ "${1:-}" = "--json" ] && as_json=1
            local raw
            raw=$(cli_request "/cli/companion/status")
            if [ "$as_json" = "1" ]; then
                printf '%s\n' "$raw"
                return 0
            fi
            printf '%s' "$raw" | python3 -c '
import json, sys
try:
    data = json.loads(sys.stdin.read())
except Exception as e:
    print(f"Failed to parse companion status: {e}", file=sys.stderr)
    sys.exit(1)
running = data.get("running", False)
url = data.get("tunnelUrl") or ""
clients = data.get("connectedClients", 0)
ws_clients = data.get("wsClients", 0)
sessions = data.get("sessions") or []
print("Companion server")
running_label = "yes" if running else "no"
print(f"  Running:    {running_label}")
if running:
    tunnel_label = url or "(not connected)"
    print(f"  Tunnel URL: {tunnel_label}")
    print(f"  Sessions:   {clients}")
    print(f"  WS clients: {ws_clients}")
    for s in sessions:
        tok = s.get("token", "")
        addr = s.get("remoteAddr", "")
        created = s.get("createdAt", "")
        print(f"    - {tok} from {addr} (created {created})")
'
            ;;
        *)
            echo "Usage: k2 daemon companion <start|stop|status [--json]>" >&2
            exit 1
            ;;
    esac
}

cmd_reviews() {
    cli_request "/cli/reviews" | python3 -c "
import json, sys
data = json.loads(sys.stdin.read())
if not data:
    print('No pending reviews.')
    sys.exit(0)
for r in data:
    agent = r.get('agentName', '?')
    branch = r.get('branch', '')
    items = len(r.get('workItems', []))
    files = len(r.get('diffSummary', []))
    print(f'  {agent:<20} {branch:<30} {items} items, {files} files changed')
" 2>/dev/null || cat
}

cmd_review_approve() {
    local agent="${1:-}" branch="${2:-}"
    if [ -z "$agent" ] || [ -z "$branch" ]; then
        echo "Usage: k2 review approve <agent> <branch>" >&2
        exit 1
    fi
    cli_request "/cli/review/approve" "agent=$(urlencode "$agent")&branch=$(urlencode "$branch")"
}

cmd_review_reject() {
    local agent="${1:-}" reason=""
    shift 2>/dev/null
    while [ $# -gt 0 ]; do
        case "$1" in
            --reason) reason="$2"; shift 2 ;;
            *) shift ;;
        esac
    done
    if [ -z "$agent" ]; then
        echo "Usage: k2 review reject <agent> [--reason \"...\"]" >&2
        exit 1
    fi
    local params="agent=$(urlencode "$agent")"
    [ -n "$reason" ] && params="${params}&reason=$(urlencode "$reason")"
    cli_request "/cli/review/reject" "$params"
}

cmd_review_feedback() {
    local agent="${1:-}" feedback=""
    shift 2>/dev/null
    while [ $# -gt 0 ]; do
        case "$1" in
            --message|-m) feedback="$2"; shift 2 ;;
            *) shift ;;
        esac
    done
    if [ -z "$agent" ] || [ -z "$feedback" ]; then
        echo "Usage: k2 review feedback <agent> --message \"...\"" >&2
        exit 1
    fi
    cli_request "/cli/review/feedback" "agent=$(urlencode "$agent")&feedback=$(urlencode "$feedback")"
}

# 0.39.0f Phase 2.1b: cmd_agents_running / cmd_agents_reap removed.
# Use `k2 workspace list --running` / `k2 daemon reap` instead.

cmd_terminal_write() {
    local terminal_id="${1:-}"
    shift || true
    local message="$*"
    if [ -z "$terminal_id" ] || [ -z "$message" ]; then
        echo "Usage: k2 terminal write <terminal-id> \"message\"" >&2
        exit 1
    fi
    cli_request "/cli/terminal/write" "id=$(urlencode "$terminal_id")&message=$(urlencode "$message")"
}

cmd_terminal_read() {
    local terminal_id="${1:-}"
    local lines=50
    shift || true
    while [ $# -gt 0 ]; do
        case "$1" in
            --lines) lines="$2"; shift 2 ;;
            *) shift ;;
        esac
    done
    if [ -z "$terminal_id" ]; then
        echo "Usage: k2 terminal read <terminal-id> [--lines N]" >&2
        exit 1
    fi
    cli_request "/cli/terminal/read" "id=$(urlencode "$terminal_id")&lines=$lines" | python3 -c "
import json, sys
data = json.loads(sys.stdin.read())
for line in data.get('lines', []):
    print(line)
" 2>/dev/null || cli_request "/cli/terminal/read" "id=$(urlencode "$terminal_id")&lines=$lines"
}

# 0.39.x: read a workspace's live terminal by NAME — the read
# complement to `msg` (talk live) and `inbox` (mail). Resolves the
# workspace name daemon-side to its canonical (primary / coordinator)
# session, or a specific agent's session with `--agent <name>`. Prints
# the last N lines of plain text (default 50), one per line. Use this
# to peek another agent before injecting (human-in-the-loop) or to
# diagnose a stuck agent.
cmd_read() {
    local workspace="${1:-}"
    local lines=50
    local agent=""
    shift || true
    while [ $# -gt 0 ]; do
        case "$1" in
            --lines) lines="$2"; shift 2 ;;
            --agent) agent="$2"; shift 2 ;;
            *) shift ;;
        esac
    done
    if [ -z "$workspace" ]; then
        echo "Usage: k2 read <workspace> [--lines N] [--agent <name>]" >&2
        exit 1
    fi
    local query="workspace=$(urlencode "$workspace")&lines=$lines"
    if [ -n "$agent" ]; then
        query="$query&agent=$(urlencode "$agent")"
    fi
    # Prefer python to unwrap {"lines":[...]} into one line per row and
    # surface daemon {"error":...} to stderr with a non-zero exit (so
    # agents scripting `k2 read` can detect failure). Fall back to the
    # raw daemon JSON only when python3 is genuinely unavailable.
    if command -v python3 >/dev/null 2>&1; then
        cli_request "/cli/terminal/read" "$query" | python3 -c "
import json, sys
data = json.loads(sys.stdin.read())
if isinstance(data, dict) and 'error' in data:
    sys.stderr.write(str(data['error']) + '\n')
    sys.exit(1)
for line in data.get('lines', []):
    print(line)
"
    else
        cli_request "/cli/terminal/read" "$query"
    fi
}

cmd_heartbeat_wake() {
    # Automated manager wake cycle:
    # 1. Check if any agents have inbox work
    # 2. If manager is running → send triage message
    # 3. If manager is asleep → launch with triage prompt
    # Check both agent inboxes and workspace inbox for work
    local has_work
    has_work=$(python3 -c "
import json, sys, subprocess, os

port = os.environ.get('K2SO_PORT', '')
token = os.environ.get('K2SO_HOOK_TOKEN', '')
if not port:
    try: port = open(os.path.expanduser('$K2_HOME/heartbeat.port')).read().strip()
    except: pass
if not token:
    try: token = open(os.path.expanduser('$K2_HOME/heartbeat.token')).read().strip()
    except: pass

project = '$PROJECT'
base = f'http://127.0.0.1:{port}'

agent_inbox = False
ws_inbox = False
try:
    import urllib.request
    req = urllib.request.Request(f'{base}/cli/agents/list?token={token}&project={urllib.parse.quote(project)}')
    with urllib.request.urlopen(req, timeout=5) as resp:
        agents = json.loads(resp.read())
        agent_inbox = any(a.get('inboxCount', 0) > 0 for a in agents)
except: pass
try:
    req = urllib.request.Request(f'{base}/cli/work/inbox?token={token}&project={urllib.parse.quote(project)}')
    with urllib.request.urlopen(req, timeout=5) as resp:
        items = json.loads(resp.read())
        ws_inbox = len(items) > 0 if isinstance(items, list) else False
except: pass

print('yes' if (agent_inbox or ws_inbox) else 'no')
" 2>/dev/null || echo "no")

    if [ "$has_work" != "yes" ]; then
        echo '{"status":"noop","reason":"no inbox work found"}'
        return
    fi

    # Check if manager is running via DB session first, fall back to terminal scan
    local manager_terminal=""
    local manager_status=""

    # Try DB session lookup
    local session_info
    session_info=$(python3 -c "
import json, os, sqlite3
try:
    db = os.path.expanduser('$K2_HOME/k2so.db')
    conn = sqlite3.connect(db)
    project = '$PROJECT'
    row = conn.execute('SELECT id FROM projects WHERE path = ?', (project,)).fetchone()
    if not row:
        print('{}')
    else:
        pid = row[0]
        sessions = conn.execute(
            'SELECT agent_name, terminal_id, status FROM agent_sessions WHERE project_id = ?', (pid,)
        ).fetchall()
        for name, tid, status in sessions:
            print(json.dumps({'agent': name, 'terminalId': tid or '', 'status': status}))
            break
        else:
            print('{}')
    conn.close()
except Exception as e:
    print('{}')
" 2>/dev/null)

    if [ -n "$session_info" ] && [ "$session_info" != "{}" ]; then
        manager_terminal=$(echo "$session_info" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('terminalId',''))" 2>/dev/null)
        manager_status=$(echo "$session_info" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('status',''))" 2>/dev/null)
    fi

    # Fall back to scanning running terminals if no DB session
    if [ -z "$manager_terminal" ] || [ "$manager_status" != "running" ]; then
        local running
        running=$(cli_request "/cli/agents/running" 2>/dev/null)
        manager_terminal=$(echo "$running" | python3 -c "
import json, sys
def agent_from_tid(tid):
    # New format: agent-chat:<project_id>:<agent>[:hb:<heartbeat>]
    # Worktree:   agent-chat:wt:<workspace_id>     -> no agent name
    # Legacy:     agent-chat-<agent>               (and -wt-<wsid>)
    if tid.startswith('agent-chat:'):
        rest = tid[len('agent-chat:'):]
        if rest.startswith('wt:'):
            return None
        parts = rest.split(':')
        return parts[1] if len(parts) >= 2 and parts[0] and parts[1] else None
    if tid.startswith('agent-chat-') and not tid.startswith('agent-chat-wt-'):
        return tid[len('agent-chat-'):] or None
    return None

try:
    data = json.loads(sys.stdin.read())
    for t in data:
        cwd = t.get('cwd', '')
        tid = t.get('terminalId', '')
        agent = agent_from_tid(tid)
        if '.k2so/agents/manager' in cwd or '.k2so/agents/coordinator' in cwd or agent in ('manager', 'coordinator'):
            print(tid)
            break
except: pass
" 2>/dev/null)
    fi

    if [ -n "$manager_terminal" ]; then
        # Manager is awake — send triage message
        local msg="New work detected. Run k2 checkin to see your inbox and triage work."
        cli_request "/cli/terminal/write" "id=$(urlencode "$manager_terminal")&message=$(urlencode "$msg")"
        echo "{\"status\":\"notified\",\"terminalId\":\"$manager_terminal\"}"
    else
        # Manager is asleep — launch it, then send triage message after startup
        cli_request "/cli/agents/launch" "agent=manager"
        # Wait for the terminal to be created and Claude to initialize
        local attempts=0
        while [ $attempts -lt 12 ]; do
            sleep 5
            attempts=$((attempts + 1))
            # Check if manager terminal now exists
            local check
            check=$(cli_request "/cli/agents/running" 2>/dev/null)
            local new_id
            new_id=$(echo "$check" | python3 -c "
import json, sys
try:
    data = json.loads(sys.stdin.read())
    for t in data:
        cwd = t.get('cwd', '')
        tid = t.get('terminalId', '')
        if '.k2so/agents/manager' in cwd or '.k2so/agents/coordinator' in cwd or tid.startswith('agent-chat-manager') or tid.startswith('agent-chat-coordinator'):
            print(tid)
            break
except: pass
" 2>/dev/null)
            if [ -n "$new_id" ]; then
                local msg="New work detected. Run k2 checkin to see your inbox and triage work."
                cli_request "/cli/terminal/write" "id=$(urlencode "$new_id")&message=$(urlencode "$msg")"
                echo "{\"status\":\"launched_and_notified\",\"terminalId\":\"$new_id\"}"
                return
            fi
        done
        echo '{"status":"launched","agent":"manager","note":"triage message not sent - terminal not ready"}'
    fi
}

cmd_agent_complete() {
    # Sub-agent completion command:
    # Reads workspace state capability for the work item's source type
    # Auto mode: commit, merge, clean up, notify manager
    # Gated mode: commit, move to done, notify manager for review
    local agent="" file=""
    while [ $# -gt 0 ]; do
        case "$1" in
            --agent) agent="$2"; shift 2 ;;
            --file)  file="$2"; shift 2 ;;
            *) shift ;;
        esac
    done
    if [ -z "$agent" ] || [ -z "$file" ]; then
        echo "Usage: k2 agent complete --agent <name> --file <filename>" >&2
        exit 1
    fi
    cli_request "/cli/agent/complete" "agent=$(urlencode "$agent")&file=$(urlencode "$file")"
}

cmd_workspace_cleanup() {
    cli_request "/cli/workspace/cleanup"
}

# 0.37.5 — resolve resume-chat launch args for a workspace's pinned
# chat tab. Daemon-first: every thin client (Tauri pinned tab,
# companion, MCP, this CLI) hits the same daemon route. Returns JSON
# `{command, args, cwd, resumeSession, resumedExisting}`.
#
# When `workspace_sessions.session_id` points at an on-disk JSONL,
# returns `--resume <id>` (continues the conversation). Otherwise
# pre-allocates a fresh UUID, persists it to SQL, and returns
# `--session-id <new>`.
#
#   k2 workspace resume-chat-args <workspace>
cmd_workspace_resume_chat_args() {
    local target="${1:-}"
    [[ -z "$target" ]] && { echo "Usage: k2 workspace resume-chat-args <workspace>" >&2; exit 1; }
    local resolved_path
    resolved_path=$(cli_request "/cli/workspace/resolve" "q=$(urlencode "$target")" 2>/dev/null \
        | python3 -c 'import json,sys
try:
    print(json.load(sys.stdin).get("path",""))
except Exception:
    pass' 2>/dev/null)
    if [ -z "$resolved_path" ]; then
        resolved_path="$target"
    fi
    cli_request "/cli/workspace/resume-chat-args" "project=$(urlencode "$resolved_path")"
}

# 0.37.4 Phase B: set a v2 session's authoritative label.
# Daemon-side; broadcasts LabelChanged to every WS subscriber so
# every Tauri window + the mobile companion converge.
#
# Optional --no-lock leaves label_source as Pty/Seed (PTY title
# events can still update). Default locks the label.
#
#   k2 sessions set-label <session-uuid> <new label>
#   k2 sessions set-label <session-uuid> <new label> --no-lock
cmd_sessions_set_label() {
    local session_id="${1:-}"
    [[ -z "$session_id" ]] && { echo "Usage: k2 sessions set-label <session-id> <label> [--no-lock]" >&2; exit 1; }
    shift
    local lock="true"
    local label_parts=()
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --no-lock) lock="false"; shift ;;
            *) label_parts+=("$1"); shift ;;
        esac
    done
    local label="${label_parts[*]}"
    [[ -z "$label" ]] && { echo "Usage: k2 sessions set-label <session-id> <label> [--no-lock]" >&2; exit 1; }
    local params="id=$(urlencode "$session_id")&label=$(urlencode "$label")&lock=$lock"
    cli_request "/cli/sessions/label" "$params" \
        | python3 -c 'import json,sys
try:
    d = json.load(sys.stdin)
    if "error" in d:
        print("Error: %s" % d["error"], file=sys.stderr); sys.exit(1)
    print("Set label to: %s (locked=%s)" % (d.get("label",""), d.get("locked","?")))
except Exception as e:
    print("Error: %s" % e, file=sys.stderr); sys.exit(1)'
}

# 0.37.4: read the friendly display name for a workspace's primary
# agent. Reads .k2so/agent/AGENT.md frontmatter (display_name → name)
# then falls back to projects.name. Total — always returns a string.
cmd_workspace_get_agent_display_name() {
    local target="${1:-}"
    [[ -z "$target" ]] && { echo "Usage: k2 workspace agent-name <workspace>" >&2; exit 1; }
    local resolved_path
    resolved_path=$(cli_request "/cli/workspace/resolve" "q=$(urlencode "$target")" 2>/dev/null \
        | python3 -c 'import json,sys
try:
    print(json.load(sys.stdin).get("path",""))
except Exception:
    pass' 2>/dev/null)
    if [ -z "$resolved_path" ]; then
        # Treat as raw path if resolver didn't match a registered workspace.
        resolved_path="$target"
    fi
    cli_request "/cli/workspace/agent-display-name" "project=$(urlencode "$resolved_path")" \
        | python3 -c 'import json,sys
try:
    d = json.load(sys.stdin)
    if "error" in d:
        print("Error: %s" % d["error"], file=sys.stderr); sys.exit(1)
    print(d.get("display_name", ""))
except Exception as e:
    print("Error: %s" % e, file=sys.stderr); sys.exit(1)'
}

# 0.37.4: write the friendly display name for a workspace's primary
# agent. Atomic AGENT.md frontmatter rewrite, scaffolds the file if
# missing. Does NOT touch the technical agent name (`name:`) or
# infrastructure layers (v2_session_map, terminal_id, pending_live).
cmd_workspace_set_agent_display_name() {
    local target="${1:-}"
    local new_name="${2:-}"
    [[ -z "$target" ]] && { echo "Usage: k2 workspace set-agent-name <workspace> <new-name>" >&2; exit 1; }
    [[ -z "$new_name" ]] && { echo "Usage: k2 workspace set-agent-name <workspace> <new-name>" >&2; exit 1; }
    local resolved_path
    resolved_path=$(cli_request "/cli/workspace/resolve" "q=$(urlencode "$target")" 2>/dev/null \
        | python3 -c 'import json,sys
try:
    print(json.load(sys.stdin).get("path",""))
except Exception:
    pass' 2>/dev/null)
    if [ -z "$resolved_path" ]; then
        resolved_path="$target"
    fi
    local params="project=$(urlencode "$resolved_path")&name=$(urlencode "$new_name")"
    cli_request "/cli/workspace/set-agent-display-name" "$params" \
        | python3 -c 'import json,sys
try:
    d = json.load(sys.stdin)
    if "error" in d:
        print("Error: %s" % d["error"], file=sys.stderr); sys.exit(1)
    print("Set agent display name to: %s" % d.get("display_name", ""))
except Exception as e:
    print("Error: %s" % e, file=sys.stderr); sys.exit(1)'
}

# ── Agent Skill Surface ─────────────────────────────────────────────

cmd_checkin() {
    local agent="${K2SO_AGENT_NAME:-}"
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --agent) agent="$2"; shift 2 ;;
            *) shift ;;
        esac
    done
    [[ -z "$agent" ]] && { echo "Error: --agent required (or set K2SO_AGENT_NAME)" >&2; exit 1; }
    cli_request "/cli/checkin" "agent=$agent" | python3 -c "
import json, sys
try:
    d = json.loads(sys.stdin.read())
    if 'error' in d:
        print('Error: %s' % d['error'])
        sys.exit(1)

    print('== Agent Check-in ==')
    print('Agent: %s' % d.get('agent', '?'))
    print('Project: %s' % d.get('project', '?'))
    print()

    # Current task
    task = d.get('task')
    if task and task is not None:
        if isinstance(task, dict):
            print('Current Task:')
            print('  Title: %s' % task.get('title', '(untitled)'))
            print('  Priority: %s' % task.get('priority', 'normal'))
            if task.get('from'): print('  From: %s' % task['from'])
            if task.get('body'): print('  %s' % task['body'][:200])
        else:
            print('Current Task: (active)')
    else:
        print('Current Task: None')
    print()

    # Inbox (unified: work items from filesystem + messages from DB)
    inbox = d.get('inbox', {})
    if isinstance(inbox, dict):
        work = inbox.get('work', [])
        messages = inbox.get('messages', [])
    else:
        # Legacy format (list)
        work = inbox if isinstance(inbox, list) else []
        messages = []

    if messages:
        print('Messages (%d):' % len(messages))
        for msg in messages:
            frm = msg.get('from', '?')
            txt = msg.get('text', '')
            print('  [%s] %s' % (frm, txt[:120]))
    else:
        print('Messages: None')
    print()

    if work:
        print('Work Items (%d):' % len(work))
        for item in work:
            if isinstance(item, dict):
                pri = item.get('priority', 'normal')
                title = item.get('title', item.get('file', '?'))
                frm = item.get('from')
                src = ' (from %s)' % frm if frm and frm != 'null' else ''
                print('  [%s] %s%s' % (pri, title, src))
            else:
                print('  - %s' % item)
    else:
        print('Work Items: None')
    print()

    # Peers
    peers = d.get('peers', [])
    if peers:
        print('Peers (%d):' % len(peers))
        for p in peers:
            status = p.get('status', '?')
            msg = p.get('statusMessage', '')
            proj = p.get('project', '')
            detail = ' - %s' % msg if msg else ''
            loc = ' (%s)' % proj if proj else ''
            print('  %s: %s%s%s' % (p.get('agent', '?'), status, detail, loc))
    else:
        print('Peers: None')
    print()

    # Reservations
    res = d.get('reservations', {})
    if res and isinstance(res, dict) and len(res) > 0:
        print('File Reservations:')
        for path, info in res.items():
            agent_name = info.get('agent', '?') if isinstance(info, dict) else '?'
            print('  %s (held by %s)' % (path, agent_name))
    else:
        print('File Reservations: None')
    print()

    # Feed
    feed = d.get('feed', [])
    if feed:
        print('Recent Activity:')
        for e in feed[:5]:
            etype = e.get('eventType', e.get('type', '?'))
            agent_name = e.get('agent', '')
            summary = e.get('summary', '')
            print('  %s: %s %s' % (agent_name or '-', etype, summary or ''))
    else:
        print('Recent Activity: None')
    print()

    # Wake-up instructions — the agent's wakeup.md (or the shipped default
    # template if the user hasn't edited it yet). Null for agent-template
    # types, which are dispatched by a manager with explicit orders.
    wakeup = d.get('wakeupInstructions')
    if wakeup:
        print('== Wake-up Instructions ==')
        print(wakeup)
except Exception as e:
    sys.stderr.write('Error: %s\n' % e)
" 2>/dev/null
}

cmd_status() {
    local agent="${K2SO_AGENT_NAME:-}"
    local message=""
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --agent) agent="$2"; shift 2 ;;
            *) message="$1"; shift ;;
        esac
    done
    [[ -z "$agent" ]] && { echo "Error: --agent required (or set K2SO_AGENT_NAME)" >&2; exit 1; }
    [[ -z "$message" ]] && { echo "Error: status message required" >&2; exit 1; }
    cli_request "/cli/status" "agent=$agent&message=$(urlencode "$message")"
}

cmd_done() {
    local agent="${K2SO_AGENT_NAME:-}"
    local blocked=""
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --agent) agent="$2"; shift 2 ;;
            --blocked) blocked="$2"; shift 2 ;;
            *) shift ;;
        esac
    done
    [[ -z "$agent" ]] && { echo "Error: --agent required (or set K2SO_AGENT_NAME)" >&2; exit 1; }
    local params="agent=$agent"
    [[ -n "$blocked" ]] && params="$params&blocked=$(urlencode "$blocked")"
    cli_request "/cli/done" "$params"
}

cmd_msg() {
    # `k2 msg <workspace> "text" [--from <name>]`
    #
    # 0.38.6 deliver-or-loudly-fail contract: tries to deliver live to
    # the workspace's running agent, spawning the agent if needed.
    # Returns success only when the message has landed in the agent's
    # session. Never silently writes to inbox. For queued tasks the
    # recipient reads on their own schedule, use `k2 work send`.
    #
    # <workspace>   workspace name (preferred), absolute path, or UUID
    # --from <name> sender identity (default: auto-derived from CWD/
    #               K2SO_PROJECT_PATH; falls back to `external`)
    #
    # Legacy flags `--wake` and `--agent <name>` are accepted as
    # silent no-ops for one release so external scripts don't break
    # overnight. They'll be removed in 0.39.x.

    # `--help` / `-h` must be detected BEFORE arg-required validation
    # below — closes the chronic UX bug where `k2 msg --help`
    # errored with "message text required" instead of showing help.
    for arg in "$@"; do
        case "$arg" in
            --help|-h)
                cmd_help_msg
                return 0
                ;;
        esac
    done

    local target=""
    local text=""
    local explicit_from=""
    local explicit_command=""
    local saw_wake="false"
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --from) explicit_from="$2"; shift 2 ;;
            --command) explicit_command="$2"; shift 2 ;;
            --wake)
                # Deprecated — msg always tries live now.
                saw_wake="true"; shift ;;
            --agent)
                # 0.39.0f Phase 2.1b: hard-deprecated. The agent-keyed
                # routing path is gone; `msg` is workspace-implicit.
                fail_deprecated "k2 msg --agent <name>" "k2 msg <workspace> \"text\"  (workspace-implicit; pass a workspace name/path)" ;;
            -*)
                echo "Error: unknown flag '$1' (try 'k2 msg --help')" >&2
                exit 1 ;;
            *)
                if [[ -z "$target" ]]; then target="$1"
                else text="$1"
                fi
                shift ;;
        esac
    done

    [[ -z "$target" ]] && {
        echo "Error: workspace required" >&2
        echo "Usage: k2 msg <workspace> \"text\" [--from <name>] [--command \"<text>\"]" >&2
        echo "       Run 'k2 msg --help' for full reference." >&2
        exit 1
    }
    [[ -z "$text" ]] && {
        echo "Error: message text required" >&2
        echo "Usage: k2 msg <workspace> \"text\" [--from <name>] [--command \"<text>\"]" >&2
        exit 1
    }

    if [[ "$saw_wake" == "true" ]]; then
        echo "note: '--wake' is now the default; the flag is a no-op and will be removed in 0.39.x." >&2
    fi

    # Resolve sender identity.
    #
    # Priority:
    #   1. Explicit --from <name>
    #   2. Auto-derive from K2SO_PROJECT_PATH or CWD (look up workspace name)
    #   3. Fallback: "external"
    #
    # The daemon also substitutes "external" defensively if it gets an
    # empty string, so the recipient PTY always sees a [from ...] prefix.
    local from="$explicit_from"
    if [[ -z "$from" ]]; then
        local sender_path="${K2_PROJECT_PATH:-${K2SO_PROJECT_PATH:-$PWD}}"
        if [[ -n "$sender_path" ]]; then
            local sender_name
            sender_name=$(cli_request "/cli/workspace/resolve" "q=$(urlencode "$sender_path")" 2>/dev/null \
                | python3 -c 'import json,sys,os
try:
    data = json.load(sys.stdin)
    path = data.get("path","")
    if path:
        print(os.path.basename(path.rstrip("/")))
except Exception:
    pass' 2>/dev/null)
            from="${sender_name:-external}"
        else
            from="external"
        fi
    fi

    # Single canonical endpoint. Returns the canonical MsgResponse
    # shape: {success, target_session_id, attempts, reason, hint}.
    #
    # 0.39.45 (#29/#35/#37): POST with the text in the form body — long
    # messages used to ride the URL query string and got silently
    # truncated at the daemon's request-head cap (~2.7KB of payload).
    local params="workspace=$(urlencode "$target")"
    params="${params}&text=$(urlencode "$text")"
    params="${params}&from=$(urlencode "$from")"
    # 0.39.25: optional slash-command, prepended at the very front of the
    # delivered message (before the [from <name>] prefix). Only sent when
    # non-empty; an older daemon simply ignores this unknown param.
    [[ -n "$explicit_command" ]] && params="${params}&command=$(urlencode "$explicit_command")"

    local response
    response=$(cli_post_form "/cli/workspace/msg" "$params")
    local rc=$?
    # Back-compat: a pre-0.39.45 daemon 405s the POST form (route not
    # POST-allowlisted there). Retry once via the legacy GET query path.
    if echo "$response" | grep -q "method not allowed"; then
        response=$(cli_request "/cli/workspace/msg" "$params")
        rc=$?
    fi
    echo "$response"

    # Exit code mirrors the response's success field so shell scripts
    # can branch cleanly: `k2 msg foo "hi" && echo delivered`.
    local success
    success=$(echo "$response" | python3 -c 'import json,sys
try:
    print("true" if json.load(sys.stdin).get("success") else "false")
except Exception:
    print("false")' 2>/dev/null)
    if [[ "$success" != "true" ]]; then
        return 2
    fi
    return $rc
}

# ── 0.38.7: `k2 whatsnew` ──────────────────────────────────────────
#
# Prints the user-facing changelog highlights for versions newer than
# the user's last-seen marker, with subcommands to inspect / reset /
# mark-seen. Mirrors what the Tauri popup shows on first launch after
# an update; mostly here for terminal-driven users who don't open the
# app every time.
cmd_whatsnew() {
    local action="show"  # show | all | reset | mark-seen
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --reset) action="reset"; shift ;;
            --all) action="all"; shift ;;
            --mark-seen) action="mark-seen"; shift ;;
            --help|-h)
                cat <<'EOF'
k2 whatsnew [--reset] [--all] [--mark-seen]

Show the user-facing changelog for K2SO versions you haven't seen yet
(the same content the Tauri app shows in its popup on first launch
after an update).

OPTIONS
  --reset      Clear the "last seen" marker so the popup shows again
               next K2SO launch.
  --all        Print the full changelog regardless of last-seen state.
  --mark-seen  Manually mark the current version as seen (rarely needed;
               the popup does this on dismiss).
EOF
                return 0 ;;
            *) shift ;;
        esac
    done

    case "$action" in
        reset)
            local resp
            resp=$(cli_request "/cli/whats_new/reset" "")
            echo "$resp"
            echo "(K2SO will show the popup again on next app launch.)" >&2
            ;;
        mark-seen)
            cli_request "/cli/whats_new/mark_seen" ""
            ;;
        show|all)
            local resp
            resp=$(cli_request "/cli/whats_new" "")
            # Parse + print content; --all bypasses the has_new gate.
            # Python embedded in single-quoted bash; uses only single
            # quotes internally so no `\"` escaping is needed.
            local script
            if [[ "$action" == "all" ]]; then
                script='import json, sys
try:
    d = json.load(sys.stdin)
    content = d.get("content", "").strip() or "(no changelog available)"
    cv = d.get("current_version", "?")
    ls = d.get("last_seen_version") or "(never)"
    print(content)
    sys.stderr.write("\n— current: " + cv + ", last seen: " + ls + "\n")
except Exception as e:
    sys.stderr.write("could not parse response: " + str(e) + "\n")
    sys.exit(1)'
            else
                script='import json, sys
try:
    d = json.load(sys.stdin)
    if d.get("has_new"):
        print(d.get("content", "").strip())
        cv = d.get("current_version", "?")
        sys.stderr.write("\n— up through K2SO " + cv + "\n")
    else:
        cv = d.get("current_version", "?")
        ls = d.get("last_seen_version") or "(never)"
        sys.stderr.write("You are up to date on K2SO " + cv + ". (Last seen: " + ls + ")\n")
except Exception as e:
    sys.stderr.write("could not parse response: " + str(e) + "\n")
    sys.exit(1)'
            fi
            echo "$resp" | python3 -c "$script"
            ;;
    esac
}

# ── Awareness Bus CLI verbs (0.34.0 Phase 3) ─────────────────────────
#
# Low-level signal emission + roster query. The primitives every
# agent uses to collaborate: "who's around?" + "tell them something
# right now" + "send them a notice for later."

# Emit a signal to another agent.
#
# Usage:
#   k2 signal <target-agent> <kind> <json-payload> [--inbox] [--from <sender>]
#
# Defaults:
#   kind     — "msg" if omitted
#   delivery — Live (real-time 1-on-1) unless --inbox present
#   from     — $K2SO_AGENT_NAME if set, else "cli"
cmd_signal() {
    local target="${1:-}"
    local kind="${2:-msg}"
    local raw_payload="${3:-{\}}"
    shift 3 2>/dev/null || true
    local delivery="live"
    local from="${K2SO_AGENT_NAME:-cli}"
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --inbox) delivery="inbox"; shift ;;
            --from)  from="$2"; shift 2 ;;
            *) shift ;;
        esac
    done
    [[ -z "$target" ]] && {
        echo "Usage: k2 signal <target> <kind> <json-payload> [--inbox] [--from <sender>]" >&2
        echo "  Default kind is 'msg'; default delivery is 'live' (interrupts running session)." >&2
        echo "  --inbox sends as an intentional-async notice (target reads on their schedule)." >&2
        exit 1
    }

    # Build the AgentSignal wire format. Matches
    # k2so_core::awareness::AgentSignal serialization.
    local now
    now=$(python3 -c 'from datetime import datetime, timezone; print(datetime.now(timezone.utc).isoformat(timespec="microseconds"))' 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%S.000000+00:00")
    local signal_id
    signal_id=$(python3 -c 'import uuid; print(uuid.uuid4())' 2>/dev/null || echo "00000000-0000-0000-0000-000000000000")

    # Resolve the project UUID so the audit row's FK to projects.id
    # is satisfied. Fall back to the raw path if the lookup fails —
    # egress's _orphan sentinel catches that case and still writes
    # the audit row, just bucketed under _orphan instead of a real
    # project. Without this lookup the CLI would send the absolute
    # path as the workspace id, which FK-violates against
    # projects.id (UUIDs) and silently drops audit.
    local workspace_id="$PROJECT"
    if [ -f "$K2_HOME/k2so.db" ] && command -v sqlite3 >/dev/null 2>&1; then
        # SQL-escape single quotes in the path (doubled per SQL spec).
        local _safe_path="${PROJECT//\'/\'\'}"
        local _resolved
        _resolved=$(sqlite3 "$K2_HOME/k2so.db" \
            "SELECT id FROM projects WHERE path = '${_safe_path}' LIMIT 1;" 2>/dev/null || true)
        if [ -n "$_resolved" ]; then
            workspace_id="$_resolved"
        fi
        unset _safe_path _resolved
    fi

    local body
    body=$(python3 <<PY
import json, sys
kind = "$kind"
raw = """$raw_payload"""
# Map CLI kind argument → SignalKind JSON shape.
kind_map = {
    "msg":      lambda d: {"kind":"msg","data":{"text": d.get("text", "")}},
    "status":   lambda d: {"kind":"status","data":{"text": d.get("text", "")}},
    "presence": lambda d: {"kind":"presence","data":{"state": d.get("state", "active")}},
}
try:
    payload_data = json.loads(raw) if raw.strip() else {}
except json.JSONDecodeError:
    payload_data = {"text": raw}
if kind in kind_map:
    kind_json = kind_map[kind](payload_data)
else:
    kind_json = {"kind":"custom","data":{"kind": kind, "payload": payload_data}}

signal = {
    "id": "$signal_id",
    "from": {"scope":"agent","workspace":"$workspace_id","name":"$from"},
    "to":   {"scope":"agent","workspace":"$workspace_id","name":"$target"},
    "kind": kind_json,
    "priority": "normal",
    "delivery": "$delivery",
    "at": "$now",
}
print(json.dumps(signal))
PY
)

    cli_post_json "/cli/awareness/publish" "$body"
    echo
}

# Spawn a Session Stream session in the daemon. After spawn, the
# session's agent_name is registered in the daemon's session_map,
# which means `k2 signal <agent>` now reaches that session's
# live PTY via the InjectProvider.
#
# Usage:
#   k2 sessions spawn --agent foo [--cwd <path>] [--command <cmd>] [--cols N] [--rows N]
cmd_sessions_spawn() {
    local agent="" cwd="$(pwd)" command="" cols="80" rows="24"
    local args_json="null"
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --agent)   agent="$2"; shift 2 ;;
            --cwd)     cwd="$2"; shift 2 ;;
            --command) command="$2"; shift 2 ;;
            --cols)    cols="$2"; shift 2 ;;
            --rows)    rows="$2"; shift 2 ;;
            --) shift; break ;;
            *) shift ;;
        esac
    done
    [[ -z "$agent" ]] && { echo "Usage: k2 sessions spawn --agent <name> [--command <cmd>]" >&2; exit 1; }

    local cmd_json="null"
    [[ -n "$command" ]] && cmd_json=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$command")

    local body
    body=$(python3 <<PY
import json
body = {
    "agent_name": "$agent",
    "cwd": "$cwd",
    "cols": int("$cols"),
    "rows": int("$rows"),
}
cmd = $cmd_json
if cmd is not None:
    body["command"] = cmd
print(json.dumps(body))
PY
)
    cli_post_json "/cli/sessions/spawn" "$body"
    echo
}

# List every session this project's filesystem knows about, by
# walking $PROJECT/.k2so/sessions/ and summarizing each directory.
# No daemon round-trip — archive dirs are the source of truth.
#
# Output columns: session id, total archive bytes, segment count,
# active-segment size, last-modified wall-clock timestamp.
# Pass --json to get a machine-parseable array.
#
# Usage:
#   k2 sessions list [--json]
cmd_sessions_list() {
    local json_mode=""
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --json) json_mode="1"; shift ;;
            --) shift; break ;;
            *) shift ;;
        esac
    done
    local sessions_dir="$(_ws_dot_dir "$PROJECT")/sessions"
    if [ ! -d "$sessions_dir" ]; then
        if [ -n "$json_mode" ]; then
            echo "[]"
        else
            echo "No sessions yet for $PROJECT" >&2
        fi
        return 0
    fi
    python3 - "$sessions_dir" "$json_mode" <<'PY'
import json, os, sys, time
sessions_dir = sys.argv[1]
as_json = sys.argv[2] == "1"

rows = []
for name in sorted(os.listdir(sessions_dir)):
    session_dir = os.path.join(sessions_dir, name)
    if not os.path.isdir(session_dir):
        continue
    total = 0
    segments = 0
    active_bytes = 0
    latest_mtime = 0
    for f in os.listdir(session_dir):
        path = os.path.join(session_dir, f)
        try:
            st = os.stat(path)
        except OSError:
            continue
        if not (f == "archive.ndjson" or f.startswith("archive.") and (f.endswith(".ndjson") or f.endswith(".ndjson.gz"))):
            continue
        total += st.st_size
        segments += 1
        latest_mtime = max(latest_mtime, int(st.st_mtime))
        if f == "archive.ndjson":
            active_bytes = st.st_size
    rows.append({
        "session_id": name,
        "total_bytes": total,
        "segments": segments,
        "active_bytes": active_bytes,
        "last_modified_epoch": latest_mtime,
    })

def human_bytes(n):
    if n < 1024:
        return f"{n} B"
    if n < 1024 * 1024:
        return f"{n / 1024:.2f} KB"
    if n < 1024 * 1024 * 1024:
        return f"{n / (1024 * 1024):.2f} MB"
    return f"{n / (1024 * 1024 * 1024):.2f} GB"

if as_json:
    print(json.dumps(rows, indent=2))
else:
    if not rows:
        print("No sessions yet", file=sys.stderr)
        sys.exit(0)
    print(f"{'SESSION':38}  {'SEGMENTS':>8}  {'TOTAL':>12}  {'ACTIVE':>12}  LAST MODIFIED")
    for r in rows:
        ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(r["last_modified_epoch"])) if r["last_modified_epoch"] else "-"
        print(f"{r['session_id']:38}  {r['segments']:>8}  {human_bytes(r['total_bytes']):>12}  {human_bytes(r['active_bytes']):>12}  {ts}")
PY
}

# Compact a session's rotated segments by gzipping them in place.
# NEVER touches the active segment (archive.ndjson). Idempotent —
# re-running is a no-op once every rotated segment is already
# gzipped.
#
# Usage:
#   k2 sessions compact <session-id>
cmd_sessions_compact() {
    local session_id="${1:-}"
    [[ -z "$session_id" ]] && {
        echo "Usage: k2 sessions compact <session-id>" >&2
        echo "(use 'k2 sessions list' to find session ids)" >&2
        exit 1
    }
    local session_dir="$(_ws_dot_dir "$PROJECT")/sessions/${session_id}"
    if [ ! -d "$session_dir" ]; then
        echo "No archive directory for session $session_id at $session_dir" >&2
        exit 1
    fi
    if ! command -v gzip >/dev/null 2>&1; then
        echo "gzip not found in PATH — cannot compact" >&2
        exit 1
    fi

    local count=0
    local compacted_bytes_before=0
    local compacted_bytes_after=0
    # Loop through every rotated segment that isn't yet gzipped.
    # Filename pattern: archive.NNN.ndjson (NOT archive.ndjson,
    # NOT *.ndjson.gz).
    while IFS= read -r -d '' seg; do
        local before_size
        before_size=$(stat -f '%z' "$seg" 2>/dev/null || stat -c '%s' "$seg" 2>/dev/null || echo 0)
        compacted_bytes_before=$((compacted_bytes_before + before_size))

        # `gzip -f` overwrites if a *.gz already exists; we checked
        # above that the .gz form doesn't exist for this segment.
        if gzip -f "$seg"; then
            local after_size
            after_size=$(stat -f '%z' "$seg.gz" 2>/dev/null || stat -c '%s' "$seg.gz" 2>/dev/null || echo 0)
            compacted_bytes_after=$((compacted_bytes_after + after_size))
            count=$((count + 1))
        else
            echo "gzip failed for $seg" >&2
        fi
    done < <(find "$session_dir" -maxdepth 1 -type f \
        -name 'archive.[0-9][0-9][0-9].ndjson' -print0 | sort -z)

    if [ "$count" -eq 0 ]; then
        echo "Nothing to compact for session $session_id (no uncompressed rotated segments)." >&2
        return 0
    fi

    local saved=$((compacted_bytes_before - compacted_bytes_after))
    echo "{\"sessionId\":\"$session_id\",\"compacted\":$count,\"bytesBefore\":$compacted_bytes_before,\"bytesAfter\":$compacted_bytes_after,\"bytesSaved\":$saved}"
}

# Sessions subcommand dispatcher — handles `k2 sessions spawn`,
# 0.37.11 — List LIVE v2 sessions (daemon memory) for a workspace.
# Wraps /cli/sessions/list-for-workspace.  Distinct from
# `k2 sessions list` (which inspects on-disk archive segments) —
# this verb answers "what's the daemon currently holding open for
# this workspace?", which is what you need to verify focus-window
# adoption (sessions count shouldn't change when a focus window
# opens on top of an existing workspace).
#
# Usage:
#   k2 sessions live <path>             # human table
#   k2 sessions live <path> --json      # raw JSON from daemon
#   k2 sessions live <path> --count     # just the integer count
cmd_sessions_live() {
    local path="${1:-}"
    local mode="table"
    shift 2>/dev/null || true
    while [ $# -gt 0 ]; do
        case "$1" in
            --json)  mode="json"; shift ;;
            --count) mode="count"; shift ;;
            *) shift ;;
        esac
    done
    if [ -z "$path" ]; then
        echo "Usage: k2 sessions live <workspace-path> [--json|--count]" >&2
        echo "  Lists live daemon-side v2 sessions whose cwd is under <workspace-path>." >&2
        exit 1
    fi
    # Try the targeted endpoint first (post-0.37.11 daemons); fall back
    # to `/cli/agents/running` with client-side filtering for older
    # daemons that haven't picked up the new route yet.
    local response
    response=$(cli_request "/cli/sessions/list-for-workspace" "path=$(urlencode "$path")" 2>/dev/null)
    local fallback=0
    if echo "$response" | grep -q '"route not found"' 2>/dev/null; then
        fallback=1
        response=$(cli_request "/cli/agents/running")
    fi
    case "$mode" in
        json)
            if [ "$fallback" = "1" ]; then
                echo "$response" | python3 -c "
import json, sys
def _workspace_match(cwd: str, path: str) -> bool:
    if not cwd: return False
    p = path.rstrip('/')
    c = cwd.rstrip('/')
    return c == p or cwd.startswith(p + '/')
path = '$path'
all_sessions = json.loads(sys.stdin.read())
filtered = [
    {
        'sessionId': s.get('terminalId'),
        'agentName': s.get('agentName'),
        'command': s.get('command'),
        'args': [],
        'cwd': s.get('cwd', ''),
        'isV2': True,  # agents running only ever returns alive sessions; v2 status unknown but assumed
    }
    for s in all_sessions if _workspace_match(s.get('cwd') or '', path)
]
print(json.dumps(filtered, indent=2))
"
            else
                echo "$response"
            fi
            ;;
        count)
            if [ "$fallback" = "1" ]; then
                echo "$response" | python3 -c "
import json, sys
def _workspace_match(cwd: str, path: str) -> bool:
    if not cwd: return False
    p = path.rstrip('/')
    c = cwd.rstrip('/')
    return c == p or cwd.startswith(p + '/')
path = '$path'
sessions = json.loads(sys.stdin.read())
print(sum(1 for s in sessions if _workspace_match(s.get('cwd') or '', path)))
"
            else
                echo "$response" | python3 -c "import json,sys; print(len(json.loads(sys.stdin.read())))"
            fi
            ;;
        *)
            echo "$response" | python3 -c "
import json, sys
def _workspace_match(cwd: str, path: str) -> bool:
    if not cwd: return False
    p = path.rstrip('/')
    c = cwd.rstrip('/')
    return c == p or cwd.startswith(p + '/')
fallback = $fallback
path = '$path'
raw = json.loads(sys.stdin.read())
if fallback:
    sessions = [s for s in raw if _workspace_match(s.get('cwd') or '', path)]
    note = '  (using /cli/agents/running fallback — daemon pre-0.37.11)'
else:
    sessions = raw
    note = ''
if not sessions:
    print('No live sessions in this workspace.' + note)
    sys.exit(0)
print(f'{len(sessions)} live session(s):' + note)
print(f'  {\"TERMINAL_ID\":<38}  {\"AGENT\":<30}  {\"CMD\":<14}  CWD')
for s in sessions:
    sid = s.get('sessionId') or s.get('terminalId') or '?'
    name = s.get('agentName', '?')[:30]
    cmd = (s.get('command') or 'shell')[:14]
    cwd = s.get('cwd', '')
    print(f'  {sid:<38}  {name:<30}  {cmd:<14}  {cwd}')
"
            ;;
    esac
}

# `k2 sessions list`, `k2 sessions compact`, and any future
# `sessions *` verbs.
cmd_sessions() {
    local sub="${1:-}"
    shift 2>/dev/null || true
    case "$sub" in
        spawn)     cmd_sessions_spawn "$@" ;;
        list)      cmd_sessions_list "$@" ;;
        live)      cmd_sessions_live "$@" ;;
        compact)   cmd_sessions_compact "$@" ;;
        set-label) cmd_sessions_set_label "$@" ;;
        *)
            echo "Usage:" >&2
            echo "  k2 sessions spawn --agent <name> [...]" >&2
            echo "  k2 sessions list [--json]                     # on-disk archives" >&2
            echo "  k2 sessions live <workspace> [--json|--count] # live daemon sessions" >&2
            echo "  k2 sessions compact <session-id>" >&2
            echo "  k2 sessions set-label <session-id> <label> [--no-lock]" >&2
            exit 1
            ;;
    esac
}

# 0.39.0 (workspace==agent model): roster ≡ connections. The
# pre-0.39 cmd_roster walked `.k2so/agents/<name>/` for "known
# agents" in the calling workspace — a model that predates the
# workspace==agent unification. Post-0.39 the team of an agent is
# the set of OTHER CONNECTED WORKSPACES, surfaced by
# `k2 connections list`. Both `k2 roster` and `k2 who` now
# fail_deprecated pointing at `connections list`. The function
# body is intentionally a stub — the dispatch arms route to
# fail_deprecated directly so cmd_roster is no longer reachable
# in practice.
cmd_roster() {
    fail_deprecated "roster" "k2 connections list"
}

cmd_reserve() {
    local agent="${K2SO_AGENT_NAME:-}"
    local paths=()
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --agent) agent="$2"; shift 2 ;;
            *) paths+=("$1"); shift ;;
        esac
    done
    [[ -z "$agent" ]] && { echo "Error: --agent required (or set K2SO_AGENT_NAME)" >&2; exit 1; }
    [[ ${#paths[@]} -eq 0 ]] && { echo "Error: at least one path required" >&2; exit 1; }
    local joined=$(IFS=,; echo "${paths[*]}")
    cli_request "/cli/reserve" "agent=$agent&paths=$(urlencode "$joined")"
}

cmd_release() {
    local agent="${K2SO_AGENT_NAME:-}"
    local paths=()
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --agent) agent="$2"; shift 2 ;;
            *) paths+=("$1"); shift ;;
        esac
    done
    [[ -z "$agent" ]] && { echo "Error: --agent required (or set K2SO_AGENT_NAME)" >&2; exit 1; }
    local params="agent=$agent"
    if [[ ${#paths[@]} -gt 0 ]]; then
        local joined=$(IFS=,; echo "${paths[*]}")
        params="$params&paths=$(urlencode "$joined")"
    fi
    cli_request "/cli/release" "$params"
}

cmd_connections() {
    local action="${1:-list}"
    shift 2>/dev/null || true
    case "$action" in
        list)
            # 0.39.0 (workspace==agent model): connections list renders
            # a bidirectional, deduped peer list — both workspaces in a
            # connection see each other as just "connected." Direction
            # is a data-model concept (source → target rows), not a
            # user-visible distinction; the daemon-side dispatch in
            # `core::connections::connections("list", ...)` returns the
            # deduped Peer shape (`projectId`, `projectName`,
            # `relationTypes`) — no `direction` / `type` fields.
            # 0.39.45 (#31): print WHOSE connections these are (the
            # daemon-resolved source workspace) so an inherited/parent
            # resolution is self-evident, and flag unreachable peers.
            cli_request "/cli/connections" "action=list" | python3 -c "
import json, sys
raw = sys.stdin.read()
try:
    d = json.loads(raw)
except Exception:
    sys.stdout.write('Error: daemon returned an unparseable response: %r\n' % raw[:200])
    sys.exit(1)
if 'error' in d:
    sys.stdout.write('Error: %s\n' % d['error'])
    sys.exit(1)
ws = d.get('workspace') or {}
label = ws.get('name') or ws.get('path') or '?'
print('Connections for %s:' % label)
conns = d.get('connections', [])
if not conns:
    print('  (none)')
for c in conns:
    flag = '' if c.get('reachable', True) else '  [UNREACHABLE — project missing or path gone]'
    print('  - %s%s' % (c.get('projectName', '?'), flag))
"
            ;;
        add)
            local target="${1:-}"
            [[ -z "$target" ]] && { echo "Usage: k2 connections add <workspace-name>" >&2; exit 1; }
            # 0.39.45 (#32): NEVER exit silently. Errors used to go to
            # stderr and then get eaten by a 2>/dev/null on this pipe —
            # a failed add looked identical to success with no output.
            cli_request "/cli/connections" "action=add&target=$(urlencode "$target")" | python3 -c "
import json, sys
raw = sys.stdin.read()
try:
    d = json.loads(raw)
except Exception:
    sys.stdout.write('Error: daemon returned an unparseable response: %r\n' % raw[:200])
    sys.exit(1)
if 'error' in d:
    sys.stdout.write('Error: %s\n' % d['error'])
    sys.exit(1)
if d.get('noop'):
    print('Already connected to %s (%s)' % (d.get('target', '?'), d.get('message', '')))
else:
    print('Connected to %s' % d.get('target', '?'))
"
            ;;
        remove)
            local target="${1:-}"
            [[ -z "$target" ]] && { echo "Usage: k2 connections remove <workspace-name>" >&2; exit 1; }
            cli_request "/cli/connections" "action=remove&target=$(urlencode "$target")" | python3 -c "
import json, sys
raw = sys.stdin.read()
try:
    d = json.loads(raw)
except Exception:
    sys.stdout.write('Error: daemon returned an unparseable response: %r\n' % raw[:200])
    sys.exit(1)
if 'error' in d:
    sys.stdout.write('Error: %s\n' % d['error'])
    sys.exit(1)
print('Disconnected.')
"
            ;;
        *)
            echo "Usage: k2 connections [list|add|remove] [workspace-name]" >&2
            echo "  list              List connections for this workspace" >&2
            echo "  add <name>        Connect to another workspace" >&2
            echo "  remove <name>     Disconnect from a workspace" >&2
            exit 1
            ;;
    esac
}

# 0.39.0f Phase 2.1b: cmd_companion orphan removed. The `companion`
# top-level verb is hard-deprecated at dispatch; the new home is
# `k2 daemon companion <start|stop|status>` — wired into the
# `daemon` dispatch by Phase 2.1c (see cmd_daemon_companion above).

# POST to a query-string route (no JSON body). The daemon parses the
# token + params from the query; the body is empty/drained. Used by the
# tunnel start/stop control routes (mutating → POST-allowlisted, with a
# per-handler `require_post` gate on the daemon side).
cli_post() {
    local endpoint="$1"; shift
    local params="token=${TOKEN}&project=$(urlencode "$PROJECT")"
    for param in "$@"; do
        params="${params}&${param}"
    done
    curl -s -X POST "${BASE_URL}${endpoint}?${params}" \
        --connect-timeout 5 --max-time 30 \
        --data-raw "" 2>/dev/null
}

# K2 Connect tunnel — expose this workspace's daemon at
# https://<user>.k2.dev by running an frpc client that dials the hosted
# K2 Connect server. Routes:
#   POST /cli/tunnel/start[?subdomain=<s>]   GET /cli/tunnel/status   POST /cli/tunnel/stop
cmd_tunnel() {
    local sub="${1:-status}"
    shift || true
    case "$sub" in
        start)
            local subdomain=""
            while [ $# -gt 0 ]; do
                case "$1" in
                    --subdomain) subdomain="$2"; shift 2 ;;
                    *) shift ;;
                esac
            done
            local extra=""
            [ -n "$subdomain" ] && extra="subdomain=$(urlencode "$subdomain")"
            cli_post "/cli/tunnel/start" $extra | python3 -c '
import json, sys
try:
    d = json.loads(sys.stdin.read())
except Exception as e:
    print(f"Failed to parse daemon response: {e}", file=sys.stderr); sys.exit(1)
if d.get("error"):
    err = d["error"]
    print(f"Failed to start tunnel: {err}", file=sys.stderr); sys.exit(1)
url = d.get("public_url") or ""
print("Tunnel started." + (f" Public URL: {url}" if url else " (subdomain will be assigned by the server)"))
'
            ;;
        stop)
            cli_post "/cli/tunnel/stop" | python3 -c '
import json, sys
try:
    d = json.loads(sys.stdin.read())
except Exception as e:
    print(f"Failed to parse daemon response: {e}", file=sys.stderr); sys.exit(1)
if d.get("ok"):
    print("Tunnel stopped.")
else:
    err = d.get("error") or "unknown error"
    print(f"Failed to stop tunnel: {err}", file=sys.stderr); sys.exit(1)
'
            ;;
        status)
            local as_json=0
            [ "${1:-}" = "--json" ] && as_json=1
            local raw
            raw=$(cli_request "/cli/tunnel/status")
            if [ "$as_json" = "1" ]; then printf '%s\n' "$raw"; return 0; fi
            printf '%s' "$raw" | python3 -c '
import json, sys
try:
    d = json.loads(sys.stdin.read())
except Exception as e:
    print(f"Failed to parse tunnel status: {e}", file=sys.stderr); sys.exit(1)
print("K2 Connect tunnel")
running = "yes" if d.get("running") else "no"
print(f"  Running:    {running}")
if d.get("running"):
    public_url = d.get("public_url") or "(assigned by server)"
    print(f"  Public URL: {public_url}")
    if d.get("local_port"):
        lp = d["local_port"]
        print(f"  Local port: {lp}")
    if d.get("server_addr"):
        sa = d["server_addr"]
        print(f"  Server:     {sa}")
'
            ;;
        *)
            echo "Usage: k2 tunnel <start [--subdomain <s>]|stop|status [--json]>" >&2
            exit 1
            ;;
    esac
}

cmd_skills() {
    local subcmd="${1:-list}"
    shift || true
    case "$subcmd" in
        list)
            # Skills live at `.k2so/skills/<name>/SKILL.md` post-Phase-2.5b.
            # The daemon's `/cli/agents/list` route enumerates the same
            # folder (the legacy `.k2so/agents/` tree is consolidated
            # into `.k2so/skills/` at first boot — see
            # `consolidate_skills_v1`). Rename of the route to
            # `/cli/skills/list` is deferred until the daemon-side route
            # split lands; for now the renderer is one HTTP call away.
            cli_request "/cli/agents/list" | python3 -c "
import json, sys
try:
    d = json.loads(sys.stdin.read())
    if isinstance(d, dict) and 'error' in d:
        sys.stderr.write('Error: %s\n' % d['error']); sys.exit(1)
    items = d if isinstance(d, list) else d.get('agents', [])
    if not items:
        print('No skills in this workspace.'); sys.exit(0)
    name_w = max(len(s.get('name','')) for s in items)
    for s in items:
        nm = s.get('name','')
        role = s.get('role','')
        atype = s.get('agentType') or s.get('agent_type') or 'skill'
        print(f'  {nm:<{name_w}}  [{atype}]  {role}')
except Exception as e:
    sys.stderr.write('Error: %s\n' % e); sys.exit(1)
" 2>/dev/null
            ;;
        create)
            local name="" source_skill="" role=""
            while [ $# -gt 0 ]; do
                case "$1" in
                    --template|--from) source_skill="$2"; shift 2 ;;
                    --role) role="$2"; shift 2 ;;
                    --*) shift ;;
                    *) [ -z "$name" ] && name="$1"; shift ;;
                esac
            done
            if [ -z "$name" ]; then
                echo "Usage: k2 skills create <name> [--template <existing-skill>] [--role \"...\"]" >&2
                echo "  --template <name>   Copy an existing skill as the starting point. Any" >&2
                echo "                      skill in this workspace can seed any other; templates" >&2
                echo "                      aren't a separate kind post-Phase-2.5b." >&2
                echo "  --role \"...\"        Frontmatter role/description for the new skill." >&2
                exit 1
            fi
            local params="agent=$(urlencode "$name")"
            [ -n "$role" ] && params="$params&role=$(urlencode "$role")"
            [ -n "$source_skill" ] && params="$params&template=$(urlencode "$source_skill")"
            cli_request "/cli/agents/create" "$params" | python3 -c "
import json, sys
try:
    d = json.loads(sys.stdin.read())
    if isinstance(d, dict) and 'error' in d:
        sys.stderr.write('Error: %s\n' % d['error']); sys.exit(1)
    nm = d.get('name','?')
    print(f'Created skill: {nm}')
    print(f'  Edit: .k2so/skills/{nm}/SKILL.md')
except Exception as e:
    sys.stderr.write('Error: %s\n' % e); sys.exit(1)
" 2>/dev/null
            ;;
        profile)
            local name="${1:-}"
            if [ -z "$name" ]; then
                echo "Usage: k2 skills profile <name>" >&2
                exit 1
            fi
            cli_request "/cli/agents/profile" "agent=$(urlencode "$name")" | python3 -c "
import json, sys
try:
    d = json.loads(sys.stdin.read())
    if isinstance(d, dict) and 'error' in d:
        sys.stderr.write('Error: %s\n' % d['error']); sys.exit(1)
    body = d.get('profile') if isinstance(d, dict) else d
    print(body if isinstance(body, str) else json.dumps(d, indent=2))
except Exception as e:
    sys.stderr.write('Error: %s\n' % e); sys.exit(1)
" 2>/dev/null
            ;;
        remove|delete)
            local name="${1:-}"
            if [ -z "$name" ]; then
                echo "Usage: k2 skills remove <name>" >&2
                exit 1
            fi
            cli_request "/cli/agents/delete" "agent=$(urlencode "$name")" | python3 -c "
import json, sys
try:
    d = json.loads(sys.stdin.read())
    if isinstance(d, dict) and 'error' in d:
        sys.stderr.write('Error: %s\n' % d['error']); sys.exit(1)
    print('Removed skill (sent to recycle bin).')
except Exception as e:
    sys.stderr.write('Error: %s\n' % e); sys.exit(1)
" 2>/dev/null
            ;;
        regenerate)
            cli_request "/cli/skills/regenerate" | python3 -c "
import json, sys
try:
    d = json.loads(sys.stdin.read())
    if 'error' in d:
        sys.stderr.write('Error: %s\n' % d['error'])
        sys.exit(1)
    count = d.get('updated', 0)
    print('Regenerated SKILL.md for %d skill(s).' % count)
except Exception as e:
    sys.stderr.write('Error: %s\n' % e)
" 2>/dev/null
            ;;
        *)
            echo "Usage: k2 skills <list|create|profile|remove|regenerate> [options]" >&2
            echo "  list                              Skill profiles in this workspace" >&2
            echo "  create <name> [--template <src>]  Create a new skill profile" >&2
            echo "                                    (optionally seeded from another skill)" >&2
            echo "  profile <name>                    Print the skill's SKILL.md body" >&2
            echo "  remove <name>                     Trash the skill profile" >&2
            echo "  regenerate                        Refresh every skill's SKILL.md" >&2
            exit 1
            ;;
    esac
}

cmd_feed() {
    local limit="${1:-20}"
    local agent=""
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --agent) agent="$2"; shift 2 ;;
            --limit) limit="$2"; shift 2 ;;
            *) shift ;;
        esac
    done
    local params="limit=$limit"
    [[ -n "$agent" ]] && params="$params&agent=$(urlencode "$agent")"
    cli_request "/cli/feed" "$params" | python3 -c "
import json, sys, time
try:
    d = json.loads(sys.stdin.read())
    items = d.get('feed', [])
    if not items:
        print('No activity.')
    else:
        for e in items:
            ts = time.strftime('%H:%M:%S', time.localtime(e.get('at', 0)))
            agent = e.get('agent') or '-'
            etype = e.get('type', '?')
            summary = e.get('summary') or ''
            frm = e.get('from') or ''
            to = e.get('to') or ''
            route = f' ({frm}→{to})' if frm and to else ''
            print(f'  [{ts}] {agent}: {etype}{route} {summary}')
except Exception as ex:
    print(f'Error: {ex}', file=sys.stderr)
" 2>/dev/null
}

cmd_workspace_create() {
    local target_path="${1:-}"
    if [ -z "$target_path" ]; then
        echo "Usage: k2 workspace create <path>" >&2
        exit 1
    fi
    # Resolve to absolute path
    target_path="$(cd "$(dirname "$target_path")" 2>/dev/null && pwd)/$(basename "$target_path")" 2>/dev/null || target_path="$1"
    cli_request "/cli/workspace/create" "path=$(urlencode "$target_path")"
}

cmd_workspace_preview() {
    local target_path="${1:-}"
    if [ -z "$target_path" ]; then
        target_path="$PWD"
    fi
    if [ -d "$target_path" ]; then
        target_path="$(cd "$target_path" && pwd)"
    fi
    echo "Preview: what K2SO will do if you add $target_path"
    echo ""
    echo "Collision-prone files K2SO takes over (archive → import body into SKILL.md → symlink):"
    local any_found=false
    for rel in "CLAUDE.md" "GEMINI.md" "AGENT.md" ".goosehints" ".cursor/rules/k2so.mdc"; do
        if [ -f "$target_path/$rel" ] && [ ! -L "$target_path/$rel" ]; then
            # If this is K2SO's own generated file, don't report it as user content.
            if grep -q "k2so_generated: true" "$target_path/$rel" 2>/dev/null; then
                printf "  %-30s  K2SO-generated (will refresh in place)\n" "$rel"
            else
                local size
                size=$(wc -c < "$target_path/$rel" 2>/dev/null | tr -d ' ')
                printf "  %-30s  %s bytes → will archive + import\n" "$rel" "$size"
                any_found=true
            fi
        fi
    done
    if [ -f "$target_path/.aider.conf.yml" ] && [ ! -L "$target_path/.aider.conf.yml" ]; then
        if grep -q "SKILL.md" "$target_path/.aider.conf.yml" 2>/dev/null; then
            echo "  .aider.conf.yml               already references SKILL.md (no change)"
        else
            echo "  .aider.conf.yml               will archive + merge SKILL.md into read: list"
        fi
        any_found=true
    fi
    if [ "$any_found" = "false" ]; then
        echo "  (none — workspace is clean for first-time setup)"
    fi
    echo ""
    echo "Files K2SO will create fresh (no pre-existing file):"
    for rel in "SKILL.md" "CLAUDE.md" "GEMINI.md" "AGENT.md" ".goosehints" ".cursor/rules/k2so.mdc" ".aider.conf.yml" ".opencode/agent/k2so.md" ".pi/skills/k2so/SKILL.md"; do
        if [ ! -e "$target_path/$rel" ]; then
            printf "  %s\n" "$rel"
        fi
    done
    echo ""
    echo "Marker-injected (preserves your existing content, K2SO adds its own block):"
    for rel in "AGENTS.md" ".github/copilot-instructions.md"; do
        if [ -f "$target_path/$rel" ]; then
            echo "  $rel  (exists — K2SO block inserted between markers)"
        else
            echo "  $rel  (will create)"
        fi
    done
    echo ""
    echo "Nothing is destroyed — archives land in .k2so/migration/ and can be restored via"
    echo "  k2 workspace remove $target_path --mode restore-original"
}

cmd_workspace_remove() {
    local target_path="" mode=""
    while [ $# -gt 0 ]; do
        case "$1" in
            --mode) mode="$2"; shift 2 ;;
            --mode=*) mode="${1#--mode=}"; shift ;;
            -*) echo "Unknown flag: $1" >&2; exit 1 ;;
            *) if [ -z "$target_path" ]; then target_path="$1"; fi; shift ;;
        esac
    done
    if [ -z "$target_path" ]; then
        echo "Usage: k2 workspace remove <path> [--mode keep-current|restore-original]" >&2
        echo "" >&2
        echo "  --mode keep-current      Freeze the current SKILL.md body into each harness file so" >&2
        echo "                           every CLI LLM keeps working after K2SO disconnects." >&2
        echo "  --mode restore-original  Restore pre-K2SO files from .k2so/migration/; files K2SO" >&2
        echo "                           created fresh are removed. .k2so/ stays intact for reconnect." >&2
        echo "  (no mode)                DB-only deregister; filesystem untouched (symlinks remain)." >&2
        exit 1
    fi
    # Resolve to absolute path
    if [ -d "$target_path" ]; then
        target_path="$(cd "$target_path" && pwd)"
    fi
    if [ -n "$mode" ]; then
        cli_request "/cli/workspace/remove" \
            "path=$(urlencode "$target_path")" \
            "mode=$(urlencode "$mode")"
    else
        cli_request "/cli/workspace/remove" "path=$(urlencode "$target_path")"
    fi
}

cmd_workspace_open() {
    local target_path="${1:-}"
    if [ -z "$target_path" ]; then
        echo "Usage: k2 workspace open <path>" >&2
        exit 1
    fi
    # Resolve to absolute path
    if [ -d "$target_path" ]; then
        target_path="$(cd "$target_path" && pwd)"
    fi
    cli_request "/cli/workspace/open" "path=$(urlencode "$target_path")"
}

# 0.39.0f Phase 2.1b: cmd_mode orphan removed. `k2 mode` is
# hard-deprecated at dispatch; `k2 settings --mode <value>` owns
# this now.

cmd_heartbeat_toggle() {
    local action="${1:-}"
    case "$action" in
        on|enable|1|true)
            cli_request "/cli/heartbeat" "enable=on"
            echo "Heartbeat enabled."
            ;;
        off|disable|0|false)
            cli_request "/cli/heartbeat" "enable=off"
            echo "Heartbeat disabled."
            ;;
        noop)
            shift
            cmd_heartbeat_noop "$@"
            ;;
        action)
            shift
            cmd_heartbeat_action "$@"
            ;;
        force)
            # Force an immediate wake: k2 heartbeat force --agent name
            shift
            local agent=""
            while [ $# -gt 0 ]; do
                case "$1" in
                    --agent) agent="$2"; shift 2 ;;
                    *) [ -z "$agent" ] && agent="$1"; shift ;;
                esac
            done
            if [ -z "$agent" ]; then
                echo "Usage: k2 heartbeat force --agent <name>" >&2
                exit 1
            fi
            cli_request "/cli/agents/heartbeat" "agent=$(urlencode "$agent")&force_wake=1"
            echo "Force wake triggered for agent $agent"
            ;;
        set)
            # Adaptive heartbeat: k2 heartbeat set --interval 60 --phase "active" [--agent name]
            shift
            local interval="" phase="" agent="" mode="" cost_budget=""
            while [ $# -gt 0 ]; do
                case "$1" in
                    --interval) interval="$2"; shift 2 ;;
                    --phase) phase="$2"; shift 2 ;;
                    --agent) agent="$2"; shift 2 ;;
                    --mode) mode="$2"; shift 2 ;;
                    --cost-budget) cost_budget="$2"; shift 2 ;;
                    *) shift ;;
                esac
            done
            if [ -z "$agent" ]; then
                echo "Usage: k2 heartbeat set --agent <name> [--interval N] [--phase \"...\"] [--mode heartbeat|persistent]" >&2
                exit 1
            fi
            local params="agent=$(urlencode "$agent")"
            [ -n "$interval" ] && params="${params}&interval=${interval}"
            [ -n "$phase" ] && params="${params}&phase=$(urlencode "$phase")"
            [ -n "$mode" ] && params="${params}&mode=$(urlencode "$mode")"
            [ -n "$cost_budget" ] && params="${params}&cost_budget=$(urlencode "$cost_budget")"
            cli_request "/cli/agents/heartbeat" "$params" | python3 -c "
import json, sys
try:
    d = json.loads(sys.stdin.read())
    print(f'Heartbeat updated for agent:')
    print(f'  Mode:     {d.get(\"mode\", \"?\")}')
    print(f'  Phase:    {d.get(\"phase\", \"?\")}')
    print(f'  Interval: {d.get(\"intervalSeconds\", \"?\")}s')
    print(f'  Budget:   {d.get(\"costBudget\", \"?\")}')
    nw = d.get('nextWake', '')
    if nw:
        print(f'  Next wake: {nw[:19]}')
except:
    print('Error: failed to parse response', file=sys.stderr)
" 2>/dev/null
            ;;
        get)
            # Read heartbeat config: k2 heartbeat get --agent name
            shift
            local agent=""
            while [ $# -gt 0 ]; do
                case "$1" in
                    --agent) agent="$2"; shift 2 ;;
                    *) [ -z "$agent" ] && agent="$1"; shift ;;
                esac
            done
            if [ -z "$agent" ]; then
                echo "Usage: k2 heartbeat get --agent <name>" >&2
                exit 1
            fi
            cli_request "/cli/agents/heartbeat" "agent=$(urlencode "$agent")" | python3 -c "
import json, sys
try:
    d = json.loads(sys.stdin.read())
    print(f'Heartbeat config for agent:')
    print(f'  Mode:          {d.get(\"mode\", \"?\")}')
    print(f'  Phase:         {d.get(\"phase\", \"?\")}')
    print(f'  Interval:      {d.get(\"intervalSeconds\", \"?\")}s')
    print(f'  Min interval:  {d.get(\"minIntervalSeconds\", \"?\")}s')
    print(f'  Max interval:  {d.get(\"maxIntervalSeconds\", \"?\")}s')
    print(f'  Budget:        {d.get(\"costBudget\", \"?\")}')
    print(f'  Auto-backoff:  {d.get(\"autoBackoff\", \"?\")}')
    print(f'  No-ops:        {d.get(\"consecutiveNoOps\", 0)}')
    nw = d.get('nextWake', '')
    lw = d.get('lastWake', '')
    if lw: print(f'  Last wake:     {lw[:19]}')
    if nw: print(f'  Next wake:     {nw[:19]}')
except:
    print('Error: failed to parse response', file=sys.stderr)
" 2>/dev/null
            ;;
        schedule)
            # Project-level schedule management
            # k2 heartbeat schedule              → show current schedule
            # k2 heartbeat schedule off          → disable schedule
            # k2 heartbeat schedule hourly --start 09:00 --end 17:00 --every 30 --unit minutes
            # k2 heartbeat schedule daily --time 09:00
            # k2 heartbeat schedule weekly --days mon,wed,fri --time 09:00
            # k2 heartbeat schedule monthly --days 1,15 --time 09:00
            # k2 heartbeat schedule yearly --months jan,jul --time 09:00
            shift
            local sched_mode="${1:-}"
            if [ -z "$sched_mode" ]; then
                # Show current schedule
                cli_request "/cli/heartbeat/schedule" | python3 -c "
import json, sys
try:
    d = json.loads(sys.stdin.read())
    mode = d.get('mode', 'off')
    schedule = d.get('schedule')
    last_fire = d.get('lastFire', '')
    print(f'Heartbeat schedule:')
    print(f'  Mode: {mode}')
    if mode == 'off':
        print('  (disabled)')
    elif schedule:
        s = json.loads(schedule) if isinstance(schedule, str) else schedule
        if mode == 'hourly':
            secs = s.get('every_seconds', 300)
            freq = f'{secs // 3600}h' if secs >= 3600 else f'{secs // 60}m'
            print(f'  Window:    {s.get(\"start\", \"00:00\")} – {s.get(\"end\", \"23:59\")}')
            print(f'  Frequency: every {freq}')
        else:
            freq = s.get('frequency', '?')
            print(f'  Frequency: {freq}')
            print(f'  Time:      {s.get(\"time\", \"?\")}')
            if freq == 'weekly':
                print(f'  Days:      {\", \".join(s.get(\"days\", []))}')
            elif freq == 'monthly':
                dom = s.get('days_of_month', [])
                if dom:
                    print(f'  Days:      {\", \".join(str(d) for d in dom)}')
                else:
                    print(f'  Pattern:   {s.get(\"ordinal\", \"\")} {s.get(\"ordinal_day\", \"\")}')
            elif freq == 'yearly':
                print(f'  Months:    {\", \".join(s.get(\"months\", []))}')
    if last_fire:
        print(f'  Last fire: {last_fire[:19]}')
except Exception as e:
    print(f'Error: {e}', file=sys.stderr)
" 2>/dev/null
            elif [ "$sched_mode" = "off" ]; then
                cli_request "/cli/heartbeat/schedule" "mode=off"
                echo "Heartbeat schedule disabled."
            elif [ "$sched_mode" = "hourly" ]; then
                shift
                local start="09:00" end="17:00" every="30" unit="minutes"
                while [ $# -gt 0 ]; do
                    case "$1" in
                        --start) start="$2"; shift 2 ;;
                        --end) end="$2"; shift 2 ;;
                        --every) every="$2"; shift 2 ;;
                        --unit) unit="$2"; shift 2 ;;
                        *) shift ;;
                    esac
                done
                local secs
                if [ "$unit" = "hours" ]; then
                    secs=$((every * 3600))
                else
                    secs=$((every * 60))
                fi
                local schedule_json="{\"start\":\"${start}\",\"end\":\"${end}\",\"every_seconds\":${secs}}"
                cli_request "/cli/heartbeat/schedule" "mode=hourly&schedule=$(urlencode "$schedule_json")"
                echo "Heartbeat set: hourly, every ${every} ${unit}, ${start}–${end}"
            else
                # Scheduled mode: daily|weekly|monthly|yearly
                local freq="$sched_mode"
                shift
                local time="09:00" days="" months="" interval="1"
                while [ $# -gt 0 ]; do
                    case "$1" in
                        --time) time="$2"; shift 2 ;;
                        --days) days="$2"; shift 2 ;;
                        --months) months="$2"; shift 2 ;;
                        --interval) interval="$2"; shift 2 ;;
                        *) shift ;;
                    esac
                done
                # Build schedule JSON via Python for proper escaping
                local schedule_json
                schedule_json=$(python3 -c "
import json, sys
freq = '$freq'
time_str = '$time'
days_str = '$days'
months_str = '$months'
interval = int('$interval')
sched = {'frequency': freq, 'interval': interval, 'time': time_str}
if freq == 'weekly' and days_str:
    sched['days'] = [d.strip() for d in days_str.split(',')]
elif freq == 'monthly' and days_str:
    sched['days_of_month'] = [int(d.strip()) for d in days_str.split(',')]
elif freq == 'yearly' and months_str:
    sched['months'] = [m.strip() for m in months_str.split(',')]
print(json.dumps(sched))
" 2>/dev/null)
                cli_request "/cli/heartbeat/schedule" "mode=scheduled&schedule=$(urlencode "$schedule_json")"
                echo "Heartbeat set: ${freq}, at ${time}"
                [ -n "$days" ] && echo "  Days: ${days}"
                [ -n "$months" ] && echo "  Months: ${months}"
            fi
            ;;
        *)
            # No on/off/set/get → trigger triage (original behavior)
            echo "Triggering heartbeat triage..."
            cli_request "/cli/heartbeat"
            ;;
    esac
}

cmd_settings() {
    # 0.39.45 (#26c): `k2 settings --mode <value>` actually SETS the
    # workspace agent mode. The `k2 mode` deprecation pointed here,
    # but the setter was never implemented — the command printed the
    # current settings and silently ignored the flag.
    local set_mode=""
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --mode)
                set_mode="${2:-}"
                shift
                [[ $# -gt 0 ]] && shift
                ;;
            -*)
                echo "Error: unknown flag '$1' (supported: --mode <off|custom|manager|k2>)" >&2
                exit 1
                ;;
            *) shift ;;
        esac
    done
    if [[ -n "$set_mode" ]]; then
        case "$set_mode" in
            off|custom|manager|k2) ;;
            *)
                echo "Error: invalid mode '$set_mode' (valid: off, custom, manager, k2)" >&2
                exit 1
                ;;
        esac
        cli_request "/cli/mode" "set=$(urlencode "$set_mode")" | python3 -c "
import json, sys
raw = sys.stdin.read()
try:
    d = json.loads(raw)
except Exception:
    sys.stdout.write('Error: daemon returned an unparseable response: %r\n' % raw[:200])
    sys.exit(1)
if 'error' in d:
    sys.stdout.write('Error: %s\n' % d['error'])
    sys.exit(1)
print('Mode set to %s' % d.get('mode', '?'))
"
        return
    fi
    cli_request "/cli/settings" | python3 -c "
import json, sys
d = json.loads(sys.stdin.read())
print('Workspace Settings:')
print(f\"  Name:      {d.get('name', '?')}\")
print(f\"  Mode:      {d.get('mode', 'off')}\")
print(f\"  Heartbeat: {'on' if d.get('heartbeatEnabled') else 'off'}\")
print(f\"  State:     {d.get('stateId', '(none)')}\")
print(f\"  Pinned:    {'yes' if d.get('pinned') else 'no'}\")
" 2>/dev/null || cli_request "/cli/settings"
}

cmd_help() {
    # Phase 2.1: 3-tier help.
    #   k2 help                  → daily verbs (14)
    #   k2 help --advanced       → power-user verbs (heartbeat, daemon, etc.)
    #   k2 help --internal       → orchestrator-RPC verbs (rare)
    #   k2 help <verb>           → per-verb help (msg, inbox, workspace, ...)
    case "${1:-}" in
        -a|--advanced|advanced)   cmd_help_v2_advanced ;;
        --internal|internal)      cmd_help_internal ;;
        msg)                      cmd_help_msg ;;
        read)                     cmd_help_read ;;
        inbox)                    cmd_help_inbox ;;
        workspace)                cmd_help_workspace ;;
        skills)                   cmd_help_skills ;;
        settings)                 cmd_help_settings ;;
        heartbeat)                cmd_help_heartbeat ;;
        deprecated|--deprecated)  cmd_help_deprecated ;;
        '')                       cmd_help_v2_daily ;;
        *)                        cmd_help_v2_daily ;;
    esac
}

cmd_help_msg() {
    cat <<'EOF'
k2 msg <workspace> "text" [--from <name>] [--command "<text>"]

Deliver a message to a workspace's agent, live. Spawns the agent if
not already running. Returns success only when the message has landed
in the agent's session.

ARGUMENTS
  <workspace>    Workspace name (preferred — see `k2 connections list`).
                 Absolute path or project UUID also accepted.
  "text"         The message body. Wrap in quotes if it has spaces.

OPTIONS
  --from <name>  Sender identity rendered as [from <name>] prefix in
                 the recipient's session. Defaults to the sender's
                 workspace name (auto-derived from K2SO_PROJECT_PATH
                 or CWD). Falls back to `external` if neither resolves.
  --command "<text>"
                 Prepend <text> at the VERY FRONT of the delivered
                 message, BEFORE the [from <name>] prefix. Intended for
                 slash-commands so the recipient's agent runs them on the
                 incoming line — e.g. --command "/loop" delivers:
                     /loop [from <name>] <text>
                 Omitted (the default) leaves delivery unchanged:
                     [from <name>] <text>

RESPONSE (JSON)
  {
    "success": true,
    "target_session_id": "<uuid>",
    "attempts": 1
  }

  Failures include `reason` + `hint` fields:
    workspace_not_found  — token didn't match a registered workspace
    no_agent_mode        — workspace has no agent (use `work send`)
    spawn_failed         — couldn't start the recipient's agent
    pty_died             — target session crashed during delivery

  Exit code 0 on success, 2 on any failure.

LENGTH LIMIT — IMPORTANT
  Live `msg` is injected into the recipient's running input line (as if
  typed, then Enter). It is for SHORT, single-line messages. Long or
  multi-line text gets TRUNCATED or mangled by the recipient's terminal
  input widget (Claude Code / Codex / Gemini all treat fast-arriving
  bytes as a paste with input-buffer limits). For anything longer than a
  sentence or two — task briefs, file contents, structured/multi-line
  content — use the inbox instead, which has no length limit and the
  recipient reads on their own schedule:

      k2 msg <workspace> --inbox --title "..." --body "..."

RELATED VERBS
  k2 msg <workspace> --inbox --title "..." --body "..."
      Queue a task (email-style) — recipient reads on their own
      schedule, no length limit. Use for anything long or non-urgent.

  k2 read <workspace> [--lines N] [--agent <name>]
      Read the recipient's live terminal (peek before you inject, or
      diagnose a stuck agent). The read complement to `msg`.

  k2 terminal write <id> "text"      (see `k2 help --advanced`)
      Raw bytes to any PTY by terminal ID. Power-user escape hatch
      for cases where `msg`'s workspace-keyed routing isn't what you
      want. `target_session_id` from a `msg` response is a valid <id>.

EXAMPLES
  k2 msg scout_v3 "ready when you are"
  k2 msg scout_v3 "ping" --from sms-bridge
  k2 msg scout_v3 "keep going" --command "/loop"
  k2 msg /Users/me/work/MyApp "deploy completed"
EOF
}

cmd_help_read() {
    cat <<'EOF'
k2 read <workspace> [--lines N] [--agent <name>]

Read another workspace's LIVE terminal — the last N lines of plain
text from its running session. The "read" complement to the messaging
verbs: `msg` talks live, `inbox` is mail, `read` looks over their
shoulder. Addresses by workspace NAME exactly like `msg`.

Use it to PEEK BEFORE YOU INJECT (human-in-the-loop: see what an agent
is doing / waiting on before sending it a message), or to diagnose a
stuck or quiet agent.

ARGUMENTS
  <workspace>    Workspace name (preferred — see `k2 connections list`).
                 Absolute path or project UUID also accepted. Same
                 addressing as `k2 msg`.

OPTIONS
  --lines N      How many trailing lines to return. Default 50.
  --agent <name> Read a specific agent's session in the workspace.
                 Omit to read the workspace's primary (coordinator)
                 session — the usual target.

OUTPUT
  Plain text, one terminal row per line (most recent at the bottom).
  Styling / cursor are stripped — this is for reading, not rendering.
  Exit code 0 on success; non-zero with a hint if the workspace isn't
  found or has no live session (it may be asleep — see
  `k2 sessions live <workspace>`).

RELATED VERBS
  k2 msg <workspace> "text"          Talk to the agent live.
  k2 msg <ws> --inbox --title ...    Queue a task (email-style).
  k2 sessions live <workspace>       List a workspace's live sessions.

EXAMPLES
  k2 read scout_v3                   # last 50 lines of its coordinator
  k2 read scout_v3 --lines 120       # more history
  k2 read MyApp --agent frontend-eng # a specific agent's session
EOF
}

# 0.39.0f Phase 2.1b: cmd_help_general / cmd_help_advanced (the old
# pre-2.1a help screens) removed. The 3-tier help system
# (cmd_help_v2_daily / cmd_help_v2_advanced / cmd_help_internal +
# cmd_help_msg / cmd_help_inbox) owns all help text now.

# ── Self-Update ──────────────────────────────────────────────────────

K2SO_CLI_RAW_URL="https://raw.githubusercontent.com/Alakazam-211/K2SO/main/cli/k2so"

cmd_app_update() {
    local version="${1:-}"
    if [ "$version" = "--list" ] || [ "$version" = "-l" ]; then
        # List available versions from GitHub
        echo "Available K2SO releases:"
        curl -sH "Accept: application/vnd.github.v3+json" \
            "https://api.github.com/repos/Alakazam-211/K2SO/releases?per_page=10" 2>/dev/null | \
            python3 -c "
import json, sys
data = json.loads(sys.stdin.read())
for r in data:
    tag = r.get('tag_name', '?')
    date = r.get('published_at', '')[:10]
    pre = ' (pre-release)' if r.get('prerelease') else ''
    print(f'  {tag}  {date}{pre}')
" 2>/dev/null
        return
    fi

    if [ -z "$version" ]; then
        # Check for latest and show info
        echo "Checking for app updates..."
        local latest
        latest=$(curl -sH "Accept: application/vnd.github.v3+json" \
            "https://api.github.com/repos/Alakazam-211/K2SO/releases/latest" 2>/dev/null)
        local tag
        tag=$(echo "$latest" | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('tag_name','unknown'))" 2>/dev/null)
        echo "  Current: v$K2_CLI_VERSION"
        echo "  Latest:  $tag"
        echo ""
        echo "To update the app, download from:"
        echo "  https://github.com/Alakazam-211/K2SO/releases/tag/$tag"
        echo ""
        echo "Or use: k2 app-update --list  to see all versions"
        return
    fi

    # Download specific version
    local tag="$version"
    # Add 'v' prefix if not present
    [[ "$tag" != v* ]] && tag="v$tag"
    local dmg_url="https://github.com/Alakazam-211/K2SO/releases/download/${tag}/K2SO_${tag#v}_aarch64.dmg"
    echo "Downloading K2SO $tag..."
    echo "  URL: $dmg_url"
    local tmpfile="/tmp/K2SO_${tag}.dmg"
    if curl -L --progress-bar -o "$tmpfile" "$dmg_url" 2>&1; then
        echo ""
        echo "Downloaded to: $tmpfile"
        echo "Opening DMG..."
        open "$tmpfile"
    else
        echo "Error: Failed to download. Check the version tag exists." >&2
        exit 1
    fi
}

cmd_update() {
    local check_only=0
    while [ $# -gt 0 ]; do
        case "$1" in
            --check|-c) check_only=1; shift ;;
            *) shift ;;
        esac
    done

    echo "Checking for updates..."

    # Download the latest version string from GitHub
    local remote_script
    remote_script=$(curl -sS --connect-timeout 5 --max-time 15 "$K2SO_CLI_RAW_URL" 2>/dev/null)
    if [ -z "$remote_script" ]; then
        echo "Error: Could not reach GitHub to check for updates." >&2
        exit 1
    fi

    # Extract the version from the remote script
    local remote_version
    remote_version=$(echo "$remote_script" | grep -m1 '^K2_CLI_VERSION=' | sed 's/K2_CLI_VERSION="0.40.2"//')
    if [ -z "$remote_version" ]; then
        echo "Error: Could not parse remote version." >&2
        exit 1
    fi

    echo "  Installed: $K2_CLI_VERSION"
    echo "  Latest:    $remote_version"

    # Compare versions (simple string comparison works for semver with same format)
    if [ "$K2_CLI_VERSION" = "$remote_version" ]; then
        echo "Already up to date."
        exit 0
    fi

    # Check if remote is actually newer (not a downgrade)
    local newer
    newer=$(printf '%s\n%s\n' "$K2_CLI_VERSION" "$remote_version" | sort -V | tail -1)
    if [ "$newer" = "$K2_CLI_VERSION" ]; then
        echo "Installed version is newer than remote (dev build?)."
        exit 0
    fi

    if [ "$check_only" = "1" ]; then
        echo "Update available: $K2_CLI_VERSION → $remote_version"
        echo "Run 'k2 update' to install."
        exit 0
    fi

    # Find where k2 is installed
    local install_path
    install_path=$(which k2 2>/dev/null)
    if [ -z "$install_path" ]; then
        echo "Error: Could not find k2 in PATH." >&2
        exit 1
    fi

    # Check if we can write to the install path
    if [ ! -w "$install_path" ]; then
        echo "Updating $install_path (requires sudo)..."
        echo "$remote_script" | sudo tee "$install_path" > /dev/null
        sudo chmod +x "$install_path"
    else
        echo "Updating $install_path..."
        echo "$remote_script" > "$install_path"
        chmod +x "$install_path"
    fi

    echo "Updated k2: $K2_CLI_VERSION → $remote_version"
}

# ── Onboarding (workspace-add three-option flow) ─────────────────────
#
# Thin shell wrappers around `/cli/onboarding/*` daemon routes. Logic
# lives in k2so-core::agents::onboarding so the Tauri modal and the
# CLI share a single implementation. Project path defaults to $PWD.

# k2 onboarding scan [project-path]
#   List workspace harness files (CLAUDE.md, GEMINI.md, .cursor/rules,
#   etc.) that have substantive user content. Empty result == no
#   prompt needed; a non-empty result is what the GUI modal shows in
#   the "Adopt" picker.
cmd_onboarding_scan() {
    local target="${1:-$PWD}"
    target="$(cd "$target" && pwd)"
    cli_request "/cli/onboarding/scan" "project=$(urlencode "$target")"
}

# k2 onboarding adopt <relative-or-absolute-path> [project-path]
#   Adopt the named harness file as the seed for .k2so/PROJECT.md and
#   trigger a workspace SKILL regen. The source file is archived to
#   .k2so/migration/ and removed (so the regen's existing migration
#   helpers don't double-import the same body).
cmd_onboarding_adopt() {
    local source="${1:-}"
    local target="${2:-$PWD}"
    if [ -z "$source" ]; then
        echo "Usage: k2 onboarding adopt <file> [project-path]" >&2
        echo "Run 'k2 onboarding scan' to see candidate files." >&2
        exit 1
    fi
    target="$(cd "$target" && pwd)"
    # Resolve to absolute path if user passed a relative one
    if [[ "$source" != /* ]]; then
        source="$target/$source"
    fi
    cli_request "/cli/onboarding/adopt" \
        "project=$(urlencode "$target")&source=$(urlencode "$source")"
}

# k2 onboarding later [project-path]
#   "Do it later" — drops .k2so/.skip-harness-management so K2SO won't
#   touch CLAUDE.md / GEMINI.md / .cursor/rules / etc. on subsequent
#   regens. K2SO's internal SKILL still gets written so heartbeats and
#   agent launches keep working. Reversible by removing the flag.
cmd_onboarding_later() {
    local target="${1:-$PWD}"
    target="$(cd "$target" && pwd)"
    cli_request "/cli/onboarding/skip" "project=$(urlencode "$target")"
}

# k2 onboarding fresh [project-path]
#   Run the workspace regen normally, archiving any pre-existing
#   harness files to .k2so/migration/ and replacing them with K2SO
#   symlinks. Equivalent to the renderer's "Start Fresh" button.
cmd_onboarding_fresh() {
    local target="${1:-$PWD}"
    target="$(cd "$target" && pwd)"
    cli_request "/cli/onboarding/start-fresh" "project=$(urlencode "$target")"
}

# ── Phase 2.1: Deprecation helpers ────────────────────────────────────

# Print a one-line deprecation warning. Use for soft-deprecation
# (old verb still works; forward to the new form).
warn_deprecated() {
    local old_verb="$1"
    local new_verb="$2"
    echo "warning: '$old_verb' is deprecated; use '$new_verb' instead." >&2
}

# Hard-deprecation: print pointer to new equivalent + help-deprecated.
# Exits non-zero so scripts notice.
fail_deprecated() {
    local old_verb="$1"
    local new_form="$2"
    cat >&2 <<EOF
Error: '$old_verb' is no longer supported.

Use instead:
  $new_form

Run 'k2 help-deprecated' for the full map of retired verbs.
EOF
    exit 1
}

# ── Phase 2.1: Inbox verbs (A22) ──────────────────────────────────────

# Pretty-print a list of inbox items received as JSON on stdin.
format_inbox() {
    python3 -c "
import json, sys
try:
    data = json.loads(sys.stdin.read() or '[]')
except Exception as e:
    sys.stderr.write('failed to parse inbox JSON: %s\n' % e)
    sys.exit(1)
if not data:
    print('No items.')
    sys.exit(0)
for item in data:
    folder = item.get('folder', '') or '(top)'
    priority = item.get('priority', 'normal')
    title = item.get('title') or item.get('filename', '')
    iid = item.get('id', '?')
    prio_mark = {'high': '!', 'critical': '!!', 'normal': ' ', 'low': '.'}
    mark = prio_mark.get(priority, ' ')
    print(f'  {iid:<24} [{folder:<10}] {mark} {title}')
" 2>/dev/null || cat
}

cmd_inbox_list() {
    local folder=""
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --folder) folder="$2"; shift 2 ;;
            -*) shift ;;
            *) [[ -z "$folder" ]] && folder="$1"; shift ;;
        esac
    done
    local params=""
    [[ -n "$folder" ]] && params="folder=$(urlencode "$folder")"
    if [[ -n "$params" ]]; then
        cli_request "/cli/inbox/list" "$params" | format_inbox
    else
        cli_request "/cli/inbox/list" | format_inbox
    fi
}

cmd_inbox_read() {
    local id="${1:-}"
    if [[ -z "$id" ]]; then
        echo "Usage: k2 inbox read <id>" >&2
        exit 1
    fi
    cli_request "/cli/inbox/read" "id=$(urlencode "$id")" | python3 -c "
import json, sys
try:
    d = json.loads(sys.stdin.read())
    if 'error' in d:
        sys.stderr.write('Error: %s\n' % d['error'])
        sys.exit(1)
    print(d.get('content', ''))
except Exception as e:
    sys.stderr.write('parse: %s\n' % e)
    sys.exit(1)
" 2>/dev/null
}

cmd_inbox_compose() {
    local title="" body="" priority="" source="" from=""
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --title) title="$2"; shift 2 ;;
            --body) body="$2"; shift 2 ;;
            --priority) priority="$2"; shift 2 ;;
            --source) source="$2"; shift 2 ;;
            --from) from="$2"; shift 2 ;;
            *) shift ;;
        esac
    done
    if [[ -z "$title" ]]; then
        echo "Usage: k2 inbox compose --title \"...\" [--body \"...\"] [--priority H|N|L]" >&2
        exit 1
    fi
    local params="title=$(urlencode "$title")"
    [[ -n "$body" ]] && params="${params}&body=$(urlencode "$body")"
    [[ -n "$priority" ]] && params="${params}&priority=$(urlencode "$priority")"
    [[ -n "$source" ]] && params="${params}&source=$(urlencode "$source")"
    [[ -n "$from" ]] && params="${params}&from=$(urlencode "$from")"
    # 0.39.45 (#35/#37): real POST body. The old `curl -sG -X POST` put
    # everything on the URL, where the daemon's request-head cap silently
    # clipped bodies at ~2.7KB — corrupting the durable inbox record.
    cli_post_form "/cli/inbox/compose" "$params"
    echo
}

cmd_inbox_respond() {
    local id="${1:-}" text="${2:-}"
    if [[ -z "$id" || -z "$text" ]]; then
        echo "Usage: k2 inbox respond <id> \"text\"" >&2
        exit 1
    fi
    curl -sG -X POST "${BASE_URL}/cli/inbox/respond" \
        --connect-timeout 5 --max-time 30 \
        -d "token=${TOKEN}&project=$(urlencode "$PROJECT")&id=$(urlencode "$id")&text=$(urlencode "$text")" 2>/dev/null
    echo
}

cmd_inbox_move() {
    local id="${1:-}" folder="${2:-}"
    if [[ -z "$id" || -z "$folder" ]]; then
        echo "Usage: k2 inbox move <id> <folder>" >&2
        exit 1
    fi
    curl -sG -X POST "${BASE_URL}/cli/inbox/move" \
        --connect-timeout 5 --max-time 30 \
        -d "token=${TOKEN}&project=$(urlencode "$PROJECT")&id=$(urlencode "$id")&folder=$(urlencode "$folder")" 2>/dev/null
    echo
}

cmd_inbox_archive() {
    local id="${1:-}"
    if [[ -z "$id" ]]; then
        echo "Usage: k2 inbox archive <id>" >&2
        exit 1
    fi
    curl -sG -X POST "${BASE_URL}/cli/inbox/archive" \
        --connect-timeout 5 --max-time 30 \
        -d "token=${TOKEN}&project=$(urlencode "$PROJECT")&id=$(urlencode "$id")" 2>/dev/null
    echo
}

cmd_inbox_delete() {
    local id="${1:-}"
    if [[ -z "$id" ]]; then
        echo "Usage: k2 inbox delete <id>" >&2
        exit 1
    fi
    curl -sG -X POST "${BASE_URL}/cli/inbox/delete" \
        --connect-timeout 5 --max-time 30 \
        -d "token=${TOKEN}&project=$(urlencode "$PROJECT")&id=$(urlencode "$id")" 2>/dev/null
    echo
}

cmd_inbox_search() {
    local q="${1:-}"
    if [[ -z "$q" ]]; then
        echo "Usage: k2 inbox search \"query\"" >&2
        exit 1
    fi
    cli_request "/cli/inbox/search" "q=$(urlencode "$q")" | format_inbox
}

cmd_inbox_folders() {
    cli_request "/cli/inbox/folders" | python3 -c "
import json, sys
try:
    folders = json.loads(sys.stdin.read() or '[]')
except Exception:
    folders = []
if not folders:
    print('No folders yet. Create one by moving an item:')
    print('  k2 inbox move <id> projects')
else:
    for f in folders:
        print('  ' + f)
" 2>/dev/null
}

cmd_inbox() {
    # `k2 inbox`            → list top-level (default subcommand)
    # `k2 inbox <sub>`      → dispatch to subcommand
    if [ $# -eq 0 ]; then
        cmd_inbox_list
        return
    fi
    local sub="$1"
    case "$sub" in
        list)     shift; cmd_inbox_list "$@" ;;
        read)     shift; cmd_inbox_read "$@" ;;
        compose)  shift; cmd_inbox_compose "$@" ;;
        respond)  shift; cmd_inbox_respond "$@" ;;
        move)     shift; cmd_inbox_move "$@" ;;
        archive)  shift; cmd_inbox_archive "$@" ;;
        delete)   shift; cmd_inbox_delete "$@" ;;
        search)   shift; cmd_inbox_search "$@" ;;
        folders)  cmd_inbox_folders ;;
        --help|-h) cmd_help_inbox ;;
        -*|*)
            # If it looks like a flag, treat as `inbox list <flags>`.
            cmd_inbox_list "$@"
            ;;
    esac
}

# ── Phase 2.1: Glossary (A10) ─────────────────────────────────────────

cmd_glossary() {
    local term="${1:-}"
    if [[ -z "$term" ]]; then
        # List all terms.
        echo "K2 glossary — definitions of K2-specific terms"
        echo ""
        cli_request "/cli/glossary/list" | python3 -c "
import json, sys
try:
    entries = json.loads(sys.stdin.read() or '[]')
except Exception:
    entries = []
if not entries:
    print('  (no terms returned)')
    sys.exit(0)
for e in entries:
    term = e.get('term', '?')
    summary = e.get('summary', '')
    print('  %-16s %s' % (term, summary))
print()
print('Run \`k2 glossary <term>\` for a full definition.')
" 2>/dev/null
        return
    fi
    cli_request "/cli/glossary/get" "term=$(urlencode "$term")" | python3 -c "
import json, sys
try:
    d = json.loads(sys.stdin.read())
except Exception as e:
    sys.stderr.write('parse: %s\n' % e)
    sys.exit(1)
if 'error' in d:
    sys.stderr.write('Error: %s\n' % d['error'])
    sys.exit(1)
term = d.get('term', '?')
definition = d.get('definition', '')
print('%-16s %s' % (term, definition))
" 2>/dev/null
}

# ── Phase 2.1: Renames (feed→activity, roster→who) ────────────────────

cmd_activity() {
    # 0.39.0f Phase 2.1: feed → activity (PRD A5).
    # Same endpoint; renamed at the CLI layer.
    cmd_feed "$@"
}

cmd_who() {
    # 0.39.0 (workspace==agent model): `who` collapses into
    # `connections list`. The Phase-2.1 rename (roster → who) was a
    # naming pass; the model-cleanup happened in 0.39.0 when the
    # roster's data source moved from `.k2so/agents/` filesystem
    # walks to `crate::connections::list_peers`. With the data
    # source aligned, the verb itself collapses too — `who` and
    # `connections list` answer the same question.
    fail_deprecated "who" "k2 connections list"
}

# ── Phase 2.1: help text helpers ──────────────────────────────────────

cmd_help_inbox() {
    cat <<'EOF'
k2 inbox [<subcommand>] [options]

The workspace's email-like communication channel. Items arrive from
other workspaces (via `k2 msg --inbox`) or are composed by your own
agent (via `k2 inbox compose`). Read, triage, file into folders.

SUBCOMMANDS
  (none)                    Show inbox items (= `inbox list`)
  list [<folder>]           List items in inbox (or a specific folder)
  read <id>                 Full text of one item
  compose --title "..." --body "..."
                            Write your own inbox item (self-note / task)
  respond <id> "text"       Reply (back to sender)
  move <id> <folder>        File into a folder (creates folder if needed)
  archive <id>              Standard archive (preserved + searchable; goes to inbox/done/)
  delete <id>               Move to macOS Recycle Bin (recoverable from Trash)
                            Use `inbox archive` for "done but keep around";
                            use `inbox delete` only for "actually remove this."
  search "query"            Search inbox + all folders
  folders                   List folders this workspace has created

ORGANIZING YOUR INBOX
  K2SO doesn't impose a folder taxonomy. Organize your inbox the way
  you'd organize email — create folders that fit your workflow:
    inbox move <id> projects/    # active work items
    inbox move <id> reference/   # things to remember
    inbox move <id> issues/      # problems to address
    inbox move <id> done/        # completed work
  Folders are auto-created on first `inbox move` into them.

EXAMPLES
  k2 inbox                                  # show top-level inbox
  k2 inbox list projects                    # what's in projects/
  k2 inbox read 42                          # full text of item 42
  k2 inbox move 42 projects                 # file 42 into projects/
  k2 inbox compose --title "audit auth" --body "..."   # self-note
  k2 inbox respond 42 "thanks, looking into this"
  k2 inbox archive 42                       # done with this item
  k2 inbox search "oauth"                   # find all oauth-related

RECEIVING FROM OTHER WORKSPACES
  Other workspaces send to your inbox with:
    k2 msg <your-workspace> --inbox --title "..." --body "..."
  Their items appear in your inbox alongside your own composes.

LEGACY ITEMS
  Pre-Phase-2.1 work items at .k2so/work/{inbox,active,done}/ have
  been migrated to .k2so/inbox/{,active,done}/. If migration didn't
  run yet, trigger it manually: `k2 inbox` will reveal nothing
  until the daemon has converted the layout.
EOF
}

cmd_help_workspace() {
    cat <<'EOF'
k2 workspace <subcommand> [options]

Manage workspaces (project folders K2SO knows about).

DAILY SUBCOMMANDS
  list                              Yellow pages: every workspace + status
  list --running                    Filter to live agents
  launch [<path>]                   Smart cascade: attach|wake|spawn
  profile [<path>]                  Read workspace agent's AGENT.md
  update --field <f> --value <v>    Edit workspace agent profile

LIFECYCLE (less common)
  create <path>                     Create folder + register
  open <path>                       Register existing folder
  remove <path>                     Deregister (files stay)
  triage                            Plain-text summary of pending work

`<path>` defaults to K2SO_PROJECT_PATH or PWD.

EXAMPLES
  k2 workspace list
  k2 workspace launch
  k2 workspace launch /Users/me/Projects/k2so
  k2 workspace profile
  k2 workspace update --field role --value "frontend lead"
EOF
}

cmd_help_skills() {
    cat <<'EOF'
k2 skills <subcommand> [options]

Manage documentation profiles (skills) for the workspace. Skills are
markdown files (SKILL.md) describing roles, personas, and instructions
your harness loads when spawning sub-agents.

  list                              Skill profiles in this workspace
  create <name> [--template <src>]  Create a new skill profile
                                    (optionally seeded from another skill)
  remove <name>                     Delete a skill profile (sent to Trash)
  profile <name>                    Read the skill's SKILL.md
  regenerate                        Refresh every skill's SKILL.md

SKILLS vs AGENT
  An agent IS a workspace (1:1). A skill is a documentation profile
  the agent (or your harness) can reference when applying a specific
  role to work.

  Spawning sessions pre-loaded with a skill is your harness's job
  (Claude Code's sub-agent spawning, Cursor's worktree management).
  K2SO no longer provides a `delegate` verb — see `k2 help-deprecated`.

  Skills live at `.k2so/skills/<name>/SKILL.md` (consolidated home as of
  Phase 2.5b). The legacy `.k2so/agents/` and `.k2so/agent-templates/`
  folders are migrated into here on first daemon boot per upgraded
  workspace. Originals go to the macOS Recycle Bin (recoverable).

TEMPLATING (post-Phase-2.5b)
  Any existing skill can seed a new one — there is no separate
  "template" namespace. `--template <name>` means "copy this skill as
  the starting point for the new skill." Edit the new file freely from
  there; the seed is unaffected.

EXAMPLES
  k2 skills list
  k2 skills create backend-eng --template rust-eng
  k2 skills profile backend-eng
  k2 skills regenerate
EOF
}

cmd_help_settings() {
    cat <<'EOF'
k2 settings [options]

Show or modify workspace settings. Without flags: prints current settings.

WORKSPACE MODE
  --mode <off|agent|manager>
                            off: K2SO ignores this workspace
                            agent: workspace has a primary agent
                            manager: workspace coordinates other workspaces

WORKSPACE STATE (capability tier)
  --state <build|managed|maintenance|locked>
                            Drives which actions the agent can take
                            autonomously. See `k2 glossary state`.

GLOBAL TOGGLES
  --agentic <on|off>        Enable/disable all background agent systems

COMPANION (mobile + remote-desktop access)
  --companion <on|off>      Enable/disable companion server (ngrok tunnel)
  --companion-password "<pw>" Set companion auth password

EXAMPLES
  k2 settings                              # show current
  k2 settings --mode manager
  k2 settings --state managed
  k2 settings --agentic off
  k2 settings --companion on
EOF
}

cmd_help_heartbeat() {
    cat <<'EOF'
k2 heartbeat [<family> <subcommand>]

Manage workspace-scoped scheduled wakes.

DEFAULT (no args)
  k2 heartbeat                                Lists active heartbeat schedules
                                                (= `heartbeat schedule list`)

THREE FAMILIES:

SCHEDULE (CRUD on heartbeat definitions)
  heartbeat schedule add --name <n> <spec>
       <spec> is one of:
         --daily --time HH:MM
         --weekly --days mon,wed,fri --time HH:MM
         --monthly --days 1,15 --time HH:MM
         --yearly --months jan,jul --time HH:MM
         --hourly --start 09:00 --end 17:00 --every 30 --unit minutes
  heartbeat schedule list [--archived]
  heartbeat schedule remove <n> [--purge]
  heartbeat schedule unarchive <n>
  heartbeat schedule edit <n> <new-spec>
  heartbeat schedule rename <old> <new>
  heartbeat schedule enable <n>                  Resume scheduling
  heartbeat schedule disable <n>                 Pause scheduling

SIGNAL (immediate one-shot triggers)
  heartbeat signal fire <n>      Fire one heartbeat now (skips schedule window)
  heartbeat signal wakeup <n>    Print/edit the WAKEUP.md
  heartbeat signal wake          Auto-wake (no name needed)

INSPECTION (read-only)
  heartbeat show <n> [--json]    Single heartbeat details
  heartbeat status <n> [-n N]    Recent fire history
  heartbeat log [-n N]           Scheduler decisions

EXAMPLES
  k2 heartbeat schedule add --name deploy-check --daily --time 09:00
  k2 heartbeat schedule list
  k2 heartbeat schedule disable deploy-check
  k2 heartbeat signal fire deploy-check
  k2 heartbeat log
EOF
}

cmd_help_deprecated() {
    cat <<'EOF'
K2SO retired verbs → new equivalents

HARD-DEPRECATED (verb removed; commands exit non-zero with pointer)

  agentic on|off                  → settings --agentic <on|off>
  state list|get|set              → settings --state <id>
  mode off|agent|manager          → settings --mode <value>
  app-update                      → update --app
  commit-merge                    → commit --merge
  companion                       → daemon companion
  whatsnew                        → whats-new
  roster                          → connections list  (0.39.0)
  who                             → connections list  (0.39.0)
  feed                            → activity
  signal <target> <kind> <payload> → msg <workspace> --signal <kind> [...]
  status "msg"                    → checkin --status "msg"
  agents reap                     → daemon reap
  agents triage                   → workspace triage
  delegate <agent> <file>         → Your harness handles worktree+spawn now
                                     (Claude Code sub-agent, Cursor worktree,
                                     Tauri Cmd+T). K2SO no longer manages
                                     the spawn lifecycle. For skill profile
                                     content, use `k2 skills profile <name>`
                                     and load it into your harness.

  # 0.39.0f Phase 2.1b — these were soft-deprecated in 2.1a (warn +
  # forward) and hard-cut in 2.1b. No transition window remains; every
  # arm exits non-zero.

  agents create                   → skills create <name>
  agents delete                   → skills remove <name> (or `workspace remove`)
  agents list                     → workspace list (yellow pages)
                                       or `skills list` (skill profiles)
  agents work <n>                 → inbox  (workspace-implicit; pass
                                       --workspace <path> to target another)
  agents lock <n>                 → skills lock <name> (debugging only)
  agents unlock <n>               → skills lock <name> --release
  agents launch <n>               → your IDE/harness's session-start
                                       feature; K2SO no longer manages spawn
  agents profile <n>              → skills profile <name>
                                       (or `workspace profile`)
  agents status <n>               → workspace status (or `checkin --status`)
  agents generate-md              → skills regenerate [<name>]
  agents running                  → workspace list --running

  work create --title --body      → inbox compose --title "..." --body "..."
  work inbox                      → inbox (default verb shows new arrivals)
  work move --from <a> --to <b>   → inbox move <id> <folder>
  work done                       → inbox archive <id>
  work send <ws> --title --body   → msg <ws> --inbox --title "..." --body "..."

  --agent <name> flag             → --workspace <path>  (everywhere)
                                       Hard-removed; pass workspace explicitly.

EXAMPLES OF MIGRATION

  # Sending a message
  Old: k2 msg --agent scout_v3 "hello"
  New: k2 msg scout_v3 "hello"            # workspace-implicit; --agent removed

  # Sending a typed signal
  Old: k2 signal scout_v3 status "deploying"
  New: k2 msg scout_v3 --signal status --payload '{"text":"deploying"}'

  # Queueing work to another workspace
  Old: k2 work send my_other_ws --title "task" --body "do this"
  New: k2 msg my_other_ws --inbox --title "task" --body "do this"

  # Creating your own task / note
  Old: k2 work create --title "audit auth" --body "..."
  New: k2 inbox compose --title "audit auth" --body "..."

  # Marking a task done
  Old: k2 done --blocked "Stripe API access"
  New: k2 checkin --done --blocked "Stripe API access"   (or just `done`)

  # Listing tasks
  Old: k2 work inbox
  New: k2 inbox                            # default action lists inbox

  # Filing a completed item
  Old: k2 work move --file <f> --from active --to done
  New: k2 inbox archive <id>               # archives by id (folder-agnostic)

  # Creating a sub-agent
  Old: k2 agents create backend-eng
  New: k2 skills create backend-eng        # skill is documentation; your
                                             # harness spawns the agent

  # Spawning a sub-agent on a work item
  Old: k2 delegate backend-eng .k2so/agents/backend-eng/work/inbox/task.md
  New: Use your harness's sub-agent feature (Claude Code, Cursor) to spawn
       a worktree-based session. Reference the skill via
       `k2 skills profile backend-eng` and load into the harness's context.
EOF
}

cmd_help_internal() {
    cat <<'EOF'
K2SO CLI — Internal surface (rare; used by agent runtimes)

These verbs exist for orchestrators and tools that integrate with K2SO.
Humans rarely run them directly.

TERMINAL (raw PTY I/O)
  terminal spawn --command "..."                Spawn a sub-terminal
  terminal write <id> "text"                    Paste + Enter to PTY by id
  terminal read <id> [--lines N]                Last N lines from buffer

SESSIONS (low-level session lifecycle)
  sessions spawn --agent <n>                    Session Stream spawn
  sessions list [--json]                        Raw session map
  sessions live                                 Subscribe to session events
  sessions compact                              Compact archive ring

AGENT (single-item ops on the workspace's primary agent)
  agent profile [<path>]                        Equivalent to `workspace profile`
  agent update --field <f> --value <v>          Equivalent to `workspace update`
  agent complete --file <f>                     Mark work item complete

HOOKS (Claude Code / Cursor integration state)
  hooks status [--limit N] [--json]             Hook pipeline state
                                                (same as `daemon hooks status`)

For "what's running" use `workspace list --running` (daily tier).
For workspace-scoped ops use the `workspace` verb (daily tier).
EOF
}

# Phase 2.1 — workspace launch (smart cascade placeholder).
# Calls existing /cli/workspace/ensure-canonical-session which provides
# the attach|wake|spawn semantics the mock describes.
cmd_workspace_launch() {
    local target="${1:-$PROJECT}"
    local json="false" no_attach="false"
    shift 2>/dev/null || true
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --json) json="true"; shift ;;
            --no-attach) no_attach="true"; shift ;;
            *) shift ;;
        esac
    done
    if [[ ! -d "$target" ]]; then
        # Possibly a workspace name; resolve via daemon.
        local resolved
        resolved=$(cli_request "/cli/workspace/resolve" "q=$(urlencode "$target")" 2>/dev/null \
            | python3 -c 'import json,sys
try: print(json.load(sys.stdin).get("path",""))
except: pass' 2>/dev/null)
        [[ -n "$resolved" ]] && target="$resolved"
    fi
    cli_request "/cli/workspace/ensure-canonical-session" "project=$(urlencode "$target")"
}

cmd_workspace_profile() {
    local target="${1:-$PROJECT}"
    cli_request "/cli/workspace/agent-display-name" "project=$(urlencode "$target")"
}

cmd_workspace_update() {
    local field="" value=""
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --field) field="$2"; shift 2 ;;
            --value) value="$2"; shift 2 ;;
            *) shift ;;
        esac
    done
    if [[ -z "$field" || -z "$value" ]]; then
        echo "Usage: k2 workspace update --field <f> --value <v>" >&2
        exit 1
    fi
    # Forward to agent profile update (workspace agent IS the workspace).
    # The agent name is the workspace's primary agent — daemon resolves it.
    local agent_name
    agent_name=$(cli_request "/cli/workspace/agent-display-name" 2>/dev/null \
        | python3 -c 'import json,sys
try: print(json.load(sys.stdin).get("display_name",""))
except: pass' 2>/dev/null)
    if [[ -z "$agent_name" ]]; then
        # 0.39.0f: workspace folder basename is the canonical fallback
        # for the workspace's primary agent name (matches the post-
        # unification invariant: workspace == agent). Replaces the
        # pre-unification `__lead__` sentinel that the daemon no
        # longer recognizes as a routing key.
        agent_name="$(basename "$PROJECT")"
    fi
    cli_request "/cli/agent/update" \
        "agent=$(urlencode "$agent_name")" \
        "field=$(urlencode "$field")" \
        "value=$(urlencode "$value")"
}

cmd_workspace_list() {
    # 0.39.45 (#26d): this used to hit /cli/workspaces/list, which is a
    # PER-PROJECT branch-workspace lookup requiring project_id — the
    # call always got `{"error": "Missing 'project_id'..."}` back, the
    # python renderer crashed iterating a dict, stderr was swallowed by
    # 2>/dev/null, and the command exited 1 with no output. "Workspace"
    # at the CLI surface means a registered project: list those.
    local running="false"
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --running) running="true"; shift ;;
            *) shift ;;
        esac
    done
    cli_request "/cli/projects/list" | RUNNING_ONLY="$running" python3 -c "
import json, os, sys
raw = sys.stdin.read()
try:
    rows = json.loads(raw)
except Exception:
    sys.stdout.write('Error: daemon returned an unparseable response: %r\n' % raw[:200])
    sys.exit(1)
if isinstance(rows, dict):
    sys.stdout.write('Error: %s\n' % rows.get('error', raw[:200]))
    sys.exit(1)
if os.environ.get('RUNNING_ONLY') == 'true':
    rows = [r for r in rows if r.get('agentEnabled')]
    if not rows:
        print('No agent-enabled workspaces.')
        sys.exit(0)
if not rows:
    print('No workspaces registered.')
    sys.exit(0)
for r in rows:
    name = r.get('name') or '?'
    mode = r.get('agentMode') or 'off'
    path = r.get('path', '')
    print(f'  {name:<24} [{mode:<8}] {path}')
"
}

cmd_workspace_triage() {
    # 0.39.0f Phase 2.1b: /cli/work/inbox is HTTP 410 now. Use the
    # inbox-first endpoint and the inbox formatter so the output
    # matches the rest of the inbox surface.
    cli_request "/cli/inbox/list" | format_inbox
}

# ── Phase 2.1: 3-tier help (verbatim mock text) ───────────────────────

cmd_help_v2_daily() {
    cat <<'EOF'
K2SO CLI — workspace orchestration for AI agents

A workspace = a project folder K2SO knows about. Each workspace has
one primary agent that reads its inbox, applies skills to work, and
coordinates with other workspaces.

TALK TO OTHERS
  msg <workspace> "text"                Live delivery (call/IM — blocks until landed; SHORT messages only)
  msg <workspace> "text" --inbox        Drop in their inbox (email — async, no length limit)
  msg <workspace> --signal <kind> ...   Emit a typed signal (advanced)
  read <workspace> [--lines N]          Read their live terminal (peek before you inject)
  connections list                      Connected workspaces (bidirectional)
  connections add <path>                Connect to another workspace
  connections remove <name>             Disconnect

YOUR INBOX
  inbox                                 Show inbox items (default)
  inbox list [<folder>]                 List items in inbox or a folder
  inbox read <id>                       Full text of one item
  inbox compose --title "..." --body    Write your own item (self-note / task)
  inbox respond <id> "text"             Reply (back to sender)
  inbox move <id> <folder>              File into folder (creates if needed)
  inbox archive <id>                    Standard archive (preserved + searchable)
  inbox delete <id>                     Move to macOS Recycle Bin (recoverable from Trash)
  inbox search "query"                  Search inbox + folders
  inbox folders                         List folders this workspace has created

CHECKIN
  checkin                               Heartbeat ping ("I'm alive")
  checkin --status "message"            Report status update
  checkin --done [--blocked "reason"]   Task complete (or blocked)
  done                                  Shortcut for `checkin --done`

WORKSPACE
  workspace list                        Yellow pages: every workspace + status
  workspace list --running              Filter to live agents
  workspace launch [<path>]             Smart cascade: attach|wake|spawn
  workspace profile [<path>]            Read workspace agent's AGENT.md
  workspace update --field <f> --value <v>
                                        Edit workspace agent profile

REVIEW IN-FLIGHT WORK
  reviews                               Pending merge reviews for worktrees
  review approve|reject|feedback <agent> [options]
                                        Act on a pending review

ACTIVITY
  activity [--limit N] [--workspace <p>]
                                        Audit log of workspace events
  commit [-m "..."] [--merge]           AI-assisted commit (--merge to also merge)

INFO
  help                                  This help
  help --advanced                       Power-user verbs (heartbeat, daemon, etc.)
  help --internal                       Orchestrator-RPC verbs (rare)
  help-deprecated                       Map: old verb → new equivalent
  glossary [<term>]                     Define K2SO-specific terms (try `glossary inbox`)
  whats-new                             Changelog popup
  version                               CLI version

ENVIRONMENT
  K2SO_DAEMON_URL     Remote daemon URL (default: localhost — see K2SO Connect)
  K2SO_PROJECT_PATH   Workspace root (default: $PWD)
  K2SO_AGENT_NAME     Default sender for `msg --from`

Run `k2 glossary <term>` for any unfamiliar word (workspace, skill,
inbox, heartbeat, etc.).
EOF
}

cmd_help_v2_advanced() {
    cat <<'EOF'
K2SO CLI — Power-user surface

Includes all daily verbs above, plus:

HEARTBEATS (workspace-scoped scheduled wakes)
  heartbeat schedule add --name <n> <spec>      Create heartbeat schedule
  heartbeat schedule list [--archived]          Active or archived
  heartbeat schedule remove <n> [--purge]       Soft-archive (default) or hard-delete
  heartbeat schedule unarchive <n>              Restore from archive
  heartbeat schedule edit <n> <spec>            Change schedule
  heartbeat schedule rename <old> <new>         Rename folder + DB row
  heartbeat schedule enable <n>                 Resume scheduling
  heartbeat schedule disable <n>                Pause scheduling

  heartbeat signal fire <n>                     Fire one heartbeat now
  heartbeat signal wakeup <n>                   Open the WAKEUP.md
  heartbeat signal wake                         Auto-wake (no name needed)

  heartbeat show <n> [--json]                   Single heartbeat details
  heartbeat status <n> [-n N]                   Recent fire history
  heartbeat log [-n N]                          Scheduler decisions

DAEMON LIFECYCLE
  daemon status [--json]                        PID, port, uptime
  daemon start|stop|restart                     launchctl bootstrap/bootout
  daemon log [--lines N]                        Tail daemon log
  daemon companion start|stop|status            Companion (ngrok tunnel) server
  daemon hooks status [--limit N] [--json]      Hook pipeline state
  daemon reap                                   GC dead-PID sessions
  daemon uninstall                              Remove daemon plist

TUNNEL (K2 Connect — expose this daemon at https://<user>.k2.dev)
  tunnel start [--subdomain <s>]                Start the frpc tunnel
  tunnel stop                                   Stop the tunnel
  tunnel status [--json]                        Running? + public URL

SETTINGS
  settings                                      Show current settings
  settings --mode <off|agent|manager>           Workspace mode
  settings --state <build|managed|maintenance|locked>
                                                Workspace capability tier
  settings --agentic <on|off>                   Global agentic systems toggle
  settings --companion <on|off>                 Enable/disable companion tunnel
  settings --companion-password "<pw>"          Rotate companion password

SKILLS (documentation profiles — NOT spawnable; harness handles spawn)
  skills list                                   Skill profiles in this workspace
  skills create <name> [--template <role>]      Create a new skill profile
  skills remove <name>                          Delete skill profile
  skills profile <name>                         Read skill's SKILL.md/AGENT.md
  skills regenerate [<name>]                    Refresh SKILL.md files

UPDATE
  update                                        Update K2SO app (default: --app)
  update --app                                  Same: check/install app updates
  update --cli                                  Update this CLI script
  update --list                                 Show available versions

ONBOARDING (first-launch flow)
  onboarding scan                               Find adoptable projects
  onboarding adopt <path>                       Register an existing folder
  onboarding defer <path>                       Skip onboarding for now
  onboarding start-fresh <path>                 Create new workspace from scratch
EOF
}

# ── Phase 2.1: msg --inbox / --signal flag handlers ───────────────────

cmd_msg_inbox_form() {
    # 0.39.0f Phase 2.1c: migrated from /cli/work/inbox/create (now 410)
    # to POST /cli/inbox/compose?project=<target>. The new inbox surface
    # uses `project=<path>` to target a different workspace's inbox —
    # same shape as the local `inbox compose` POST, just with a different
    # `project` parameter than the caller's PWD.
    # `msg <ws> --inbox --title "..." --body "..."`
    local target="$1"
    shift
    local title="" body="" priority="" from=""
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --title)    title="$2"; shift 2 ;;
            --body)     body="$2"; shift 2 ;;
            --priority) priority="$2"; shift 2 ;;
            --from)     from="$2"; shift 2 ;;
            *) shift ;;
        esac
    done
    if [[ -z "$title" ]]; then
        echo "Usage: k2 msg <workspace> --inbox --title \"...\" [--body \"...\"]" >&2
        exit 1
    fi

    # Resolve target → workspace path
    local resolved
    resolved=$(cli_request "/cli/workspace/resolve" "q=$(urlencode "$target")" 2>/dev/null \
        | python3 -c 'import json,sys
try: print(json.load(sys.stdin).get("path",""))
except: pass' 2>/dev/null)
    [[ -z "$resolved" ]] && {
        echo "Error: workspace not found: $target" >&2
        exit 1
    }

    # POST to /cli/inbox/compose with project=<target-workspace>. Note
    # we deliberately do NOT call cli_post_form or cli_request here —
    # both hard-code project=$PROJECT (caller's PWD), and we need to
    # override it to address the recipient's workspace.
    #
    # 0.39.45 (#35/#37): title/body/priority/from ride a REAL form body.
    # The old `curl -sG -X POST` put them on the URL, where the daemon's
    # request-head cap silently clipped memo bodies at ~2.7KB.
    local params="title=$(urlencode "$title")"
    [[ -n "$body" ]] && params="${params}&body=$(urlencode "$body")"
    [[ -n "$priority" ]] && params="${params}&priority=$(urlencode "$priority")"
    # `--from` was historically `assigned_by` on the old work-route;
    # the inbox-compose route names it `from` (writes to the YAML
    # frontmatter `from:` field — identical persisted shape).
    [[ -n "$from" ]] && params="${params}&from=$(urlencode "$from")"
    curl -s -X POST "${BASE_URL}/cli/inbox/compose?token=${TOKEN}&project=$(urlencode "$resolved")" \
        --connect-timeout 5 --max-time 30 \
        -H "Content-Type: application/x-www-form-urlencoded" \
        --data-raw "$params" 2>/dev/null
    echo
}

cmd_msg_signal_form() {
    # Forward to legacy cmd_signal which builds the AgentSignal wire payload.
    # `msg <ws> --signal <kind> [--payload <json>] [--from <name>]`
    local target="$1"
    shift
    local kind="msg" payload="{}" from="${K2SO_AGENT_NAME:-cli}" delivery="live"
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --signal)  kind="$2"; shift 2 ;;
            --payload) payload="$2"; shift 2 ;;
            --from)    from="$2"; shift 2 ;;
            --inbox)   delivery="inbox"; shift ;;
            *) shift ;;
        esac
    done
    # Delegate to cmd_signal — preserves wire-format consistency with
    # the now-deprecated `signal` top-level verb.
    local args=("$target" "$kind" "$payload" "--from" "$from")
    [[ "$delivery" == "inbox" ]] && args+=("--inbox")
    cmd_signal "${args[@]}"
}

# Wrapper that splits `k2 msg <ws> ...` into live / --inbox / --signal forms.
cmd_msg_v2() {
    # Detect --inbox or --signal anywhere in the args. If present, dispatch
    # to the alternate form; otherwise fall through to legacy cmd_msg
    # (live delivery).
    local has_inbox="false" has_signal="false"
    for arg in "$@"; do
        case "$arg" in
            --inbox) has_inbox="true" ;;
            --signal) has_signal="true" ;;
        esac
    done
    if [[ "$has_signal" == "true" ]]; then
        local target="$1"; shift
        cmd_msg_signal_form "$target" "$@"
    elif [[ "$has_inbox" == "true" ]]; then
        local target="$1"; shift
        # Drop the --inbox flag itself from forwarded args
        local rest=()
        for arg in "$@"; do
            [[ "$arg" == "--inbox" ]] && continue
            rest+=("$arg")
        done
        cmd_msg_inbox_form "$target" "${rest[@]}"
    else
        cmd_msg "$@"
    fi
}

# ── Phase 2.1: checkin --status / --done flag handlers ───────────────

cmd_checkin_v2() {
    # Detect --status or --done flags; dispatch to legacy cmd_status /
    # cmd_done. Without those flags, defer to legacy cmd_checkin.
    local has_status="false" has_done="false"
    for arg in "$@"; do
        case "$arg" in
            --status) has_status="true" ;;
            --done) has_done="true" ;;
        esac
    done
    if [[ "$has_done" == "true" ]]; then
        local blocked=""
        local agent_arg="${K2SO_AGENT_NAME:-}"
        while [[ $# -gt 0 ]]; do
            case "$1" in
                --done) shift ;;
                --blocked) blocked="$2"; shift 2 ;;
                --agent) agent_arg="$2"; shift 2 ;;
                *) shift ;;
            esac
        done
        local args=("--agent" "$agent_arg")
        [[ -n "$blocked" ]] && args+=("--blocked" "$blocked")
        cmd_done "${args[@]}"
    elif [[ "$has_status" == "true" ]]; then
        local message=""
        local agent_arg="${K2SO_AGENT_NAME:-}"
        while [[ $# -gt 0 ]]; do
            case "$1" in
                --status) message="$2"; shift 2 ;;
                --agent) agent_arg="$2"; shift 2 ;;
                *) shift ;;
            esac
        done
        cmd_status "--agent" "$agent_arg" "$message"
    else
        cmd_checkin "$@"
    fi
}

# ── Phase 2.1: commit --merge flag handler ───────────────────────────

cmd_commit_v2() {
    local has_merge="false"
    for arg in "$@"; do
        [[ "$arg" == "--merge" ]] && has_merge="true"
    done
    if [[ "$has_merge" == "true" ]]; then
        # Strip --merge and call commit-merge.
        local rest=()
        for arg in "$@"; do
            [[ "$arg" == "--merge" ]] && continue
            rest+=("$arg")
        done
        cmd_commit_merge "${rest[@]}"
    else
        cmd_commit "$@"
    fi
}

# ── Phase 2.1: update --cli / --app flag handler ─────────────────────

cmd_update_v2() {
    local target="app"  # default per mock: `update` = `update --app`
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --app)  target="app";  shift ;;
            --cli)  target="cli";  shift ;;
            --list) cmd_app_update --list; return ;;
            *) shift ;;
        esac
    done
    if [[ "$target" == "cli" ]]; then
        cmd_update "$@"
    else
        cmd_app_update "$@"
    fi
}

# ── Router ───────────────────────────────────────────────────────────

case "${1:-help}" in
    agent)
        # 0.39.0f Phase 2.1b: singular `agent` keeps only the documented
        # internal-tier subverbs (`update`, `complete`). Undocumented
        # `create` / `delete` / `list` / `profile` arms previously chained
        # to legacy `cmd_agents_*` bodies; with those bodies removed in
        # 2.1b, the arms hard-deprecate. `profile` routes to the
        # workspace-level verb per `cmd_help_internal`.
        case "${2:-}" in
            update) shift 2; cmd_agent_update "$@" ;;
            complete) shift 2; cmd_agent_complete "$@" ;;
            profile) shift 2; cmd_workspace_profile "$@" ;;
            create)
                fail_deprecated "agent create" "k2 skills create <name>" ;;
            delete|remove)
                fail_deprecated "agent delete" "k2 skills remove <name>" ;;
            list)
                fail_deprecated "agent list" "k2 workspace list (or k2 skills list)" ;;
            *)      echo "Unknown agent subcommand: ${2:-}" >&2; cmd_help; exit 1 ;;
        esac
        ;;
    agents)
        # 0.39.0f Phase 2.1b: all `agents *` subverbs hard-deprecate.
        # Phase 2.1a kept warn+forward for one release; 2.1b cuts the
        # transition window entirely per user directive ("I'm done with
        # the old CLI tools"). Every subverb exits non-zero with a
        # pointer to the new equivalent.
        case "${2:-list}" in
            list)
                fail_deprecated "agents list" "k2 workspace list (yellow pages) or k2 skills list (skill profiles)" ;;
            work)
                fail_deprecated "agents work" "k2 inbox (workspace-implicit; pass --workspace <path> to target another)" ;;
            status)
                fail_deprecated "agents status" "k2 workspace status or k2 checkin --status" ;;
            create)
                fail_deprecated "agents create" "k2 skills create <name> (or register a new workspace via your IDE/harness)" ;;
            delete|remove)
                fail_deprecated "agents delete" "k2 skills remove <name> or k2 workspace remove <path>" ;;
            launch)
                fail_deprecated "agents launch" "your IDE/harness's session-start feature; K2SO no longer manages session spawn" ;;
            generate-md)
                fail_deprecated "agents generate-md" "k2 skills regenerate [<name>]" ;;
            profile)
                fail_deprecated "agents profile" "k2 skills profile <name> or k2 workspace profile" ;;
            lock)
                fail_deprecated "agents lock" "k2 skills lock <name> (debugging only; rarely needed)" ;;
            unlock)
                fail_deprecated "agents unlock" "k2 skills lock <name> --release" ;;
            triage)
                fail_deprecated "agents triage" "k2 workspace triage" ;;
            running)
                fail_deprecated "agents running" "k2 workspace list --running" ;;
            reap)
                fail_deprecated "agents reap" "k2 daemon reap" ;;
            *)      echo "Unknown agents subcommand: $2" >&2; cmd_help; exit 1 ;;
        esac
        ;;
    work)
        # 0.39.0f Phase 2.1b: all `work *` subverbs hard-deprecate. No
        # transition window — fail loudly with a pointer to the
        # inbox-as-email replacement (PRD A22 / A24).
        case "${2:-}" in
            create)
                fail_deprecated "work create" "k2 inbox compose --title \"...\" --body \"...\"" ;;
            inbox)
                fail_deprecated "work inbox" "k2 inbox (default verb shows new arrivals)" ;;
            send)
                fail_deprecated "work send" "k2 msg <workspace> --inbox --title \"...\" --body \"...\"" ;;
            move)
                fail_deprecated "work move" "k2 inbox move <id> <folder>" ;;
            done)
                fail_deprecated "work done" "k2 inbox archive <id>" ;;
            *)      echo "Unknown work subcommand: ${2:-}" >&2; cmd_help; exit 1 ;;
        esac
        ;;
    terminal)
        case "${2:-}" in
            spawn) shift 2; cmd_terminal_spawn "$@" ;;
            write) shift 2; cmd_terminal_write "$@" ;;
            read)  shift 2; cmd_terminal_read "$@" ;;
            *)     echo "Usage: k2 terminal <spawn|write|read> ..." >&2; exit 1 ;;
        esac
        ;;
    agentic)
        # Phase 2.1: hard-deprecated. Forward users to settings --agentic.
        fail_deprecated "agentic" "k2 settings --agentic <on|off>"
        ;;
    state)
        # Phase 2.1: hard-deprecated. Forward users to settings --state.
        fail_deprecated "state" "k2 settings --state <id>"
        ;;
    delegate)
        # Phase 2.1 A23: hard-deprecated. Modern harnesses (Claude Code,
        # Cursor) create worktrees + spawn sub-agents natively. K2SO no
        # longer manages the spawn lifecycle.
        cat >&2 <<EOF
Error: 'delegate' is deprecated.

Modern harnesses (Claude Code, Cursor, etc.) create worktrees and
spawn agents natively — use your IDE's sub-agent / worktree feature
instead. K2SO no longer manages the spawn lifecycle.

For applying a skill profile to specific work without a fresh worktree,
see 'k2 skills profile <name>' for the SKILL.md content; load it
into your harness's context.

Run 'k2 help-deprecated' for the full map of retired verbs.
EOF
        exit 1
        ;;
    commit)
        # Phase 2.1: commit --merge subsumes commit-merge per A2.3.
        shift; cmd_commit_v2 "$@"
        ;;
    commit-merge)
        # Phase 2.1: hard-deprecated. Use commit --merge.
        fail_deprecated "commit-merge" "k2 commit --merge"
        ;;
    reviews)
        shift; cmd_reviews "$@"
        ;;
    review)
        case "${2:-}" in
            approve)  shift 2; cmd_review_approve "$@" ;;
            reject)   shift 2; cmd_review_reject "$@" ;;
            feedback) shift 2; cmd_review_feedback "$@" ;;
            *)        echo "Unknown review subcommand: ${2:-}" >&2; cmd_help; exit 1 ;;
        esac
        ;;
    workspace)
        case "${2:-}" in
            # Phase 2.1 A6.1 daily subverbs.
            list)              shift 2; cmd_workspace_list "$@" ;;
            launch)            shift 2; cmd_workspace_launch "$@" ;;
            profile)           shift 2; cmd_workspace_profile "$@" ;;
            update)            shift 2; cmd_workspace_update "$@" ;;
            triage)            cmd_workspace_triage ;;
            # Existing lifecycle subverbs.
            create)            shift 2; cmd_workspace_create "$@" ;;
            open)              shift 2; cmd_workspace_open "$@" ;;
            remove)            shift 2; cmd_workspace_remove "$@" ;;
            preview)           shift 2; cmd_workspace_preview "$@" ;;
            cleanup)           cmd_workspace_cleanup ;;
            agent-name)        shift 2; cmd_workspace_get_agent_display_name "$@" ;;
            set-agent-name)    shift 2; cmd_workspace_set_agent_display_name "$@" ;;
            resume-chat-args)  shift 2; cmd_workspace_resume_chat_args "$@" ;;
            *)                 echo "Usage: k2 workspace <list|launch|profile|update|create|open|remove|triage|...> [args]" >&2; exit 1 ;;
        esac
        ;;
    onboarding)
        # Phase 2.1 A5: later → defer, fresh → start-fresh.
        # Old names still work but warn.
        case "${2:-}" in
            scan)         shift 2; cmd_onboarding_scan "$@" ;;
            adopt)        shift 2; cmd_onboarding_adopt "$@" ;;
            defer)        shift 2; cmd_onboarding_later "$@" ;;
            start-fresh)  shift 2; cmd_onboarding_fresh "$@" ;;
            later)
                warn_deprecated "onboarding later" "k2 onboarding defer"
                shift 2; cmd_onboarding_later "$@" ;;
            fresh)
                warn_deprecated "onboarding fresh" "k2 onboarding start-fresh"
                shift 2; cmd_onboarding_fresh "$@" ;;
            *)            echo "Usage: k2 onboarding <scan|adopt|defer|start-fresh> [args]" >&2; exit 1 ;;
        esac
        ;;
    mode)
        # Phase 2.1: hard-deprecated. Use settings --mode.
        fail_deprecated "mode" "k2 settings --mode <off|agent|manager>"
        ;;
    heartbeat)
        case "${2:-}" in
            wake)           shift 2; cmd_heartbeat_wake "$@" ;;
            log)            shift 2; cmd_heartbeat_log "$@" ;;
            add)            shift 2; cmd_heartbeat_add "$@" ;;
            list)           shift 2; cmd_heartbeat_list "$@" ;;
            list-archived)  shift 2; cmd_heartbeat_list_archived "$@" ;;
            archive)        shift 2; cmd_heartbeat_remove "$@" ;;     # alias — soft-archive default
            unarchive)      shift 2; cmd_heartbeat_unarchive "$@" ;;
            remove)         shift 2; cmd_heartbeat_remove "$@" ;;
            fire)           shift 2; cmd_heartbeat_fire "$@" ;;
            enable)         shift 2; cmd_heartbeat_enable_disable true "$@" ;;
            disable)        shift 2; cmd_heartbeat_enable_disable false "$@" ;;
            use-pinned-session)
                shift 2
                case "${1:-}" in
                    on|true|1)   shift; cmd_heartbeat_use_pinned_session true "$@" ;;
                    off|false|0) shift; cmd_heartbeat_use_pinned_session false "$@" ;;
                    *) echo "Usage: k2 heartbeat use-pinned-session [on|off] <name>" >&2; exit 1 ;;
                esac
                ;;
            rename)         shift 2; cmd_heartbeat_rename "$@" ;;
            edit)           shift 2; cmd_heartbeat_edit "$@" ;;
            show)           shift 2; cmd_heartbeat_show "$@" ;;
            wakeup)         shift 2; cmd_heartbeat_wakeup "$@" ;;
            status)         shift 2; cmd_heartbeat_status "$@" ;;
            *)              shift; cmd_heartbeat_toggle "$@" ;;
        esac
        ;;
    hooks)
        if [ "${2:-}" = "status" ]; then
            shift 2; cmd_hooks_status "$@"
        else
            echo "Usage: k2 hooks status [--limit N] [--json]" >&2
            exit 1
        fi
        ;;
    daemon)
        case "${2:-status}" in
            status)    shift 2; cmd_daemon_status "$@" ;;
            start)     shift 2; cmd_daemon_start "$@" ;;
            stop)      shift 2; cmd_daemon_stop "$@" ;;
            restart)   shift 2; cmd_daemon_restart "$@" ;;
            log|logs)  shift 2; cmd_daemon_log "$@" ;;
            install)   shift 2; cmd_daemon_install "$@" ;;
            uninstall) shift 2; cmd_daemon_uninstall "$@" ;;
            companion) shift 2; cmd_daemon_companion "$@" ;;
            *)
                echo "Usage: k2 daemon <status|start|stop|restart|log|install|uninstall|companion>" >&2
                echo "" >&2
                echo "  install [--version <x.y.z>] [--bin-dir <dir>] [--manifest-url <url>]" >&2
                echo "          [--no-service] [--dry-run]" >&2
                echo "    Install the STANDALONE (headless, no-GUI) daemon binary from a" >&2
                echo "    signed GitHub release. Minisign-verifies the binary against the" >&2
                echo "    embedded updater pubkey, installs to ~/.local/bin by default, and" >&2
                echo "    writes a systemd (Linux) / launchd (macOS) supervisor unit so a" >&2
                echo "    crash respawns it. --dry-run prints the plan without downloading." >&2
                echo "" >&2
                echo "  restart [--host <base-url>] [--token <token>] [--wait]" >&2
                echo "    Default: restart the LOCAL daemon (launchctl kickstart)." >&2
                echo "    --host:  restart a REMOTE daemon with no GUI by POSTing" >&2
                echo "             <base-url>/cli/daemon/restart (e.g. --host https://<sub>.k2.dev)." >&2
                echo "             Authorizes with the owner token OR an Owner/Admin connect-user" >&2
                echo "             session token; --token defaults to ~/.k2so/daemon.token." >&2
                echo "    --wait:  after the restart is accepted, poll <base>/boot-status every" >&2
                echo "             ~2s up to ~60s until phase=ready, then print the version it" >&2
                echo "             came back on. Timeout → message + non-zero exit." >&2
                exit 1
                ;;
        esac
        ;;
    tunnel)
        # K2 Connect tunnel — expose this daemon at https://<user>.k2.dev.
        shift; cmd_tunnel "$@"
        ;;
    settings)
        shift; cmd_settings "$@"
        ;;
    checkin)
        # Phase 2.1: checkin --status / --done flag handlers.
        shift; cmd_checkin_v2 "$@"
        ;;
    status)
        # Phase 2.1: hard-deprecated. Use checkin --status.
        # (Top-level only; --status flag on checkin keeps working.)
        fail_deprecated "status" "k2 checkin --status \"msg\""
        ;;
    done)
        # Phase 2.1: `done` survives as a daily-tier shortcut for
        # `checkin --done` per A2.2 / mock daily tier.
        shift; cmd_done "$@"
        ;;
    msg)
        # Phase 2.1: msg now has --inbox and --signal flag forms.
        shift; cmd_msg_v2 "$@"
        ;;
    read)
        # 0.39.x: read a workspace's live terminal by NAME — the read
        # complement to `msg` (talk live) and `inbox` (mail). Addresses
        # by workspace name like `msg`, resolving to that workspace's
        # canonical session (or `--agent <name>` for a specific agent).
        shift; cmd_read "$@"
        ;;
    whatsnew)
        # Phase 2.1: hard-deprecated spelling. Use whats-new.
        fail_deprecated "whatsnew" "k2 whats-new"
        ;;
    whats-new)
        shift; cmd_whatsnew "$@"
        ;;
    signal)
        # Phase 2.1 A2.1: hard-deprecated. Use msg --signal <kind>.
        fail_deprecated "signal" "k2 msg <workspace> --signal <kind> [--payload <json>]"
        ;;
    roster)
        # 0.39.0: roster ≡ connections in the workspace==agent model.
        # The Phase-2.1 pointer was `k2 who`; 0.39.0 makes the
        # canonical command `k2 connections list`.
        fail_deprecated "roster" "k2 connections list"
        ;;
    who)
        # 0.39.0: roster ≡ connections. Both legacy verbs collapse to
        # `k2 connections list`. See cmd_who docstring for context.
        fail_deprecated "who" "k2 connections list"
        ;;
    activity)
        # Phase 2.1 A5: feed → activity.
        shift; cmd_activity "$@"
        ;;
    inbox)
        # Phase 2.1 A22: inbox-as-email primitive.
        shift; cmd_inbox "$@"
        ;;
    glossary)
        # Phase 2.1 A10: K2SO glossary lookup.
        shift; cmd_glossary "$@"
        ;;
    help-deprecated)
        cmd_help_deprecated
        ;;
    help-internal)
        cmd_help_internal
        ;;
    sessions)
        # 0.34.0 Phase 3.1 — k2 sessions spawn --agent foo
        shift; cmd_sessions "$@"
        ;;
    reserve)
        shift; cmd_reserve "$@"
        ;;
    release)
        shift; cmd_release "$@"
        ;;
    connections)
        shift; cmd_connections "$@"
        ;;
    companion)
        # Phase 2.1: hard-deprecated. Companion is daemon-owned.
        fail_deprecated "companion" "k2 daemon companion <start|stop|status>"
        ;;
    skills)
        shift; cmd_skills "$@"
        ;;
    feed)
        # Phase 2.1 A5: hard-deprecated. Use activity.
        fail_deprecated "feed" "k2 activity"
        ;;
    app-update)
        # Phase 2.1 A1: hard-deprecated. Use update --app.
        fail_deprecated "app-update" "k2 update --app"
        ;;
    update)
        # Phase 2.1: update --app / --cli flag handler.
        shift; cmd_update_v2 "$@"
        ;;
    version|--version|-v)
        echo "k2 $K2_CLI_VERSION"
        ;;
    help|--help|-h)
        # Guard against `set -u` when no trailing args.
        if [ $# -gt 0 ]; then shift; fi
        cmd_help "$@"
        ;;
    help-advanced)
        # Legacy alias for the Phase 2.1 `help --advanced` form.
        cmd_help_v2_advanced
        ;;
    *)
        echo "Unknown command: $1" >&2
        cmd_help
        exit 1
        ;;
esac
