#!/usr/bin/env bash
# swap-guard — shared state/guard engine for the /swap + /handoff Claude Code integration.
# Contract: section A of the binding interface contract.
# Subcommands:
#   whoami                          own session registry JSON (compact); exit 1 if not in a session
#   sessions                        JSON array of OTHER live claude sessions (always exit 0)
#   preflight <target>              switch-safety verdict JSON for slot number / alias / email (always exit 0)
#   path flag|handoff|codex-handoff [--cwd <dir>]
#                                   print per-cwd state-file path (codex-handoff: overcodex's pending file)
#   flag <json>                     validate + fill + atomic-write relaunch flag; prints path (exit 1 on bad input)
#   status                          own-session status JSON (always exit 0)
#   cancel [--cwd <dir>]            remove pending handoff + flag for cwd (always exit 0)
#   history [--cwd <dir>] [--all]   archived handoff packages (newest first, numbered) as JSON
#   restore [<n>|<path>] [--cwd <dir>] [--force]
#                                   re-arm an archived package as this cwd's pending handoff
#   auto tick|install|uninstall|status [opts]
#                                   busy-aware wrapper around `cswap auto --once` (+ opt-in LaunchAgent)
#   artifacts [--session <id>] [--cwd <dir>]
#                                   collect this session's claude.ai artifacts: copy sources to
#                                   $STATE_ROOT/handoff-artifacts/<sid>/ + manifest.json; prints manifest
#   artifacts find|list [words] [--account X] | link <old> <new> | index
#                                   the artifact registry (every publish, with its owning account)
#   artifact-log                    PostToolUse hook (Artifact): append one publish to the registry
#   ratelimit [--dry-run]           StopFailure hook (rate_limit): instant swap on a usage cap
#   budget [--hook]                 usage bands for ULTRACODE budget pressure (UserPromptSubmit hook)
#   login <slot|alias|email> | --new
#                                   safe re-login / add that never overwrites the live account's token
#   schedule-kill <pid>             v1 stub (exit 1)
set -u

STATE_ROOT="$HOME/.claude-swap-backup"
CTX_DIR="$STATE_ROOT/ctx"
ARCHIVE_DIR="$STATE_ROOT/handoff-archive"
ARTIFACTS_DIR="$STATE_ROOT/handoff-artifacts"
SESSIONS_DIR="$HOME/.claude/sessions"

usage() {
  cat >&2 <<'EOF'
usage: swap-guard <subcommand> [args]
  whoami                          own session registry JSON (compact)
  sessions                        JSON array of other live claude sessions
  preflight <target>              switch-safety verdict for slot / alias / email
  path flag|handoff|codex-handoff [--cwd <dir>]
                                  print per-cwd state-file path (codex-handoff: where overcodex picks it up)
  flag <json>                     write relaunch flag ({"mode":"handoff"|"restart",...}); prints path
  status                          own-session status JSON
  cancel [--cwd <dir>]            remove pending handoff + flag for cwd
  history [--cwd <dir>] [--all]   list archived handoff packages (newest first)
  restore [<n>|<path>] [--cwd <dir>] [--force]
                                  re-arm an archived package (default: newest for cwd)
  auto tick [--threshold PCT] [--model NAMES] [--no-notify]
                                  one busy-aware auto-switch evaluation (JSON line; exit = cswap's)
  auto install [--interval SEC] [--threshold PCT] [--model NAMES] [--no-notify]
  auto uninstall | auto status    manage the LaunchAgent that runs `auto tick` periodically
  artifacts [--session <id>] [--cwd <dir>]
                                  snapshot this session's artifacts (sources + manifest) for handoff
  artifacts find|list [words] [--account X]   search every artifact you have published
  artifacts link <old> <new>      record that <new> is a republished copy of <old>
  artifacts index                 backfill the registry from past transcripts
  ratelimit [--dry-run] [--print] StopFailure hook: switch accounts the moment a usage cap hits
  auto instant on|off|status      enable/disable that instant swap (default: on)
  budget [--hook]                 usage bands the model should know about (hook: inject on change)
  login <slot|alias|email>        re-login a dead account WITHOUT killing the live one's token
  login --new                     add a new account the same safe way
  version-check [--force]         newest overclaude on PyPI (cached for a day)
  codex-usage [--refresh]         Codex CLI rate limits per Codex home, from its session logs
  codex-ready                     can /handoff codex work here? (CLI, overcodex hook, limits)
  route-guard [on|off|status]     PreToolUse hook (Agent|Workflow): deny un-routed subagents on Fable
  spend [--days N]                API-price value of your usage (by model/account/session/day) vs plan cost
  codex-review [--dir D] [--model M] [--effort E] [--schema F] [--timeout S] [--raw] <prompt> [--stdin < input]
                                  cross-family second opinion: read-only, ephemeral `codex exec`
  schedule-kill <pid>             phase-2 stub
EOF
  exit 2
}

# HASH(cwd) per shared constants: first 12 hex of sha256
hash_cwd() { printf '%s' "$1" | /usr/bin/shasum -a 256 | awk '{print $1}' | cut -c1-12; }

flag_path_for()    { printf '%s/relaunch-%s.json\n'      "$STATE_ROOT" "$(hash_cwd "$1")"; }
pending_path_for() { printf '%s/handoff-pending-%s.md\n' "$STATE_ROOT" "$(hash_cwd "$1")"; }

# whoami_json [start_pid] — walk the PPID chain from start_pid (default $$) up to pid 1;
# print the first matching registry file's JSON (compact) and return 0, else return 1.
whoami_json() {
  local p="${1:-$$}"
  while :; do
    case "$p" in ''|*[!0-9]*) return 1;; esac
    [ "$p" -gt 1 ] || return 1
    if [ -f "$SESSIONS_DIR/$p.json" ]; then
      jq -c . "$SESSIONS_DIR/$p.json" 2>/dev/null || cat "$SESSIONS_DIR/$p.json"
      return 0
    fi
    p="$(ps -o ppid= -p "$p" 2>/dev/null | tr -d ' ')"
  done
}

# session_live <pid> — true iff the process exists (kill -0) AND its comm contains "claude"
session_live() {
  local pid="${1:-}" comm
  case "$pid" in ''|*[!0-9]*) return 1;; esac
  kill -0 "$pid" 2>/dev/null || return 1
  comm="$(ps -o comm= -p "$pid" 2>/dev/null)"
  case "$comm" in *claude*) return 0;; *) return 1;; esac
}

# transcript_status <cwd> <sessionId> — activity fallback for sessions whose registry
# file has no status field (clients < ~2.1.211 never publish one). An active session
# appends to ~/.claude/projects/<flattened-cwd>/<sessionId>.jsonl on every turn, so:
# mtime within TRANSCRIPT_ACTIVE_SECS -> "busy"; older -> "idle"; no transcript -> "unknown".
TRANSCRIPT_ACTIVE_SECS=120
transcript_status() {
  local cwd="${1:-}" sid="${2:-}" t mtime now
  [ -n "$cwd" ] && [ -n "$sid" ] || { printf 'unknown\n'; return 0; }
  t="$HOME/.claude/projects/$(printf '%s' "$cwd" | sed 's/[^A-Za-z0-9]/-/g')/$sid.jsonl"
  [ -f "$t" ] || { printf 'unknown\n'; return 0; }
  mtime="$(stat -f %m "$t" 2>/dev/null)"
  case "$mtime" in ''|*[!0-9]*) printf 'unknown\n'; return 0;; esac
  now="$(date +%s)"
  if [ $(( now - mtime )) -le "$TRANSCRIPT_ACTIVE_SECS" ]; then
    printf 'busy\n'
  else
    printf 'idle\n'
  fi
}

# sessions_json — JSON array of OTHER live sessions. status precedence: the value the
# session itself reports (statusSource "reported") > transcript-mtime fallback
# (statusSource "transcript-mtime") > "unknown" (statusSource "none").
sessions_json() {
  local own_pid="" out="[]" new_out f pid entry st
  own_pid="$(whoami_json 2>/dev/null | jq -r '.pid // empty' 2>/dev/null)" || own_pid=""
  for f in "$SESSIONS_DIR"/*.json; do
    [ -f "$f" ] || continue
    pid="$(jq -r '.pid // empty' "$f" 2>/dev/null)" || continue
    [ -n "$pid" ] || continue
    [ "$pid" = "$own_pid" ] && continue
    session_live "$pid" || continue
    entry="$(jq -c '{pid, sessionId, cwd, kind, entrypoint, status: (.status // "unknown"),
      statusSource: (if .status then "reported" else "none" end)}' "$f" 2>/dev/null)" || continue
    [ -n "$entry" ] || continue
    if [ "$(printf '%s' "$entry" | jq -r '.statusSource' 2>/dev/null)" = "none" ]; then
      st="$(transcript_status "$(printf '%s' "$entry" | jq -r '.cwd // empty')" \
                              "$(printf '%s' "$entry" | jq -r '.sessionId // empty')")"
      case "$st" in
        busy|idle) entry="$(printf '%s' "$entry" | jq -c --arg s "$st" '.status = $s | .statusSource = "transcript-mtime"' 2>/dev/null)" || continue;;
      esac
    fi
    new_out="$(printf '%s' "$out" | jq -c --argjson e "$entry" '. + [$e]' 2>/dev/null)" && [ -n "$new_out" ] && out="$new_out"
  done
  printf '%s\n' "$out"
}

# resolve_cwd [explicit] — precedence: explicit --cwd > whoami .cwd > $PWD
resolve_cwd() {
  local c="${1:-}"
  if [ -z "$c" ]; then
    c="$(whoami_json 2>/dev/null | jq -r '.cwd // empty' 2>/dev/null)" || c=""
  fi
  [ -n "$c" ] || c="$PWD"
  printf '%s\n' "$c"
}

# parse_cwd_opt "$@" — echo value of --cwd/--cwd= if present; return 1 if absent
parse_cwd_opt() {
  while [ $# -gt 0 ]; do
    case "$1" in
      --cwd) shift; printf '%s\n' "${1:-}"; return 0;;
      --cwd=*) printf '%s\n' "${1#--cwd=}"; return 0;;
    esac
    shift
  done
  return 1
}

cmd_whoami() {
  local out
  if out="$(whoami_json)"; then
    printf '%s\n' "$out"
    exit 0
  fi
  printf '%s\n' '{"error":"not-in-session"}'
  exit 1
}

cmd_sessions() {
  sessions_json
  exit 0
}

cmd_preflight() {
  local raw="${1:-}" busy busy_n list tgt slot="" verdict detail target_json number email us
  busy="$(sessions_json | jq -c '[.[] | select(.status == "busy" or .status == "unknown")]' 2>/dev/null)" || busy="[]"
  [ -n "$busy" ] || busy="[]"
  busy_n="$(printf '%s' "$busy" | jq 'length' 2>/dev/null)" || busy_n=0

  if [ -z "$raw" ]; then
    jq -cn --argjson busy "$busy" \
      '{verdict:"unknown-target",target:{number:null,email:null},busy:$busy,detail:"no target given"}'
    exit 0
  fi

  list="$(cswap list --json 2>/dev/null)"
  if [ -z "$list" ] || ! printf '%s' "$list" | jq -e . >/dev/null 2>&1; then
    jq -cn --argjson busy "$busy" --arg t "$raw" \
      '{verdict:"unknown-target",target:{number:null,email:null},busy:$busy,detail:("cswap list --json failed - cannot verify target " + ($t|tojson))}'
    exit 0
  fi

  # Resolve target: digits -> slot number; else alias in sequence.json; else email
  case "$raw" in
    *[!0-9]*)
      slot="$(jq -r --arg a "$raw" '.accounts | to_entries[] | select((.value.alias // "") == $a) | .key' \
        "$STATE_ROOT/sequence.json" 2>/dev/null | head -n1)" || slot=""
      ;;
    *) slot="$raw";;
  esac
  if [ -n "$slot" ]; then
    tgt="$(printf '%s' "$list" | jq -c --arg n "$slot" '[.accounts[]? | select((.number|tostring) == $n)] | first // empty' 2>/dev/null)"
  else
    tgt="$(printf '%s' "$list" | jq -c --arg e "$raw" '[.accounts[]? | select(((.email // "") | ascii_downcase) == ($e | ascii_downcase))] | first // empty' 2>/dev/null)"
  fi

  if [ -z "$tgt" ]; then
    jq -cn --argjson busy "$busy" --arg t "$raw" \
      '{verdict:"unknown-target",target:{number:null,email:null},busy:$busy,detail:("no account matches " + ($t|tojson) + " (not a slot number, alias, or email)")}'
    exit 0
  fi

  target_json="$(printf '%s' "$tgt" | jq -c '{number, email}')"
  number="$(printf '%s' "$tgt" | jq -r '.number')"
  email="$(printf '%s' "$tgt" | jq -r '.email // ""')"
  us="$(printf '%s' "$tgt" | jq -r '.usageStatus // ""' | tr '[:upper:]' '[:lower:]')"

  case "$us" in
    # Dead = needs a re-login. token_expired / unavailable / keychain_unavailable are
    # transient (cswap retries the refresh itself) and must never trigger one.
    *relogin*|*quarantin*|*invalid*|*revoked*|no_credentials)
      verdict="relogin-required"
      detail="account $number ($email) requires re-login (usageStatus: $us) - recover with: swap-guard login $number (never a plain /login: it kills the live account's token)"
      ;;
    *)
      if [ "$busy_n" -gt 0 ] 2>/dev/null; then
        verdict="busy"
        detail="account $number ($email) is healthy, but $busy_n other session(s) are busy or possibly-busy - a switch flips ALL sessions within ~30s"
      else
        verdict="ok"
        detail="account $number ($email) is healthy and no other session is busy"
      fi
      ;;
  esac

  jq -cn --arg v "$verdict" --argjson t "$target_json" --argjson busy "$busy" --arg d "$detail" \
    '{verdict:$v,target:$t,busy:$busy,detail:$d}'
  exit 0
}

cmd_path() {
  local kind="${1:-}"
  [ $# -gt 0 ] && shift
  local cwd_opt="" cwd
  cwd_opt="$(parse_cwd_opt "$@")" || cwd_opt=""
  cwd="$(resolve_cwd "$cwd_opt")"
  case "$kind" in
    flag)    flag_path_for "$cwd";;
    handoff) pending_path_for "$cwd";;
    # Where overcodex's SessionStart hook looks for a package in this cwd: same
    # header and hash convention, under the Codex home a new `codex` will use.
    codex-handoff)
      local ch; ch="$(_codex_active_home)"
      mkdir -p "$ch/overcodex" 2>/dev/null
      printf '%s/overcodex/handoff-pending-%s.md\n' "$ch" "$(hash_cwd "$cwd")";;
    *) usage;;
  esac
  exit 0
}

cmd_flag() {
  local input="${1:-}"
  if [ -z "$input" ] || ! printf '%s' "$input" | jq -e . >/dev/null 2>&1; then
    echo "swap-guard flag: input is not valid JSON" >&2
    exit 1
  fi
  local mode
  mode="$(printf '%s' "$input" | jq -r '.mode // empty')"
  case "$mode" in
    handoff|restart) ;;
    *) echo 'swap-guard flag: .mode must be "handoff" or "restart"' >&2; exit 1;;
  esac
  local sid cwd created out p tmp
  sid="$(printf '%s' "$input" | jq -r '.sessionId // ""')"
  cwd="$(printf '%s' "$input" | jq -r '.cwd // empty')"
  cwd="$(resolve_cwd "$cwd")"
  created="$(printf '%s' "$input" | jq -r '.created // empty' 2>/dev/null)"
  case "$created" in ''|*[!0-9]*) created="$(date +%s)";; esac
  out="$(jq -cn --arg m "$mode" --arg s "$sid" --arg c "$cwd" --argjson t "$created" \
    '{mode:$m,sessionId:$s,cwd:$c,created:$t}')"
  p="$(flag_path_for "$cwd")"
  mkdir -p "$STATE_ROOT" 2>/dev/null || { echo "swap-guard flag: cannot create $STATE_ROOT" >&2; exit 1; }
  tmp="$p.tmp.$$"
  if printf '%s\n' "$out" > "$tmp" 2>/dev/null && mv "$tmp" "$p" 2>/dev/null; then
    printf '%s\n' "$p"
    exit 0
  fi
  rm -f "$tmp" 2>/dev/null
  echo "swap-guard flag: write failed" >&2
  exit 1
}

# _status_json <sessionId-or-empty> <cwd> — build the status JSON (factored for testability)
_status_json() {
  local sid="${1:-}" cwd="${2:-$PWD}"
  local pct="" fired=0 bannered=0
  if [ -n "$sid" ] && [ -f "$CTX_DIR/$sid.json" ]; then
    pct="$(jq -r 'if (.pct|type) == "number" then (.pct|tostring) else "" end' "$CTX_DIR/$sid.json" 2>/dev/null)" || pct=""
  fi
  if [ -n "$sid" ] && [ -f "$CTX_DIR/$sid.state" ]; then
    fired="$(jq -r '.fired // 0' "$CTX_DIR/$sid.state" 2>/dev/null)" || fired=0
    bannered="$(jq -r '.bannered // 0' "$CTX_DIR/$sid.state" 2>/dev/null)" || bannered=0
  fi
  case "$fired" in ''|*[!0-9]*) fired=0;; esac
  case "$bannered" in ''|*[!0-9]*) bannered=0;; esac

  local h pending flagf pending_out="" page="" flag_out="" arch="" created
  h="$(hash_cwd "$cwd")"
  pending="$STATE_ROOT/handoff-pending-$h.md"
  flagf="$STATE_ROOT/relaunch-$h.json"
  if [ -f "$pending" ]; then
    pending_out="$pending"
    created="$(sed -n '1s/.*created="\([0-9][0-9]*\)".*/\1/p' "$pending" 2>/dev/null)"
    [ -n "$created" ] || created="$(stat -f %m "$pending" 2>/dev/null)"
    case "$created" in
      ''|*[!0-9]*) page="";;
      *) page="$(( $(date +%s) - created ))";;
    esac
  fi
  [ -f "$flagf" ] && flag_out="$flagf"
  if [ -d "$ARCHIVE_DIR" ]; then
    arch="$(ls "$ARCHIVE_DIR" 2>/dev/null | grep -E -- "-$h(-expired)?\.md$" | sort | tail -n1)"
    [ -n "$arch" ] && arch="$ARCHIVE_DIR/$arch"
  fi

  jq -cn --arg sid "$sid" --arg pct "$pct" --arg fired "$fired" --arg bannered "$bannered" \
    --arg pending "$pending_out" --arg page "$page" --arg flagp "$flag_out" --arg arch "$arch" '
    {sessionId: (if $sid == "" then null else $sid end),
     pct: (if $pct == "" then null else ($pct|tonumber) end),
     fired: ($fired|tonumber),
     bannered: ($bannered|tonumber),
     pendingHandoff: (if $pending == "" then null else $pending end),
     pendingAgeSec: (if $page == "" then null else ($page|tonumber) end),
     flag: (if $flagp == "" then null else $flagp end),
     latestArchive: (if $arch == "" then null else $arch end)}'
}

cmd_status() {
  local who sid="" cwd=""
  if who="$(whoami_json 2>/dev/null)"; then
    sid="$(printf '%s' "$who" | jq -r '.sessionId // empty' 2>/dev/null)" || sid=""
    cwd="$(printf '%s' "$who" | jq -r '.cwd // empty' 2>/dev/null)" || cwd=""
  fi
  [ -n "$cwd" ] || cwd="$PWD"
  _status_json "$sid" "$cwd"
  exit 0
}

cmd_cancel() {
  local cwd_opt="" cwd removed=0 f
  cwd_opt="$(parse_cwd_opt "$@")" || cwd_opt=""
  cwd="$(resolve_cwd "$cwd_opt")"
  for f in "$(pending_path_for "$cwd")" "$(flag_path_for "$cwd")"; do
    if [ -f "$f" ]; then
      rm -f "$f" 2>/dev/null && { printf 'removed: %s\n' "$f"; removed=1; }
    fi
  done
  [ "$removed" -eq 1 ] || printf 'nothing to remove for cwd %s\n' "$cwd"
  exit 0
}

# _history_files <cwd> <all:0|1> — archive paths, newest first (max 20).
# Archive names are <UTC-ts>-<cwdhash>[-expired].md, so the cwd filter is a name match.
_history_files() {
  local cwd="$1" all="$2" h
  [ -d "$ARCHIVE_DIR" ] || return 0
  h="$(hash_cwd "$cwd")"
  ls "$ARCHIVE_DIR" 2>/dev/null | grep -E '^[0-9]{8}-[0-9]{6}-[0-9a-f]{12}(-expired|-replaced)?\.md$' |
    { if [ "$all" = 1 ]; then cat; else grep -E -- "-$h(-expired|-replaced)?\.md$"; fi; } |
    sort -r | head -n 20 | sed "s|^|$ARCHIVE_DIR/|"
}

cmd_history() {
  local cwd_opt="" cwd all=0 a n=0 f base hdr pcwd pcreated title bytes expired out="[]"
  for a in "$@"; do [ "$a" = "--all" ] && all=1; done
  cwd_opt="$(parse_cwd_opt "$@")" || cwd_opt=""
  cwd="$(resolve_cwd "$cwd_opt")"
  while IFS= read -r f; do
    [ -n "$f" ] && [ -f "$f" ] || continue
    n=$((n + 1))
    base="${f##*/}"
    hdr="$(head -n 1 "$f")"
    pcwd="$(printf '%s' "$hdr" | sed -n 's/^<!-- handoff cwd="\(.*\)" created="[0-9][0-9]*" -->.*$/\1/p')"
    pcreated="$(printf '%s' "$hdr" | sed -n 's/^<!-- handoff cwd=".*" created="\([0-9][0-9]*\)" -->.*$/\1/p')"
    title="$(sed -n '2s/^# *//p' "$f" | cut -c1-120)"
    bytes="$(LC_ALL=C wc -c < "$f" | tr -d ' ')"
    # (no `case` inside $(...): bash 3.2 mis-parses its `)` patterns there)
    expired=false; case "$base" in *-expired.md) expired=true;; esac
    out="$(printf '%s' "$out" | jq -c --argjson n "$n" --arg path "$f" --arg cwd "$pcwd" \
      --arg created "$pcreated" --arg title "$title" --arg bytes "$bytes" \
      --arg arch "$(printf '%s' "$base" | sed -E 's/^([0-9]{4})([0-9]{2})([0-9]{2})-([0-9]{2})([0-9]{2})([0-9]{2}).*/\1-\2-\3T\4:\5:\6Z/')" \
      --argjson expired "$expired" '
      . + [{n: $n, path: $path, title: $title, cwd: (if $cwd == "" then null else $cwd end),
            created: (if $created == "" then null else ($created | tonumber) end),
            archivedAt: $arch, expired: $expired, bytes: ($bytes | tonumber)}]')"
  done <<EOF
$(_history_files "$cwd" "$all")
EOF
  printf '%s\n' "$out"
  exit 0
}

cmd_restore() {
  local cwd_opt="" cwd force=0 sel="" src="" hdr pcwd P created_old now age
  while [ $# -gt 0 ]; do
    case "$1" in
      --force) force=1;;
      --cwd) shift; cwd_opt="${1:-}";;
      --cwd=*) cwd_opt="${1#--cwd=}";;
      --*) :;;
      *) [ -z "$sel" ] && sel="$1";;
    esac
    shift
  done
  cwd="$(resolve_cwd "$cwd_opt")"
  case "$sel" in
    '') src="$(_history_files "$cwd" 0 | head -n 1)";;
    *[!0-9]*)
      # Paths are reduced to their basename and must look like an archive name, so
      # `..`, foreign dirs, and look-alike prefixes cannot escape the archive dir.
      # Restored text is injected as trusted context — symlinks are refused too.
      local b="${sel##*/}"
      if printf '%s' "$b" | grep -Eq '^[0-9]{8}-[0-9]{6}-[0-9a-f]{12}(-expired|-replaced)?\.md$' &&
         [ -f "$ARCHIVE_DIR/$b" ] && [ ! -L "$ARCHIVE_DIR/$b" ]; then
        src="$ARCHIVE_DIR/$b"
      fi;;
    *) src="$(_history_files "$cwd" 0 | sed -n "${sel}p")";;
  esac
  if [ -z "$src" ] || [ ! -f "$src" ] || [ -L "$src" ]; then
    jq -cn --arg cwd "$cwd" --arg sel "$sel" '{error:"not-found", cwd:$cwd, selector:$sel,
      detail:"no archived package matches; run swap-guard history (paths must be inside the archive dir)"}'
    exit 1
  fi
  hdr="$(head -n 1 "$src")"
  pcwd="$(printf '%s' "$hdr" | sed -n 's/^<!-- handoff cwd="\(.*\)" created="[0-9][0-9]*" -->.*$/\1/p')"
  if [ -z "$pcwd" ]; then
    jq -cn --arg src "$src" '{error:"malformed-header", path:$src}'; exit 1
  fi
  if [ "$pcwd" != "$cwd" ]; then
    jq -cn --arg src "$src" --arg pcwd "$pcwd" --arg cwd "$cwd" '{error:"cwd-mismatch", path:$src,
      packageCwd:$pcwd, cwd:$cwd, detail:"the startup injector only loads a package in its own directory: cd there, or pass --cwd <packageCwd>"}'
    exit 1
  fi
  P="$(pending_path_for "$cwd")"
  now="$(date +%s)"
  if [ -f "$P" ] && [ "$force" -ne 1 ]; then
    created_old="$(sed -n '1s/.*created="\([0-9][0-9]*\)".*/\1/p' "$P" 2>/dev/null)"
    case "$created_old" in ''|*[!0-9]*) age=0;; *) age=$(( now - created_old ));; esac
    if [ "$age" -lt 600 ]; then
      jq -cn --arg p "$P" --argjson age "$age" '{error:"pending-exists", pending:$p, pendingAgeSec:$age,
        detail:"a live pending handoff exists for this cwd; re-run with --force to replace it"}'
      exit 1
    fi
  fi
  mkdir -p "$STATE_ROOT" "$ARCHIVE_DIR" || exit 1
  # Build the new package BEFORE touching the old one (src may be re-read below).
  { printf '<!-- handoff cwd="%s" created="%s" -->\n' "$cwd" "$now"; tail -n +2 "$src"; } > "$P.tmp.$$" ||
    { rm -f "$P.tmp.$$"; exit 1; }
  # An existing pending package (live with --force, or expired but never archived
  # because no session started here since) is moved to the archive, never lost.
  local replaced=""
  if [ -f "$P" ]; then
    replaced="$ARCHIVE_DIR/$(date -u +%Y%m%d-%H%M%S)-$(hash_cwd "$cwd")-replaced.md"
    mv "$P" "$replaced" || { rm -f "$P.tmp.$$"; exit 1; }
  fi
  mv "$P.tmp.$$" "$P" || { rm -f "$P.tmp.$$"; exit 1; }
  jq -cn --arg src "$src" --arg p "$P" --arg cwd "$cwd" --argjson now "$now" --arg rep "$replaced" \
    '{restored:$src, pending:$p, cwd:$cwd, created:$now, expiresInSec:600,
      replacedArchivedTo:(if $rep == "" then null else $rep end)}'
  exit 0
}

# --- auto: busy-aware auto-switching ------------------------------------------
# `cswap auto` knows rate limits but not whether a Claude session is mid-turn.
# A tick asks cswap for a DRY-RUN decision first; a proactive switch is deferred
# while any live session is busy (swap-guard preflight), while an at-limit /
# failover switch proceeds (every session on that account is stalled anyway).
# The real switch is a non-dry `cswap auto --once`, which re-checks under its own
# state lock and records lastSwitchAt, so cswap's cooldown keeps working.
AUTO_LABEL="com.overclaude.autoswap"
AUTO_LOG="$STATE_ROOT/auto.log"
auto_plist() { printf '%s/Library/LaunchAgents/%s.plist\n' "$HOME" "$AUTO_LABEL"; }

_auto_log() { # _auto_log <json> — append + keep the last 500 lines
  mkdir -p "$STATE_ROOT" 2>/dev/null || return 0
  printf '%s\n' "$1" >> "$AUTO_LOG"
  if [ "$(wc -l < "$AUTO_LOG" 2>/dev/null || echo 0)" -gt 600 ]; then
    tail -n 500 "$AUTO_LOG" > "$AUTO_LOG.tmp.$$" && mv "$AUTO_LOG.tmp.$$" "$AUTO_LOG"
  fi
}

_auto_notify() { # _auto_notify <message>
  command -v osascript >/dev/null 2>&1 || return 0
  osascript -e "display notification \"$(printf '%s' "$1" | tr -d '"\\')\" with title \"overclaude auto-swap\"" >/dev/null 2>&1 || true
}

_auto_tick() {
  local notify=1 probe=0 cargs=() dry rc ev trigger to_num to_email pre verdict busy_n out real_rc now
  while [ $# -gt 0 ]; do
    case "$1" in
      --no-notify) notify=0;;
      --probe) probe=1;;
      --threshold|--model|--strategy|--cooldown) cargs+=("$1" "${2:-}"); shift;;
      *) :;;
    esac
    shift
  done
  command -v cswap >/dev/null 2>&1 || { printf '%s\n' '{"event":"error","detail":"cswap not found"}'; return 1; }
  now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
  local errf; errf="$(mktemp "${TMPDIR:-/tmp}/swap-guard-auto.XXXXXX")" || errf=/dev/null
  dry="$(cswap auto --once --dry-run --json ${cargs[@]+"${cargs[@]}"} 2>"$errf")"; rc=$?
  # argparse rejects bad arguments with rc 2 and no JSON on stdout — rc 2 alone is
  # indistinguishable from NO_ACTION, so treat "no events at all" as an error.
  if ! printf '%s\n' "$dry" | jq -e 'select(.event != null)' >/dev/null 2>&1; then
    out="$(jq -cn --arg ts "$now" --argjson rc "$rc" --arg err "$(tail -c 400 "$errf" 2>/dev/null)" \
      '{ts:$ts, action:"error", rc:$rc, detail:("cswap auto produced no decision: " + $err)}')"
    [ "$errf" = /dev/null ] || rm -f "$errf"
    _auto_log "$out"; printf '%s\n' "$out"; return 1
  fi
  [ "$errf" = /dev/null ] || rm -f "$errf"
  # --probe (used by `auto install`): the args parse and cswap decides — stop
  # here; a probe never switches and never writes the log.
  if [ "$probe" -eq 1 ]; then
    jq -cn --arg ts "$now" --argjson rc "$rc" '{ts:$ts, action:"probe-ok", rc:$rc}'; return 0
  fi
  ev="$(printf '%s\n' "$dry" | jq -c 'select(.event == "switch")' 2>/dev/null | tail -n 1)"
  if [ "$rc" -ne 0 ] || [ -z "$ev" ]; then
    # no-switch / blocked / error: log the last decisive event and pass cswap's exit code through
    out="$(printf '%s\n' "$dry" | jq -c --arg ts "$now" --argjson rc "$rc" \
      'select(.event != "poll") | {ts: $ts, action: "none", rc: $rc, event, reason, detail}' 2>/dev/null | tail -n 1)"
    [ -n "$out" ] || out="$(jq -cn --arg ts "$now" --argjson rc "$rc" '{ts:$ts, action:"none", rc:$rc}')"
    _auto_log "$out"; printf '%s\n' "$out"
    if [ "$rc" -eq 3 ] && [ "$notify" -eq 1 ]; then
      _auto_notify "Every account is at its limit — nothing to switch to."
    fi
    return "$rc"
  fi
  trigger="$(printf '%s' "$ev" | jq -r '.trigger // ""')"
  to_num="$(printf '%s' "$ev" | jq -r '.to.number // ""')"
  to_email="$(printf '%s' "$ev" | jq -r '.to.email // ""')"
  pre="$( (cmd_preflight "$to_num") 2>/dev/null)"
  verdict="$(printf '%s' "$pre" | jq -r '.verdict // "unknown"' 2>/dev/null)"
  busy_n="$(printf '%s' "$pre" | jq -r '(.busy // []) | length' 2>/dev/null)"
  case "$verdict" in
    relogin-required|unknown-target)
      out="$(jq -cn --arg ts "$now" --arg v "$verdict" --arg to "$to_num" '{ts:$ts, action:"skipped", reason:$v, to:$to}')"
      _auto_log "$out"; printf '%s\n' "$out"; return 3;;
    busy)
      if [ "$trigger" = "proactive" ] || [ "$trigger" = "consume-first" ]; then
        out="$(jq -cn --arg ts "$now" --arg to "$to_num" --arg t "$trigger" --argjson n "${busy_n:-0}" \
          '{ts:$ts, action:"deferred", reason:"busy", trigger:$t, to:$to, busySessions:$n}')"
        _auto_log "$out"; printf '%s\n' "$out"; return 2
      fi;;
  esac
  out="$(cswap auto --once --json ${cargs[@]+"${cargs[@]}"} 2>/dev/null)"; real_rc=$?
  ev="$(printf '%s\n' "$out" | jq -c 'select(.event == "switch")' 2>/dev/null | tail -n 1)"
  if [ "$real_rc" -eq 0 ] && [ -n "$ev" ]; then
    to_num="$(printf '%s' "$ev" | jq -r '.to.number // ""')"
    to_email="$(printf '%s' "$ev" | jq -r '.to.email // ""')"
    out="$(printf '%s' "$ev" | jq -c --arg ts "$now" --arg v "$verdict" --argjson n "${busy_n:-0}" \
      '{ts:$ts, action:"switched", trigger, from:.from.number, to:.to.number, warnings, preflight:$v, busySessions:$n}')"
    _auto_log "$out"; printf '%s\n' "$out"
    if [ "$notify" -eq 1 ]; then
      local label
      label="$(jq -r --arg n "$to_num" '.accounts[$n].alias // empty' "$STATE_ROOT/sequence.json" 2>/dev/null)"
      _auto_notify "Switched to ${label:-${to_email%%@*}} ($trigger). A paused session continues when you send any message."
    fi
    return 0
  fi
  out="$(printf '%s\n' "$out" | jq -c --arg ts "$now" --argjson rc "$real_rc" \
    'select(.event != "poll") | {ts:$ts, action:"none", rc:$rc, event, reason, detail, note:"dry-run wanted a switch; recheck declined"}' 2>/dev/null | tail -n 1)"
  [ -n "$out" ] || out="$(jq -cn --arg ts "$now" --argjson rc "$real_rc" '{ts:$ts, action:"none", rc:$rc}')"
  _auto_log "$out"; printf '%s\n' "$out"
  return "$real_rc"
}

_auto_install() {
  local interval=180 targs=() plist path_env d self uid a
  while [ $# -gt 0 ]; do
    case "$1" in
      --interval) interval="${2:-180}"; shift;;
      --threshold|--model) targs+=("$1" "${2:-}"); shift;;
      --no-notify) targs+=("$1");;
    esac
    shift
  done
  case "$interval" in ''|*[!0-9]*) echo "auto install: --interval must be whole seconds" >&2; return 1;; esac
  [ "$interval" -ge 60 ] || interval=60
  # Validate up front: a bad value would otherwise fail silently on every tick.
  local i=0 v
  while [ "$i" -lt "${#targs[@]}" ]; do
    case "${targs[$i]}" in
      --threshold)
        v="${targs[$((i + 1))]:-}"
        if ! printf '%s' "$v" | grep -Eq '^[0-9]+(\.[0-9]+)?$' ||
           ! awk -v t="$v" 'BEGIN { exit !(t >= 50 && t <= 99.9) }'; then
          echo "auto install: --threshold must be a number from 50 to 99.9 (got '$v')" >&2; return 1
        fi; i=$((i + 2));;
      --model)
        v="${targs[$((i + 1))]:-}"
        printf '%s' "$v" | grep -Eq '^[A-Za-z0-9_,.-]+$' ||
          { echo "auto install: --model must be names like Fable or 'Fable,Opus' (got '$v')" >&2; return 1; }
        i=$((i + 2));;
      *) i=$((i + 1));;
    esac
  done
  self="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
  # launchd's PATH is minimal: pin the dirs of every tool a tick needs, in priority
  # order — the cswap that install.sh manages first, so a stale brew/pip copy
  # elsewhere can never shadow it.
  path_env=""
  for d in "$(dirname "$(command -v cswap 2>/dev/null || echo /usr/bin/cswap)")" "$(dirname "$self")" \
           "$(dirname "$(command -v jq 2>/dev/null || echo /usr/bin/jq)")" /opt/homebrew/bin /usr/local/bin \
           /usr/bin /bin /usr/sbin /sbin; do
    case ":$path_env:" in *":$d:"*) :;; *) path_env="${path_env:+$path_env:}$d";; esac
  done
  # Dry-run one tick with these exact args before installing anything.
  local probe
  probe="$(PATH="$path_env" _auto_tick --probe --no-notify ${targs[@]+"${targs[@]}"} 2>/dev/null | tail -n 1)"
  if [ "$(printf '%s' "$probe" | jq -r '.action // "error"' 2>/dev/null)" != "probe-ok" ]; then
    echo "auto install: a test tick failed, not installing: $probe" >&2; return 1
  fi
  plist="$(auto_plist)"
  mkdir -p "$(dirname "$plist")" "$STATE_ROOT" || return 1
  xml() { printf '%s' "$1" | sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g' -e 's/"/\&quot;/g'; }
  {
    printf '%s\n' '<?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>$AUTO_LABEL</string>" \
      '  <key>ProgramArguments</key><array>' \
      "    <string>$(xml "$self")</string><string>auto</string><string>tick</string>"
    for a in ${targs[@]+"${targs[@]}"}; do printf '    <string>%s</string>\n' "$(xml "$a")"; done
    # stdout -> /dev/null: every tick already logs itself to the (trimmed) auto.log;
    # stderr is kept for genuine crashes and stays tiny.
    printf '%s\n' '  </array>' \
      "  <key>StartInterval</key><integer>$interval</integer>" \
      '  <key>RunAtLoad</key><true/>' \
      "  <key>EnvironmentVariables</key><dict><key>PATH</key><string>$(xml "$path_env")</string><key>HOME</key><string>$(xml "$HOME")</string></dict>" \
      '  <key>StandardOutPath</key><string>/dev/null</string>' \
      "  <key>StandardErrorPath</key><string>$(xml "$STATE_ROOT/auto-launchd.err")</string>" \
      '</dict></plist>'
  } > "$plist.tmp.$$" || { rm -f "$plist.tmp.$$"; return 1; }
  # Lint BEFORE replacing, so a bad write never clobbers a working plist.
  plutil -lint "$plist.tmp.$$" >/dev/null 2>&1 ||
    { echo "auto install: generated plist failed plutil -lint (kept: $plist.tmp.$$)" >&2; return 1; }
  mv "$plist.tmp.$$" "$plist" || return 1
  if [ -n "${SWAP_GUARD_NO_LAUNCHCTL:-}" ]; then  # tests: generate + lint only
    jq -cn --arg p "$plist" --argjson i "$interval" '{installed:false, generated:true, plist:$p, intervalSec:$i}'
    return 0
  fi
  uid="$(id -u)"
  launchctl bootout "gui/$uid/$AUTO_LABEL" >/dev/null 2>&1 || true
  if launchctl bootstrap "gui/$uid" "$plist" 2>/dev/null; then
    jq -cn --arg p "$plist" --argjson i "$interval" --arg a "${targs[*]+${targs[*]}}" \
      '{installed:true, plist:$p, intervalSec:$i, args:$a}'
  else
    echo "auto install: launchctl bootstrap failed (plist written: $plist)" >&2; return 1
  fi
}

_auto_uninstall() {
  local plist uid removed=false
  plist="$(auto_plist)"; uid="$(id -u)"
  launchctl bootout "gui/$uid/$AUTO_LABEL" >/dev/null 2>&1 && removed=true
  [ -f "$plist" ] && rm -f "$plist" && removed=true
  jq -cn --argjson r "$removed" '{uninstalled:$r}'
}

_auto_status() {
  local plist uid loaded=false last=""
  plist="$(auto_plist)"; uid="$(id -u)"
  launchctl print "gui/$uid/$AUTO_LABEL" >/dev/null 2>&1 && loaded=true
  [ -f "$AUTO_LOG" ] && last="$(tail -n 5 "$AUTO_LOG" | jq -sc '.' 2>/dev/null)"
  jq -cn --argjson loaded "$loaded" --arg plist "$plist" --argjson exists "$([ -f "$plist" ] && echo true || echo false)" \
    --argjson last "${last:-[]}" --arg log "$AUTO_LOG" \
    '{loaded:$loaded, plistExists:$exists, plist:$plist, log:$log, recent:$last}'
}

cmd_auto() {
  local sub="${1:-status}"; [ $# -gt 0 ] && shift
  case "$sub" in
    tick)      _auto_tick "$@"; exit $?;;
    install)   _auto_install "$@"; exit $?;;
    uninstall) _auto_uninstall; exit 0;;
    status)    _auto_status; exit 0;;
    instant)
      case "${1:-status}" in
        on)  rm -f "$INSTANT_OFF";;
        off) mkdir -p "$STATE_ROOT" && touch "$INSTANT_OFF";;
        status) :;;
        *) echo "usage: swap-guard auto instant on|off|status" >&2; exit 1;;
      esac
      jq -cn --argjson on "$([ -f "$INSTANT_OFF" ] && echo false || echo true)" '{instantSwap:$on}'; exit 0;;
    *) echo "usage: swap-guard auto tick|install|uninstall|status|instant" >&2; exit 1;;
  esac
}

# cmd_artifacts — snapshot every claude.ai artifact this session (and its subagents /
# workflows) published or touched, so a handoff can carry them across sessions and
# accounts. Source of truth is the transcript's structured toolUseResult
# ({url, artifact_id, title, path, ...}) — never the model's recollection. Sources
# live in the session-scoped /tmp scratchpad, so they are COPIED to a durable dir.
# Artifacts are owned server-side by the account that published them; the manifest
# records the account active at collection time as `account`.
cmd_artifacts() {
  local sid="" cwd="" who flat tdir out_dir manifest tmp acct now
  while [ $# -gt 0 ]; do
    case "$1" in
      --session) sid="${2:-}"; shift 2 || shift;;
      --cwd)     cwd="${2:-}"; shift 2 || shift;;
      *) shift;;
    esac
  done
  if [ -z "$sid" ] || [ -z "$cwd" ]; then
    who="$(whoami_json 2>/dev/null)" || who=""
    [ -n "$sid" ] || sid="$(printf '%s' "$who" | jq -r '.sessionId // empty' 2>/dev/null)"
    [ -n "$cwd" ] || cwd="$(printf '%s' "$who" | jq -r '.cwd // empty' 2>/dev/null)"
  fi
  [ -n "$cwd" ] || cwd="$PWD"
  case "$sid" in ''|*/*|*..*) printf '%s\n' '{"error":"no-session-id"}'; exit 1;; esac
  flat="$(printf '%s' "$cwd" | sed 's/[^A-Za-z0-9]/-/g')"
  tdir="$HOME/.claude/projects/$flat"
  [ -f "$tdir/$sid.jsonl" ] || { printf '{"error":"no-transcript","path":"%s"}\n' "$tdir/$sid.jsonl"; exit 1; }

  out_dir="$ARTIFACTS_DIR/$sid"
  mkdir -p "$out_dir" || exit 1
  acct="$(jq -r '.oauthAccount.emailAddress // empty' "$HOME/.claude.json" 2>/dev/null)"
  now="$(date +%s)"

  # One record per artifact_id (last touch wins). Join each result with its tool_use
  # input to recover supporting `files` (map form: pub -> src | {from}).
  tmp="$(mktemp)"
  { printf '%s\n' "$tdir/$sid.jsonl"
    [ -d "$tdir/$sid" ] && find "$tdir/$sid" -name '*.jsonl' -type f
  } | while IFS= read -r f; do
    jq -c '
      if .type == "assistant" then
        (.message.content[]? | select(.type == "tool_use" and .name == "Artifact")
         | {k: "use", id, files: (.input.files // null), file_path: (.input.file_path // null)})
      elif .type == "user" and (.toolUseResult | type) == "object"
           and ((.toolUseResult.url // "") | test("^https://claude\\.ai/(code/)?artifact/")) then
        {k: "res", id: (.message.content[0].tool_use_id // null), ts: (.timestamp // ""),
         r: (.toolUseResult | {url, artifact_id, title, path, audience, capabilities})}
      else empty end' "$f" 2>/dev/null
  done > "$tmp"

  jq -s '
    (map(select(.k == "use")) | map({key: (.id // ""), value: .}) | from_entries) as $uses
    | map(select(.k == "res" and (.r.url // "") != ""))
    | sort_by(.ts)
    | map(. as $x | $x.r + {ts: $x.ts,
          path: ($x.r.path // $uses[$x.id // ""].file_path // null),
          files: ($uses[$x.id // ""].files // null)})
    | map(.artifact_id = (.artifact_id // (.url | sub(".*/artifact/"; ""))))
    | group_by(.artifact_id)
    | map(reduce .[] as $e ({}; . + ($e | with_entries(select(.value != null)))))
    | sort_by(.ts)' "$tmp" > "$tmp.recs" 2>/dev/null || printf '[]' > "$tmp.recs"
  rm -f "$tmp"
  # Per-artifact owner from the registry (the account active when it was published);
  # null when the artifact predates the registry hook.
  _art_catalog > "$tmp.cat"
  jq --slurpfile catf "$tmp.cat" '$catf[0] as $cat | map(. as $r | (first($cat[] | select(.artifact_id == $r.artifact_id)) // {}) as $c
      | . + {owner: ($c.account // null), ownerAlias: ($c.alias // null)})' "$tmp.recs" > "$tmp.recs2" 2>/dev/null &&
    mv "$tmp.recs2" "$tmp.recs"
  rm -f "$tmp.cat"

  # Copy sources: page -> <id>/<basename>; supporting files -> <id>/<published path>.
  local n i rec aid src dst saved files_saved pub fsrc
  n="$(jq 'length' "$tmp.recs")"
  printf '[]' > "$tmp.out"
  i=0
  while [ "$i" -lt "$n" ]; do
    rec="$(jq -c ".[$i]" "$tmp.recs")"
    aid="$(printf '%s' "$rec" | jq -r '.artifact_id' | tr -cd 'A-Za-z0-9_-')"
    src="$(printf '%s' "$rec" | jq -r '.path // empty')"
    saved="null"; files_saved="{}"
    if [ -n "$aid" ] && [ -n "$src" ] && [ -f "$src" ]; then
      mkdir -p "$out_dir/$aid"
      dst="$out_dir/$aid/${src##*/}"
      _store_copy "$src" "$dst" && saved="$(jq -Rn --arg d "$dst" '$d')"
      while IFS=$'\t' read -r pub fsrc; do
        [ -n "$pub" ] && [ -n "$fsrc" ] || continue
        case "$pub" in /*|*..*) continue;; esac
        case "$fsrc" in /*) :;; *) fsrc="$cwd/$fsrc";; esac
        [ -f "$fsrc" ] || continue
        _store_copy "$fsrc" "$out_dir/$aid/$pub" &&
          files_saved="$(printf '%s' "$files_saved" | jq -c --arg k "$pub" --arg v "$out_dir/$aid/$pub" '. + {($k): $v}')"
      done < <(printf '%s' "$rec" | jq -r '
        (.files // {}) | if type == "object" then to_entries[]
          | [.key, (if (.value | type) == "string" then .value
                    elif (.value | type) == "object" then (.value.from // empty)
                    else empty end)] | @tsv else empty end' 2>/dev/null)
    fi
    jq --argjson r "$rec" --argjson s "$saved" --argjson fs "$files_saved" \
      '. + [$r + {saved: $s, savedFiles: $fs} | del(.files)]' "$tmp.out" > "$tmp.out2" && mv "$tmp.out2" "$tmp.out"
    i=$((i + 1))
  done

  manifest="$out_dir/manifest.json"
  jq -n --arg sid "$sid" --arg cwd "$cwd" --arg acct "$acct" --argjson now "$now" \
     --slurpfile a "$tmp.out" \
     '{sessionId: $sid, cwd: $cwd, account: $acct, collected: $now, artifacts: $a[0]}' > "$manifest.tmp" &&
    mv "$manifest.tmp" "$manifest"
  rm -f "$tmp.recs" "$tmp.out"
  # Keep the store bounded: drop snapshots untouched for 30+ days.
  find "$ARTIFACTS_DIR" -mindepth 1 -maxdepth 1 -type d -mtime +30 -exec rm -rf {} + 2>/dev/null
  jq -c --arg m "$manifest" '. + {manifest: $m}' "$manifest"
  exit 0
}

# --- shared: usage windows --------------------------------------------------------
# jq helpers over cswap's ISO timestamps ("...+00:00" with microseconds).
JQ_EPOCH='def ep: if type == "string" and test("(Z|\\+00:00)$")
  then (sub("\\.[0-9]+"; "") | sub("\\+00:00$"; "Z") | try (fromdateiso8601 | floor) catch null)
  else null end;'

# --- ratelimit: instant swap when a usage cap stops a session ---------------------
# Wired as a StopFailure hook (matcher rate_limit, asyncRewake). StopFailure itself
# is observe-only, so this hook does the switch; exiting 2 under asyncRewake wakes
# the stopped session with the stderr text so it can carry on.
# Only usage CAPS swap ("You've reached your Fable limit…"); a transient 429
# throttle ("Rate limit reached") is logged and ignored.
RL_COOLDOWN=90      # sessions that hit the cap within this window reuse one swap
RL_WAKE_GAP=600     # wake a given session at most once per this many seconds (loop guard)
RL_LAST="$STATE_ROOT/ratelimit-last.json"
INSTANT_OFF="$STATE_ROOT/instant-swap.off"

_rl_is_cap() {
  case "$1" in
    *"reached your"*|*"usage limit"*|*"/usage-credits"*|*"/upgrade"*|*"/extra-usage"*|*"resets "*) return 0;;
  esac
  return 1
}

_rl_model() { # the model a cap message names ("reached your Fable limit" -> Fable), else ""
  local m lc
  m="$(printf '%s' "$1" | sed -n "s/.*reached your \([A-Za-z0-9][A-Za-z0-9._-]*\) limit.*/\1/p" | head -n 1)"
  lc="$(printf '%s' "$m" | tr '[:upper:]' '[:lower:]')"
  case "$lc" in usage|weekly|session|daily|5-hour|five-hour|hourly) m="";; esac
  printf '%s\n' "$m"
}

# _rl_pick <cswap-list-json> <model> <now> — best OTHER account with room, as JSON.
# Room = every relevant window under 100% (a window whose reset passed counts as 0).
# Skips disabled accounts, dead tokens and API-key accounts (those bill per use).
RL_CAPPED="$STATE_ROOT/ratelimit-capped.json"   # [{slot, model, until}] — accounts that hit a cap
_rl_pick() {
  local capped; capped="$(jq -c 'if type == "array" then . else [] end' "$RL_CAPPED" 2>/dev/null)" || capped="[]"
  [ -n "$capped" ] || capped="[]"
  printf '%s' "$1" | jq -c --arg model "$2" --argjson now "$3" --argjson capped "$capped" "$JQ_EPOCH"'
    def pct($w): if $w == null then 0
      elif (($w.resetsAt // "") | ep) as $r | ($r != null and $r <= $now) then 0
      else ($w.pct // 0) end;
    (.activeAccountNumber // null) as $act
    | [.accounts[]?
       | select(.number != $act and ((.disabled // false) | not))
       | (.number | tostring) as $n
       | select([$capped[] | select(.slot == $n and .until > $now
                 and (.model == "" or (.model | ascii_downcase) == ($model | ascii_downcase)))] | length == 0)
       | select(.usageStatus as $s | ["ok", "token_expired", "unavailable"] | index($s))
       | (.usage // .lastGoodUsage) as $u | select($u != null)
       | {number, email, alias, h5: pct($u.fiveHour), d7: pct($u.sevenDay),
          m: (if $model == "" then 0
              else pct([$u.scoped[]? | select((.name | ascii_downcase) == ($model | ascii_downcase))] | first)
              end)}
       | .score = ([.h5, .d7, .m] | max) | select(.score < 100)]
    # Ties (same binding window) go to the account with more of the capped model left,
    # then more of its week.
    | sort_by([.score, .m, .d7, .h5]) | first // empty' 2>/dev/null
}

_label_for() { # _label_for <slot> — alias, else the email's local part, else "account N"
  local l
  l="$(jq -r --arg n "$1" '.accounts[$n] | (.alias // ((.email // "") | split("@")[0])) // empty' \
    "$STATE_ROOT/sequence.json" 2>/dev/null)"
  printf '%s\n' "${l:-account $1}"
}

_rl_wake() { # _rl_wake <sid> <text> — print text for Claude and return 0 if this session may be woken
  local sid="$1" f last now
  [ -n "$sid" ] || return 1
  now="$(date +%s)"; f="$CTX_DIR/$sid.rlwake"
  last="$(cat "$f" 2>/dev/null)"
  case "$last" in ''|*[!0-9]*) last=0;; esac
  [ $(( now - last )) -ge "$RL_WAKE_GAP" ] || return 1
  mkdir -p "$CTX_DIR" 2>/dev/null && printf '%s\n' "$now" > "$f"
  printf '%s\n' "$2" >&2
  return 0
}

cmd_ratelimit() {
  local notify=1 print=0 dry=0 input sid err msg model now out list pick to from to_l from_l lock i sw rc wake_txt
  while [ $# -gt 0 ]; do
    case "$1" in --no-notify) notify=0;; --print) print=1;; --dry-run) dry=1;; esac
    shift
  done
  input="$(cat 2>/dev/null)" || input=""
  command -v jq >/dev/null 2>&1 || exit 0
  sid="$(printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null | tr -cd 'A-Za-z0-9_-')"
  err="$(printf '%s' "$input" | jq -r '.error // empty' 2>/dev/null)"
  msg="$(printf '%s' "$input" | jq -r '[.last_assistant_message // empty, .error_details // empty] | join(" ")' 2>/dev/null)"
  now="$(date +%s)"
  _rl_out() { # _rl_out <json> — log; echo only with --print (hook stdout must stay empty)
    [ "$dry" -eq 1 ] || _auto_log "$1"; [ "$print" -eq 1 ] && printf '%s\n' "$1"; return 0
  }
  [ "$err" = rate_limit ] || exit 0
  if [ -f "$INSTANT_OFF" ]; then
    _rl_out "$(jq -cn --argjson ts "$now" '{ts:($ts|todate), source:"ratelimit", action:"skipped", reason:"instant-swap-off"}')"; exit 0
  fi
  if ! _rl_is_cap "$msg"; then
    _rl_out "$(jq -cn --argjson ts "$now" --arg m "$(printf '%s' "$msg" | cut -c1-160)" \
      '{ts:($ts|todate), source:"ratelimit", action:"ignored", reason:"transient-429", message:$m}')"; exit 0
  fi
  model="$(_rl_model "$msg")"
  command -v cswap >/dev/null 2>&1 || {
    _rl_out "$(jq -cn --argjson ts "$now" '{ts:($ts|todate), source:"ratelimit", action:"error", detail:"cswap not found"}')"; exit 0; }

  # One swap for a burst: several sessions on the capped account fail together.
  mkdir -p "$STATE_ROOT" "$CTX_DIR" 2>/dev/null
  lock="$STATE_ROOT/ratelimit.lock"
  i=0
  until mkdir "$lock" 2>/dev/null; do
    if [ -n "$(find "$lock" -maxdepth 0 -mmin +2 2>/dev/null)" ]; then rmdir "$lock" 2>/dev/null; continue; fi
    i=$((i + 1))
    if [ "$i" -ge 60 ]; then
      # Another hook held the lock the whole time (slow cswap): if it switched, wake onto that.
      local lt; lt="$(jq -r '.ts // 0' "$RL_LAST" 2>/dev/null)"; case "$lt" in ''|*[!0-9]*) lt=0;; esac
      [ $(( $(date +%s) - lt )) -lt "$RL_COOLDOWN" ] && [ "$dry" -eq 0 ] &&
        _rl_wake "$sid" "overclaude: the ${model:-usage} limit stopped this session; the account was just switched to $(_label_for "$(jq -r '.to // empty' "$RL_LAST" 2>/dev/null)"). Continue the task from where it stopped. If the same limit error comes back, stop and tell the user instead of retrying." && exit 2
      exit 0
    fi
    sleep 1
  done
  trap 'rmdir "$lock" 2>/dev/null' EXIT

  local last_ts last_to
  last_ts="$(jq -r '.ts // 0' "$RL_LAST" 2>/dev/null)"; case "$last_ts" in ''|*[!0-9]*) last_ts=0;; esac
  if [ $(( now - last_ts )) -lt "$RL_COOLDOWN" ]; then
    last_to="$(jq -r '.to // empty' "$RL_LAST" 2>/dev/null)"
    _rl_out "$(jq -cn --argjson ts "$now" --arg to "$last_to" --arg s "$sid" \
      '{ts:($ts|todate), source:"ratelimit", action:"reused", to:$to, session:$s}')"
    rmdir "$lock" 2>/dev/null; trap - EXIT
    [ "$dry" -eq 1 ] && exit 0
    _rl_wake "$sid" "overclaude: the ${model:-usage} limit stopped this session; the account was already switched to $(_label_for "$last_to") moments ago. Continue the task from where it stopped. If the same limit error comes back, stop and tell the user instead of retrying." && exit 2
    exit 0
  fi

  list="$(cswap list --json 2>/dev/null)"
  from="$(printf '%s' "$list" | jq -r '.activeAccountNumber // empty' 2>/dev/null)"
  from_l="$(_label_for "$from")"
  pick="$(_rl_pick "$list" "$model" "$now")"
  if [ -z "$pick" ]; then
    _rl_out "$(jq -cn --argjson ts "$now" --arg m "$model" --arg f "$from" \
      '{ts:($ts|todate), source:"ratelimit", action:"none", reason:"no-account-with-room", model:$m, from:$f}')"
    [ "$notify" -eq 1 ] && [ "$dry" -eq 0 ] &&
      _auto_notify "${model:-Usage} limit on $from_l, and no other account has room$(command -v codex >/dev/null 2>&1 && echo ' — /handoff codex continues in Codex' || echo ' (or they need: swap-guard login)')."
    exit 0
  fi
  to="$(printf '%s' "$pick" | jq -r '.number')"
  to_l="$(_label_for "$to")"
  if [ "$dry" -eq 1 ]; then
    _rl_out "$(printf '%s' "$pick" | jq -c --argjson ts "$now" --arg m "$model" --arg f "$from" \
      '{ts:($ts|todate), source:"ratelimit", action:"would-switch", model:$m, from:$f, to:(.number|tostring), score}')"
    exit 0
  fi
  sw="$(cswap switch "$to" --json 2>/dev/null)"; rc=$?
  # cswap exits 0 for a no-op too ({"switched": false, "reason": "already-active"}),
  # e.g. when its idea of the active account is stale — that is not a switch.
  if [ "$rc" -eq 0 ] && [ "$(printf '%s' "$sw" | jq -r 'if .switched == false then "no" else "yes" end' 2>/dev/null)" = no ]; then
    rc=4; sw="no switch: $(printf '%s' "$sw" | jq -r '.reason // "unknown"' 2>/dev/null)"
  fi
  if [ "$rc" -ne 0 ]; then
    _rl_out "$(jq -cn --argjson ts "$now" --arg to "$to" --argjson rc "$rc" --arg d "$(printf '%s' "$sw" | tail -c 300)" \
      '{ts:($ts|todate), source:"ratelimit", action:"error", to:$to, rc:$rc, detail:$d}')"
    [ "$notify" -eq 1 ] && _auto_notify "${model:-Usage} limit on $from_l — switching to $to_l failed (see auto.log)."
    exit 0
  fi
  jq -cn --argjson ts "$now" --arg f "$from" --arg to "$to" --arg m "$model" \
    '{ts:$ts, from:$f, to:$to, model:$m}' > "$RL_LAST.tmp.$$" && mv "$RL_LAST.tmp.$$" "$RL_LAST"
  # Remember that <from> is capped on <model> until its window resets (5 h if unknown),
  # so a later cap elsewhere never hops back onto it on stale cached numbers.
  printf '%s' "$list" | jq -c --arg f "$from" --arg m "$model" --argjson now "$now" \
      --argjson prev "$(jq -c 'if type == "array" then . else [] end' "$RL_CAPPED" 2>/dev/null || echo '[]')" "$JQ_EPOCH"'
    (first(.accounts[]? | select((.number | tostring) == $f)) // {}) as $a | ($a.usage // $a.lastGoodUsage // {}) as $u
    | (if $m == "" then ([$u.fiveHour, $u.sevenDay] | map(select(. != null and (.pct // 0) >= 100))[0])
       else ([$u.scoped[]? | select((.name | ascii_downcase) == ($m | ascii_downcase))] | first) end) as $w
    | ((($w // {}).resetsAt // "") | ep) as $r
    | [$prev[] | select(.until > $now and (.slot != $f or .model != $m))]
      + [{slot: $f, model: $m, until: (if $r != null and $r > $now then $r else $now + 18000 end)}]' \
    > "$RL_CAPPED.tmp.$$" 2>/dev/null && mv "$RL_CAPPED.tmp.$$" "$RL_CAPPED"
  _rl_out "$(printf '%s' "$pick" | jq -c --argjson ts "$now" --arg m "$model" --arg f "$from" --arg s "$sid" \
    '{ts:($ts|todate), source:"ratelimit", action:"switched", model:$m, from:$f, to:(.number|tostring), score, session:$s}')"
  rmdir "$lock" 2>/dev/null; trap - EXIT
  [ "$notify" -eq 1 ] &&
    _auto_notify "${model:-Usage} limit on $from_l → switched to $to_l. If the session does not resume by itself, send any message."
  wake_txt="overclaude: the ${model:-usage} limit stopped this session, so the account was switched from $from_l to $to_l (now $(printf '%s' "$pick" | jq -r --arg m "$model" '"5h \(.h5)% · week \(.d7)%" + (if $m != "" then " · \($m) \(.m)%" else "" end)')). Continue the task from where it stopped. If the same limit error comes back, stop and tell the user instead of retrying."
  _rl_wake "$sid" "$wake_txt" && exit 2
  exit 0
}

# --- budget: tell the model when usage crosses a band (UserPromptSubmit hook) ------
# ULTRACODE's budget-pressure rules key off "Fable ~75%+", a number the model cannot
# see. `budget --hook` injects one line when the active account crosses a band
# (scoped/Fable 75/90/100, week 85/95/100, 5h 90/100) and one when pressure clears.
# Silent otherwise — at most one note per band change per session.
_budget_eval() { # prints: key<US>pressure-message<US>cleared-message
  local seq="$STATE_ROOT/sequence.json" cache="$STATE_ROOT/cache/usage.json"
  [ -r "$seq" ] || return 1
  [ -r "$cache" ] || cache=/dev/null
  jq -rn --slurpfile s "$seq" --slurpfile c "$cache" --argjson now "$(date +%s)" "$JQ_EPOCH"'
    def band($p; $ts): ([$ts[] | select($p >= .)] | max) // 0;
    def eff($w): if $w == null then null
      elif (($w.resets_at // "") | ep) as $r | ($r != null and $r <= $now) then 0
      else ($w.pct // null) end;
    def when($w): (($w.resets_at // "") | ep) as $r
      | if $r == null or $r <= $now then "" else " (resets \($r | strflocaltime("%a %H:%M")))" end;
    def p0: if . == null then "n/a" else "\(. | floor)%" end;
    ($s[0] // {}) as $seq | (($c[0] // {}).accounts // {}) as $acc
    | (($seq.activeAccountNumber // "") | tostring) as $n
    | def lbl($k): ($seq.accounts[$k] // {}) | (.alias // ((.email // "?") | split("@")[0]));
      def wins($k): (($acc[$k].lastGood) // {}) as $g
        | [{name: "5h", w: $g.five_hour, t: [90, 100]},
           {name: "week", w: $g.seven_day, t: [85, 95, 100]}]
          + [$g.scoped[]? | {name, w: ., t: [75, 90, 100]}]
        | map(. + {p: eff(.w)} | . + {b: (if .p == null then 0 else band(.p; .t) end)});
    if $n == "" then empty else
    wins($n) as $ws
    | ($ws | map(select(.b > 0))) as $hot
    | ($n + "|" + ($ws | map("\(.name)=\(.b)") | join(","))) as $key
    | ([$seq.accounts | keys[] | select(. != $n)
        | select(((($acc[.].lastError // "") == "invalid_grant") or (($acc[.].authDeadStrikes // 0) > 0)) | not)
        | select((($seq.accounts[.].disabled // false) or (($seq.accounts[.].kind // "") == "api_key")) | not)
        | select($acc[.].lastGood != null)
        | . as $k | {k: $k, ws: wins($k)}
        | [.ws[] | select(.name == "5h" or .name == "week" or (.name as $nm | $hot | any(.name == $nm)))] as $rel
        | select(($rel | length) > 0 and ($rel | all(.p != null)))
        | . + {worst: ($rel | map(.p) | max)}]
       | sort_by(.worst) | first) as $alt
    | (if ($hot | length) == 0 then "" else
        "[overclaude budget] Account \(lbl($n)): "
        + ($hot | map("\(.name) \(.p | p0) used\(when(.w))") | join(", ")) + ". "
        + (if ($hot | any(.b >= 100 and (.name == "5h" or .name == "week")))
           then "This account is out of quota until the reset: every call will fail. "
           elif ($hot | any(.b >= 100))
           then "\($hot | map(select(.b >= 100) | .name) | join("/")) is exhausted: calls on it will fail, so route agents to opus/sonnet. "
           else "" end)
        + "ULTRACODE budget pressure applies: cut fan-out width and batch more per agent first, keep the scarce tier as a read-only reserve for the final synthesis, and keep verifier floors."
        + (if $alt != null and $alt.worst < 75
           then " Account \(lbl($alt.k)) has room (\($alt.ws | map(select(.name == "week" or (.name as $nm | $hot | any(.name == $nm))) | "\(.name) \(.p | p0)") | join(", "))): the user can /swap \(lbl($alt.k))."
           else "" end)
      end) as $msg
    | ("[overclaude budget] Budget pressure cleared: account \(lbl($n)) is at "
       + ($ws | map("\(.name) \(.p | p0)") | join(", ")) + ". Normal ULTRACODE routing applies.") as $clr
    | [$key, $msg, $clr] | join("\u001f") end' 2>/dev/null
}

cmd_budget() {
  local hook=0 input sid line key msg clr f prev
  for a in "$@"; do [ "$a" = "--hook" ] && hook=1; done
  command -v jq >/dev/null 2>&1 || exit 0
  if [ "$hook" -eq 0 ]; then
    line="$(_budget_eval)" || { printf '%s\n' '{"error":"no-sequence"}'; exit 1; }
    IFS=$'\x1f' read -r key msg clr <<EOF
$line
EOF
    jq -cn --arg k "$key" --arg m "$msg" '{key:$k, pressure:($m != ""), message:(if $m == "" then null else $m end)}'
    exit 0
  fi
  exec 2>/dev/null
  input="$(cat)" || input=""
  sid="$(printf '%s' "$input" | jq -r '.session_id // empty' | tr -cd 'A-Za-z0-9_-')"
  [ -n "$sid" ] || exit 0
  line="$(_budget_eval)" || exit 0
  [ -n "$line" ] || exit 0
  IFS=$'\x1f' read -r key msg clr <<EOF
$line
EOF
  f="$CTX_DIR/$sid.budget"
  # Per-session state for sessions long gone is pruned as we go.
  find "$CTX_DIR" -maxdepth 1 \( -name '*.budget' -o -name '*.rlwake' \) -mtime +7 -delete 2>/dev/null
  prev="$(cat "$f" 2>/dev/null)"
  [ "$key" = "$prev" ] && exit 0
  if [ -n "$msg" ]; then
    # Out of quota with no Claude account to swap to: Codex is the next place to work.
    # Only when the whole account is out (5h/week at 100%, not one model bucket) and no
    # other Claude account was suggested — and Codex can actually take the work.
    case "$msg" in *"/swap "*) : ;; *"until the reset"*)
      [ "$(cmd_codex_ready 2>/dev/null | jq -r '.ready // false' 2>/dev/null)" = true ] &&
        msg="$msg No Claude account has room; if the user wants to keep going now, /handoff codex continues this work in the Codex CLI.";; esac
    printf '%s\n' "$msg"
  elif [ -n "$prev" ] && printf '%s' "$prev" | grep -q '=[1-9]'; then
    printf '%s\n' "$clr"
  fi
  mkdir -p "$CTX_DIR" && printf '%s\n' "$key" > "$f.tmp.$$" && mv -f "$f.tmp.$$" "$f"
  exit 0
}

# --- artifact copies: copy-on-write clones (B3) ----------------------------------
# On APFS `cp -c` makes a clone: identical content shares disk blocks, but each copy is
# its own file, so editing one (e.g. before republishing) never changes another. Other
# filesystems fall back to a plain copy. No size cap here — a handoff snapshot must keep
# every file the next session needs to republish (artifacts go up to ~16 MB).
_store_copy() { # _store_copy <src> <dst>
  mkdir -p "$(dirname "$2")" || return 1
  cp -c -p "$1" "$2" 2>/dev/null || cp -p "$1" "$2"
}

# --- artifact registry: one list of every artifact, across sessions and accounts ---
# A PostToolUse hook on the Artifact tool (`artifact-log`) appends one record per
# publish: url, title, source path, and the account that owns it (the account active
# at publish time — artifacts are owned server-side by the publishing account). The
# latest page source is copied to a durable dir, since sources usually live in a
# session scratchpad that disappears.
ART_INDEX_DIR="$STATE_ROOT/artifacts"
ART_INDEX="$ART_INDEX_DIR/index.jsonl"
ART_SRC_MAX=16777216  # registry keeps page sources up to 16 MB (the artifact page limit)

_active_account_json() { # {slot, email, alias} of the active account (sequence.json, ~/.claude.json fallback)
  local slot
  slot="$(jq -r '(.activeAccountNumber // empty) | tostring' "$STATE_ROOT/sequence.json" 2>/dev/null)"
  if [ -n "$slot" ]; then
    jq -c --arg n "$slot" '{slot: $n, email: (.accounts[$n].email // null), alias: (.accounts[$n].alias // null)}' \
      "$STATE_ROOT/sequence.json" 2>/dev/null && return 0
  fi
  jq -c '{slot: null, email: (.oauthAccount.emailAddress // null), alias: null}' "$HOME/.claude.json" 2>/dev/null ||
    printf '%s\n' '{"slot":null,"email":null,"alias":null}'
}

cmd_artifact_log() {
  local input rec aid src dst size
  exec 2>/dev/null
  command -v jq >/dev/null 2>&1 || exit 0
  input="$(cat)" || exit 0
  rec="$(printf '%s' "$input" | jq -c --argjson acct "$(_active_account_json)" --argjson now "$(date +%s)" '
    select((.tool_name // "") == "Artifact")
    | (.tool_input // {}) as $in
    | select(($in.action // "publish") == "publish" and (($in.asset // false) | not))
    | (.tool_response // {}) as $r
    | (if ($r | type) == "object" then $r
       elif ($r | type) == "string" then
         {url: ($r | try (capture("(?<u>https://claude\\.ai/(code/)?artifact/[A-Za-z0-9_-]+)").u) catch null)}
       else {} end) as $o
    | select(($o.url // "") != "")
    | {ts: $now, kind: "publish",
       artifact_id: ($o.artifact_id // ($o.url | sub(".*/artifact/"; ""))),
       url: $o.url, title: ($o.title // $in.title // null),
       path: ($in.file_path // $o.path // null),
       account: $acct.email, slot: $acct.slot, alias: $acct.alias,
       sessionId: (.session_id // null), cwd: (.cwd // null)}' 2>/dev/null)" || exit 0
  [ -n "$rec" ] || exit 0
  mkdir -p "$ART_INDEX_DIR" || exit 0
  aid="$(printf '%s' "$rec" | jq -r '.artifact_id' | tr -cd 'A-Za-z0-9_-')"
  src="$(printf '%s' "$rec" | jq -r '.path // empty')"
  if [ -n "$aid" ] && [ -n "$src" ] && [ -f "$src" ] && [ ! -L "$src" ]; then
    size="$(stat -f %z "$src" 2>/dev/null || echo 0)"
    if [ "$size" -le "$ART_SRC_MAX" ] 2>/dev/null; then
      mkdir -p "$ART_INDEX_DIR/src/$aid" && dst="$ART_INDEX_DIR/src/$aid/${src##*/}" &&
        _store_copy "$src" "$dst" && touch "$ART_INDEX_DIR/src/$aid" &&
        rec="$(printf '%s' "$rec" | jq -c --arg d "$dst" '. + {saved: $d}')"
      # Sources of artifacts not published again for 180 days are pruned.
      find "$ART_INDEX_DIR/src" -mindepth 1 -maxdepth 1 -type d -mtime +180 -exec rm -rf {} + 2>/dev/null
    fi
  fi
  printf '%s\n' "$rec" >> "$ART_INDEX"
  exit 0
}

# _art_catalog — the index folded to one entry per artifact (latest publish wins),
# with first-seen time, owner, saved source, and republish links in both directions.
_art_catalog() {
  [ -s "$ART_INDEX" ] || { printf '[]\n'; return 0; }
  # A torn line (hook killed mid-write) must not hide every other record.
  jq -R 'fromjson? // empty' "$ART_INDEX" 2>/dev/null | jq -sc '
    (map(select(.kind == "link"))) as $links
    | map(select(.kind == "publish" and (.artifact_id // "") != ""))
    | group_by(.artifact_id)
    | map((sort_by(.ts)) as $g | ($g | first) as $c
          | ($g | last) + {firstSeen: $c.ts, publishes: ($g | length),
              account: $c.account, slot: $c.slot, alias: $c.alias, inferred: ($c.inferred // false),
              lastPublishedBy: (($g | last) | .alias // .account),
              saved: ([$g[] | .saved // empty] | last // null)})
    # Links name artifacts by URL slug or by id: tool responses carry a UUID
    # artifact_id while URLs end in a short slug, so match either and report URLs.
    | map(. + {slug: ((.url // "") | sub(".*/artifact/"; "") | sub("[?#].*"; ""))})
    | (reduce .[] as $e ({}; . + {($e.artifact_id): $e.url, ($e.slug): $e.url})) as $urlOf
    | map(. as $e
          | . + {republishedAs: ([$links[] | select(.from == $e.artifact_id or .from == $e.slug) | .to] | last
                                 | if . == null then null else ($urlOf[.] // .) end),
                 republishOf: ([$links[] | select(.to == $e.artifact_id or .to == $e.slug) | .from] | last
                                 | if . == null then null else ($urlOf[.] // .) end)}
          | del(.slug))
    | sort_by(-.ts)' 2>/dev/null || printf '[]\n'
}

_art_id() { printf '%s' "$1" | sed 's|.*/artifact/||; s|[?#].*||' | tr -cd 'A-Za-z0-9_-'; }

_art_find() { # _art_find [--account X] [words...]
  local acct="" words=()
  while [ $# -gt 0 ]; do
    case "$1" in --account) acct="${2:-}"; shift;; *) words+=("$1");; esac
    shift
  done
  _art_catalog | jq -c --arg acct "$acct" --arg q "${words[*]+${words[*]}}" '
    ($q | ascii_downcase | split(" ") | map(select(. != ""))) as $ws
    | map(select($acct == "" or ((.alias // "") == $acct) or ((.account // "") == $acct) or ((.slot // "") == $acct)))
    | map(select(([.title, .url, ((.path // "") | split("/") | last), .artifact_id] | map(. // "") | join(" ") | ascii_downcase) as $h
                 | all($ws[]; . as $w | $h | contains($w))))
    | .[:20]
    | map({artifact_id, url, title, owner: (.alias // .account), ownerEmail: .account,
           updated: (.ts | todate), firstSeen: (.firstSeen | todate), publishes,
           lastPublishedBy, saved, inferredOwner: (.inferred // false), republishedAs, republishOf})'
}

_art_link() { # _art_link <old url|id> <new url|id> — record that <new> republished <old>
  local from to
  from="$(_art_id "${1:-}")"; to="$(_art_id "${2:-}")"
  if [ -z "$from" ] || [ -z "$to" ] || [ "$from" = "$to" ]; then
    echo "usage: swap-guard artifacts link <old-url|id> <new-url|id>" >&2; return 1
  fi
  mkdir -p "$ART_INDEX_DIR" || return 1
  jq -cn --argjson ts "$(date +%s)" --arg f "$from" --arg t "$to" '{ts:$ts, kind:"link", from:$f, to:$t}' >> "$ART_INDEX"
  jq -cn --arg f "$from" --arg t "$to" '{linked:{from:$f, to:$t}}'
}

# _art_backfill — add artifacts published before the hook existed, from every
# transcript. The owner is inferred from cswap's switch log (the account active at
# publish time) and marked inferred:true; entries already indexed are skipped.
_art_backfill() {
  local tmp sw added f
  tmp="$(mktemp)"; sw="$(mktemp)"
  # cswap log lines: "2026-07-19 20:18:40,385 - INFO - Switched from account 2 to 1" (local time)
  grep -E ' - INFO - (Switched from account [0-9]+ to [0-9]+|Added account [0-9]+)' "$STATE_ROOT/claude-swap.log" 2>/dev/null |
    while IFS= read -r l; do
      local d to e
      d="$(printf '%s' "$l" | cut -c1-19)"
      to="$(printf '%s' "$l" | sed -nE 's/.*(Switched from account [0-9]+ to |Added account )([0-9]+).*/\2/p')"
      e="$(date -j -f '%Y-%m-%d %H:%M:%S' "$d" +%s 2>/dev/null)" || continue
      printf '{"t":%s,"to":"%s"}\n' "$e" "$to"
    done > "$sw"
  find "$HOME/.claude/projects" -name '*.jsonl' -type f 2>/dev/null | while IFS= read -r f; do
    grep -q '"artifact_id"' "$f" 2>/dev/null || continue
    jq -c '
      if .type == "assistant" then
        (.message.content[]? | select(.type == "tool_use" and .name == "Artifact")
         | {k: "use", id, action: (.input.action // "publish"), asset: (.input.asset // false), file_path: (.input.file_path // null)})
      elif .type == "user" and (.toolUseResult | type) == "object"
           and ((.toolUseResult.url // "") | test("^https://claude\\.ai/(code/)?artifact/")) then
        {k: "res", id: (.message.content[0].tool_use_id // null), ts: (.timestamp // ""),
         sid: (.sessionId // null), cwd: (.cwd // null),
         r: (.toolUseResult | {url, artifact_id, title, path})}
      else empty end' "$f" 2>/dev/null
  done > "$tmp"
  mkdir -p "$ART_INDEX_DIR" || { rm -f "$tmp" "$sw"; return 1; }
  touch "$ART_INDEX"
  local seqf="$STATE_ROOT/sequence.json"; [ -r "$seqf" ] || seqf=/dev/null
  added="$(jq -sc --slurpfile sw "$sw" --slurpfile seq "$seqf" --slurpfile have <(jq -R 'fromjson? // empty | select(.kind == "publish") | .artifact_id' "$ART_INDEX" 2>/dev/null) '
    ($have) as $known | ($sw | sort_by(.t)) as $switches | (($seq[0] // {}).accounts // {}) as $accts
    | (map(select(.k == "use")) | map({key: (.id // ""), value: .}) | from_entries) as $uses
    | map(select(.k == "res") | . as $x | ($uses[$x.id // ""] // {}) as $u
          | select(($u.action // "publish") == "publish" and (($u.asset // false) | not))
          | ($x.ts | sub("\\.[0-9]+Z$"; "Z") | try fromdateiso8601 catch null) as $t
          | select($t != null)
          | ([$switches[] | select(.t <= $t)] | last | .to // null) as $slot
          | {ts: $t, kind: "publish",
             artifact_id: ($x.r.artifact_id // ($x.r.url | sub(".*/artifact/"; ""))),
             url: $x.r.url, title: $x.r.title, path: ($x.r.path // $u.file_path),
             account: (if $slot then $accts[$slot].email else null end), slot: $slot,
             alias: (if $slot then $accts[$slot].alias else null end),
             sessionId: $x.sid, cwd: $x.cwd, inferred: true})
    | map(select(.artifact_id as $id | ($known | index($id)) | not))
    | unique_by([.artifact_id, .ts]) | .[]' "$tmp" 2>/dev/null)"
  [ -n "$added" ] && printf '%s\n' "$added" >> "$ART_INDEX"
  rm -f "$tmp" "$sw"
  jq -cn --argjson n "$(printf '%s' "$added" | grep -c . || true)" --arg i "$ART_INDEX" '{backfilled:$n, index:$i}'
}

# --- login: safe re-login / add, without killing the live account's token --------
# Refresh tokens rotate on every use and are single-use. A plain `/login` while
# account A is live overwrites A's newest token before cswap has copied it, so A's
# stored copy is already spent and A dies the first time it is polled as an idle
# account — re-logging B kills A, and the accounts die in turn. So:
#   1. `cswap add` (no slot) re-captures the LIVE account's newest token first;
#   2. the sign-in happens in a throwaway CLAUDE_CONFIG_DIR, whose keychain item is
#      keyed to that dir, so the live login is never touched;
#   3. that profile is registered into the slot, `cswap add` re-anchors cswap on the
#      live account, and the throwaway dir + its keychain item are deleted.
cmd_login() {
  local raw="" new=0 slot="" email="" tmp svc st got live a
  for a in "$@"; do
    case "$a" in --new) new=1;; -*) :;; *) [ -z "$raw" ] && raw="$a";; esac
  done
  command -v cswap >/dev/null 2>&1 || { echo '{"error":"cswap-not-found"}'; exit 1; }
  command -v claude >/dev/null 2>&1 || { echo '{"error":"claude-not-found"}'; exit 1; }
  if [ "$new" -eq 0 ]; then
    [ -n "$raw" ] || { echo "usage: swap-guard login <slot|alias|email> | --new" >&2; exit 2; }
    case "$raw" in
      *[!0-9]*) slot="$(jq -r --arg a "$raw" '[.accounts | to_entries[]
                  | select((.value.alias // "") == $a or ((.value.email // "") | ascii_downcase) == ($a | ascii_downcase))
                  | .key] | if length == 1 then .[0] elif length > 1 then "AMBIGUOUS" else empty end' \
                  "$STATE_ROOT/sequence.json" 2>/dev/null)"
                if [ "$slot" = AMBIGUOUS ]; then
                  jq -cn --arg t "$raw" '{error:"ambiguous-target", target:$t, detail:"several slots match (same email in two orgs?) — pass the slot number"}'
                  exit 1
                fi;;
      *) slot="$raw";;
    esac
    email="$(jq -r --arg n "$slot" '.accounts[$n].email // empty' "$STATE_ROOT/sequence.json" 2>/dev/null)"
    [ -n "$slot" ] && [ -n "$email" ] || { jq -cn --arg t "$raw" '{error:"unknown-target", target:$t}'; exit 1; }
  fi
  local list_json
  list_json="$(cswap list --json 2>/dev/null)"
  if ! printf '%s' "$list_json" | jq -e '.accounts' >/dev/null 2>&1; then
    # Without it we cannot tell which account is live, so we could neither protect
    # nor re-anchor it. Stop before anything changes.
    jq -cn '{error:"cswap-list-failed", detail:"cswap list --json failed — run: cswap list (to see why), then retry"}'; exit 1
  fi
  live="$(printf '%s' "$list_json" | jq -r '[.accounts[]? | select(.active == true) | .number] | first // empty' 2>/dev/null)"
  if [ "$new" -eq 0 ] && [ "$slot" = "$live" ]; then
    # Re-logging the live account itself: the live keychain item IS the target.
    echo "Account $slot is the live account: sign in with /login inside Claude Code, then run: cswap add" >&2
    jq -cn --arg s "$slot" '{error:"target-is-live", slot:$s}'; exit 1
  fi
  echo "1/4 saving the live account's newest token (cswap add)…" >&2
  if [ -n "$live" ] && ! cswap add >/dev/null 2>&1; then
    echo "    could not re-capture the live account; stopping so nothing gets overwritten." >&2
    jq -cn '{error:"capture-failed"}'; exit 1
  fi
  mkdir -p "$STATE_ROOT" || exit 1
  tmp="$(mktemp -d "$STATE_ROOT/login.XXXXXX")" || exit 1
  # Claude Code keys the profile's keychain item by sha256 of the NFC-normalized dir
  # string; macOS paths can arrive decomposed (NFD), so normalize before hashing.
  svc="Claude Code-credentials-$( { printf '%s' "$tmp" | iconv -f UTF-8-MAC -t UTF-8 2>/dev/null || printf '%s' "$tmp"; } |
    /usr/bin/shasum -a 256 | cut -c1-8)"
  trap 'rm -rf "$tmp"; security delete-generic-password -s "$svc" >/dev/null 2>&1' EXIT
  echo "2/4 a browser window opens — sign in as ${email:-the new account}…" >&2
  if [ -n "$email" ]; then
    CLAUDE_CONFIG_DIR="$tmp" claude auth login --email "$email" >&2 || { jq -cn '{error:"login-failed"}'; exit 1; }
  else
    CLAUDE_CONFIG_DIR="$tmp" claude auth login >&2 || { jq -cn '{error:"login-failed"}'; exit 1; }
  fi
  st="$(CLAUDE_CONFIG_DIR="$tmp" claude auth status 2>/dev/null)"
  got="$(printf '%s' "$st" | jq -r '.email // .account.email // empty' 2>/dev/null)"
  if [ -n "$email" ] && [ "$(printf '%s' "$got" | tr '[:upper:]' '[:lower:]')" != "$(printf '%s' "$email" | tr '[:upper:]' '[:lower:]')" ]; then
    jq -cn --arg want "$email" --arg got "$got" '{error:"wrong-account", expected:$want, signedInAs:$got,
      detail:"nothing was changed; run it again and sign in with the expected account"}'
    exit 1
  fi
  echo "3/4 registering ${email:-$got} in cswap…" >&2
  # cswap asks "Overwrite slot N? [y/N]" when the stored org differs; with no terminal
  # it prints "Cancelled" and still exits 0 — so success is proven by sequence.json
  # changing, not by the exit code.
  local before after addout keys_before retry
  before="$(jq -r '.lastUpdated // empty' "$STATE_ROOT/sequence.json" 2>/dev/null)"
  keys_before="$(jq -c '.accounts | keys' "$STATE_ROOT/sequence.json" 2>/dev/null)"
  local add_args=(add); [ -n "$slot" ] && add_args+=(--slot "$slot")
  if [ -t 0 ]; then
    # A terminal: cswap's own y/N (e.g. an organization change) reaches the user.
    # Its output is not captured here, so the prompt is visible.
    CLAUDE_CONFIG_DIR="$tmp" cswap "${add_args[@]}" >&2 || { jq -cn '{error:"cswap-add-failed"}'; exit 1; }
    addout=""
  else
    addout="$(CLAUDE_CONFIG_DIR="$tmp" cswap "${add_args[@]}" </dev/null 2>&1)" || { jq -cn --arg d "$addout" '{error:"cswap-add-failed", detail:$d}'; exit 1; }
    printf '%s\n' "$addout" >&2
  fi
  after="$(jq -r '.lastUpdated // empty' "$STATE_ROOT/sequence.json" 2>/dev/null)"
  if printf '%s' "$addout" | grep -qi 'cancelled' || [ "$before" = "$after" ]; then
    retry="swap-guard login ${slot:---new}"
    jq -cn --arg s "${slot:-new}" --arg r "$retry" --arg d "$(printf '%s' "$addout" | tail -c 300)" '{error:"cswap-add-cancelled", slot:$s,
      detail:("cswap did not register the account — it needs a y/N confirmation (usually an organization change) that only a terminal can answer. Run in a terminal: " + $r + "  — " + $d)}'
    exit 1
  fi
  # --new: report the slot cswap actually assigned (the one key that appeared).
  if [ -z "$slot" ]; then
    slot="$(jq -r --argjson b "${keys_before:-[]}" '(.accounts | keys) - $b | first // empty' "$STATE_ROOT/sequence.json" 2>/dev/null)"
  fi
  echo "4/4 re-anchoring cswap on the live account and cleaning up…" >&2
  local reanchored=true
  if [ -n "$live" ] && ! cswap add </dev/null >/dev/null 2>&1; then reanchored=false; fi
  [ -z "$live" ] && reanchored=false
  jq -cn --arg s "${slot:-new}" --arg e "${email:-$got}" --arg l "${live:-}" --argjson r "$reanchored" \
    '{loggedIn:$e, slot:$s, live:(if $l == "" then null else $l end), liveUntouched:true, reanchored:$r}
     + (if $r then {} else {detail:"cswap may now think the re-logged account is active — run: cswap add   (while the live account is logged in)"} end)'
  exit 0
}

# --- version-check: is a newer overclaude on PyPI? (at most one request a day) -----
# The statusline only ever READS the cache this writes, and starts this in the
# background when the cache is a day old — it never waits on the network. The
# request is an anonymous GET of PyPI's public JSON; nothing about the user is sent.
cmd_version_check() {
  local force=0 cache="$STATE_ROOT/cache/latest-version.json" checked now latest a k
  for a in "$@"; do [ "$a" = "--force" ] && force=1; done
  now="$(date +%s)"
  checked="$(jq -r '.checkedAt // 0' "$cache" 2>/dev/null)"; case "$checked" in ''|*[!0-9]*) checked=0;; esac
  # A check from before the last install/upgrade is stale: the release that was just
  # installed may postdate it.
  k="$(stat -f %m "$STATE_ROOT/kit-version" 2>/dev/null)"
  case "$k" in ''|*[!0-9]*) ;; *) [ "$k" -gt "$checked" ] && checked=0;; esac
  if [ "$force" -eq 0 ] && [ $(( now - checked )) -lt 86400 ]; then
    jq -c --arg k "$(cat "$STATE_ROOT/kit-version" 2>/dev/null)" '. + {installed: $k, cached: true}' "$cache"; exit 0
  fi
  mkdir -p "$STATE_ROOT/cache" || exit 1
  latest="$(curl -fsS --max-time 5 https://pypi.org/pypi/overclaude/json 2>/dev/null | jq -r '.info.version // empty' 2>/dev/null)"
  if [ -z "$latest" ]; then
    # Record the attempt anyway so an offline machine retries tomorrow, not every render.
    jq -cn --argjson now "$now" --arg v "$(jq -r '.version // empty' "$cache" 2>/dev/null)" \
      '{version: (if $v == "" then null else $v end), checkedAt: $now, error: "unreachable"}' > "$cache.tmp.$$" &&
      mv "$cache.tmp.$$" "$cache"
  else
    jq -cn --argjson now "$now" --arg v "$latest" '{version: $v, checkedAt: $now}' > "$cache.tmp.$$" && mv "$cache.tmp.$$" "$cache"
  fi
  jq -c --arg k "$(cat "$STATE_ROOT/kit-version" 2>/dev/null)" '. + {installed: $k, cached: false}' "$cache"
}

# --- codex: the sibling CLI (overcodex) as another place to keep working -----------
# Codex accounts are separate CODEX_HOMEs: ~/.codex (primary) and, with overcodex's
# codex-swap, ~/.codex-accounts/<name>; ~/.codex-accounts/.active names the one new
# `codex` runs use. Codex records its own rate limits in every session log
# (event_msg/token_count .payload.rate_limits), so usage is read from there — as
# fresh as that home's last Codex turn.
CODEX_ACCOUNTS="$HOME/.codex-accounts"

_codex_active_home() { # the CODEX_HOME a new `codex` would use
  # Resolved from codex-swap's marker exactly as overcodex's codex() wrapper does. An
  # inherited $CODEX_HOME is ignored: overcodex <0.3 exported it into the shell, so after
  # a `codex-swap use` it can name the previous account. OVERCLAUDE_CODEX_HOME overrides.
  local a
  if [ -n "${OVERCLAUDE_CODEX_HOME:-}" ]; then printf '%s\n' "$OVERCLAUDE_CODEX_HOME"; return 0; fi
  a="$(head -n 1 "$CODEX_ACCOUNTS/.active" 2>/dev/null | tr -d '[:space:]')"
  case "$a" in *[!A-Za-z0-9_-]*) a="";; esac
  if [ -n "$a" ] && [ "$a" != primary ] && [ -d "$CODEX_ACCOUNTS/$a" ]; then
    printf '%s\n' "$CODEX_ACCOUNTS/$a"
  else
    printf '%s\n' "$HOME/.codex"
  fi
}

_codex_homes() { # name<TAB>home for every Codex home on this machine
  [ -d "$HOME/.codex" ] && printf 'primary\t%s\n' "$HOME/.codex"
  local d
  for d in "$CODEX_ACCOUNTS"/*/; do
    [ -d "$d" ] || continue
    d="${d%/}"; printf '%s\t%s\n' "${d##*/}" "$d"
  done
}

# _codex_usage_one <name> <home> — JSON for one home's latest rate limits, or nothing.
_codex_usage_one() {
  local name="$1" home="$2" f line mt
  [ -d "$home/sessions" ] || return 0
  # Newest session log that actually carries rate limits: a just-started session has
  # none yet, and one that hit the limit early may log rate_limits: null.
  line=""
  while IFS= read -r f; do
    line="$(tail -n 400 "$f" 2>/dev/null | /usr/bin/grep '"rate_limits":{' | tail -n 1)"
    [ -n "$line" ] && break
  done <<EOF
$(find "$home/sessions" -name 'rollout-*.jsonl' -type f -mtime -8 -exec stat -f '%m %N' {} + 2>/dev/null |
    sort -rn | head -n 20 | cut -d' ' -f2-)
EOF
  [ -n "$line" ] || return 0
  mt="$(stat -f %m "$f" 2>/dev/null || echo 0)"
  printf '%s' "$line" | jq -c --arg n "$name" --arg h "$home" --argjson mt "$mt" \
    --arg active "$(_codex_active_home)" '
    (.payload.rate_limits // .rate_limits // empty) as $rl
    | def win($w): if $w == null then null
        else {pct: ($w.used_percent // null), windowMinutes: ($w.window_minutes // null), resetsAt: ($w.resets_at // null),
              label: (($w.window_minutes // 0) as $m | if $m == 300 then "5h" elif $m == 10080 then "week"
                      elif $m >= 1440 then "\($m / 1440 | floor)d" elif $m > 0 then "\($m / 60 | floor)h" else "?" end)} end;
    {name: $n, home: $h, active: ($h == $active), plan: ($rl.plan_type // null), asOf: $mt,
     limitReached: ($rl.rate_limit_reached_type // null),
     windows: ([win($rl.primary), win($rl.secondary)] | map(select(. != null)))}' 2>/dev/null
}

cmd_codex_ready() { # can a Codex session pick work up here? JSON verdict for /handoff codex
  local home cli=false hook=false limit=null u
  home="$(_codex_active_home)"
  command -v codex >/dev/null 2>&1 && cli=true
  [ -f "$home/hooks/overcodex-handoff-inject.sh" ] && hook=true
  u="$(_codex_usage_one "x" "$home")"
  local known=false; [ -n "$u" ] && known=true
  [ -n "$u" ] && limit="$(printf '%s' "$u" | jq -c --argjson now "$(date +%s)" \
    '[.windows[] | select((.resetsAt // 0) > $now and (.pct // 0) >= 100)] | if length > 0 then .[0] else null end')"
  local pend pf; pf="$(cmd_path codex-handoff 2>/dev/null)"; pend=null
  [ -f "$pf" ] && pend="$(jq -cn --arg p "$pf" --argjson age "$(( $(date +%s) - $(stat -f %m "$pf") ))" '{path:$p, ageSec:$age}')"
  jq -cn --arg home "$home" --argjson cli "$cli" --argjson hook "$hook" --argjson limit "${limit:-null}" \
    --argjson known "$known" --argjson pend "$pend" '
    {home: $home, codexCli: $cli, overcodexHook: $hook, exhausted: $limit, limitsKnown: $known, pending: $pend,
     ready: ($cli and $hook and $limit == null),
     detail: (if ($cli | not) then "Codex CLI not installed (npm i -g @openai/codex)"
              elif ($hook | not) then "overcodex is not installed in this Codex home, so nothing would load the package: pipx install overcodex && overcodex install"
              elif $limit != null then "this Codex account is at its \($limit.label) limit"
              else "ok" end)}'
}

# cmd_codex_review — a cross-family second opinion for ULTRACODE verify stages:
# `codex exec` in a READ-ONLY sandbox, ephemeral (no session file), on the active
# Codex account, with a hard timeout. Prompt = args, plus stdin with --stdin (e.g. a
# diff). Prints JSON {ok, message, json?, ...}; --raw prints only the message.
cmd_codex_review() {
  local dir="$PWD" model="" effort="" schema="" timeout=600 raw=0 words=() home out err rc t0 t1 msg stdin_f="" use_stdin=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --dir) dir="${2:-}"; shift;;
      --model) model="${2:-}"; shift;;
      --effort) effort="${2:-}"; shift;;
      --schema) schema="${2:-}"; shift;;
      --timeout) timeout="${2:-600}"; shift;;
      --raw) raw=1;;
      --stdin) use_stdin=1;;
      *) words+=("$1");;
    esac
    shift
  done
  case "$timeout" in ''|*[!0-9]*) timeout=600;; esac
  [ "$timeout" -ge 1 ] || { jq -cn '{ok:false, error:"bad-timeout", detail:"--timeout must be at least 1 second"}'; exit 1; }
  case "$effort" in ''|none|minimal|low|medium|high|xhigh|max) :;; *) jq -cn --arg e "$effort" '{ok:false, error:"bad-effort", effort:$e}'; exit 1;; esac
  case "$model" in *[!A-Za-z0-9._:-]*) jq -cn '{ok:false, error:"bad-model"}'; exit 1;; esac
  command -v codex >/dev/null 2>&1 || { jq -cn '{ok:false, error:"codex-not-installed", detail:"npm i -g @openai/codex"}'; exit 1; }
  [ -d "$dir" ] || { jq -cn --arg d "$dir" '{ok:false, error:"no-such-dir", dir:$d}'; exit 1; }
  if [ -n "$schema" ] && [ ! -f "$schema" ]; then jq -cn --arg s "$schema" '{ok:false, error:"no-such-schema", schema:$s}'; exit 1; fi
  # Only with --stdin: agent and hook shells often hold an open stdin that never
  # closes, so reading it unasked would hang.
  if [ "$use_stdin" -eq 1 ]; then stdin_f="$(mktemp)"; cat > "$stdin_f"; [ -s "$stdin_f" ] || { rm -f "$stdin_f"; stdin_f=""; }; fi
  if [ "${#words[@]}" -eq 0 ] && [ -z "$stdin_f" ]; then
    echo "usage: swap-guard codex-review [--dir D] [--model M] [--effort E] [--schema F] [--timeout S] [--raw] <prompt> [--stdin < input]" >&2; exit 2
  fi
  home="$(_codex_active_home)"
  out="$(mktemp)"; err="$(mktemp)"
  local args=(exec -s read-only --ephemeral --skip-git-repo-check --color never -C "$dir" -o "$out")
  [ -n "$model" ] && args+=(-m "$model")
  [ -n "$effort" ] && args+=(-c "model_reasoning_effort=\"$effort\"")
  [ -n "$schema" ] && args+=(--output-schema "$schema")
  # `--` ends the options: a prompt starting with "-" can never become a Codex flag
  # (e.g. one that loosens the read-only sandbox). With --stdin and no words, the
  # prompt is omitted and Codex reads it from stdin.
  [ "${#words[@]}" -gt 0 ] && args+=(-- "${words[*]}")
  t0="$(date +%s)"
  # perl's alarm is the portable timeout on macOS (no coreutils `timeout`).
  # The subshell keeps bash's "Alarm clock" job notice (on timeout) off the terminal.
  # Timeout kills the whole process group: the npm `codex` wrapper spawns a native
  # binary and forwards only TERM/INT/HUP, so killing the wrapper alone would leave
  # Codex running (and spending quota). exit 142 = timed out.
  CODEX_HOME="$home" perl -e '
    my $t = shift; my $pid = fork; exit 127 unless defined $pid;
    if ($pid == 0) { setpgrp(0, 0); exec @ARGV or exit 127 }
    $SIG{ALRM} = sub { kill "TERM", -$pid; sleep 2; kill "KILL", -$pid; waitpid($pid, 0); exit 142 };
    alarm $t; waitpid($pid, 0); alarm 0;
    my $st = $?; exit(($st & 127) ? 128 + ($st & 127) : $st >> 8)' "$timeout" codex "${args[@]}" \
      < "${stdin_f:-/dev/null}" >/dev/null 2>"$err"; rc=$?
  t1="$(date +%s)"
  msg="$(cat "$out" 2>/dev/null)"
  [ -n "$stdin_f" ] && rm -f "$stdin_f"
  if [ "$raw" -eq 1 ] && [ "$rc" -eq 0 ]; then
    rm -f "$out" "$err"; [ -n "$msg" ] || exit 1; printf '%s\n' "$msg"; exit 0
  fi
  jq -cn --argjson ok "$([ "$rc" -eq 0 ] && [ -n "$msg" ] && echo true || echo false)" --argjson rc "$rc" \
    --arg msg "$msg" --arg home "$home" --arg model "$model" --argjson secs "$(( t1 - t0 ))" \
    --arg err "$(tail -c 600 "$err" 2>/dev/null)" --argjson timeout "$timeout" '
    {ok: $ok, exitCode: $rc, codexHome: $home, model: (if $model == "" then "default" else $model end),
     durationSec: $secs, message: $msg}
    + (($msg | try fromjson catch null) as $j | if $j != null then {json: $j} else {} end)
    + (if $rc == 142 then {error: "timeout", detail: "no answer within \($timeout)s"}
       elif $rc != 0 then {error: "codex-failed", detail: $err} else {} end)'
  rm -f "$out" "$err"
  [ "$rc" -eq 0 ]
}

cmd_codex_usage() { # [--refresh] — cached for 2 minutes (the statusline reads the cache)
  local cache="$STATE_ROOT/cache/codex-usage.json" refresh=0 a now m name home out
  for a in "$@"; do [ "$a" = "--refresh" ] && refresh=1; done
  command -v jq >/dev/null 2>&1 || { printf '[]\n'; exit 0; }
  now="$(date +%s)"; m="$(stat -f %m "$cache" 2>/dev/null || echo 0)"
  if [ "$refresh" -eq 0 ] && [ $(( now - m )) -lt 120 ] && [ -s "$cache" ]; then cat "$cache"; exit 0; fi
  out="$(_codex_homes | while IFS=$'\t' read -r name home; do _codex_usage_one "$name" "$home"; done | jq -sc '.')"
  [ -n "$out" ] || out="[]"
  mkdir -p "$STATE_ROOT/cache" && printf '%s\n' "$out" > "$cache.tmp.$$" && mv "$cache.tmp.$$" "$cache"
  printf '%s\n' "$out"
}

# --- route-guard: ULTRACODE rule 1 as a gate (PreToolUse on Agent|Workflow) --------
# An un-annotated subagent silently inherits the session's model; on Fable that is a
# spend from the small Fable bucket nobody chose. When the session runs on a Fable/
# Mythos-tier model (read from the statusline relay — hooks get no model field):
#   Agent    — no `model`, subagent_type general-purpose/claude/Plan/default → deny.
#              Forks and agent types whose own definition sets a model are allowed.
#   Workflow — every agent() call needs `model:`; a call whose text names fable/apex
#              (e.g. label 'synthesize (fable/xhigh)') is a deliberate inherit.
# Deny = hookSpecificOutput permissionDecision "deny" with a reason Claude reads and
# fixes. Off switch: `swap-guard route-guard off` (or OVERCLAUDE_ROUTE_GUARD=off).
ROUTE_OFF="$STATE_ROOT/route-guard.off"

_route_deny() { # _route_deny <reason>
  jq -cn --arg r "$1" '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $r}}'
  exit 0
}

_agent_def_file() { # _agent_def_file <type> <cwd> — path of a custom agent definition, if any
  local t="$1" d="${2:-}" f
  case "$t" in ''|*/*|*..*) return 1;; esac
  # Project agents may sit in any parent of the cwd (Claude Code walks up to the repo root).
  while [ -n "$d" ] && [ "$d" != "/" ]; do
    [ -f "$d/.claude/agents/$t.md" ] && { printf '%s\n' "$d/.claude/agents/$t.md"; return 0; }
    d="$(dirname "$d")"
  done
  f="$HOME/.claude/agents/$t.md"; [ -f "$f" ] && { printf '%s\n' "$f"; return 0; }
  return 1
}

_agent_def_has_model() { # _agent_def_has_model <type> <cwd> — does its definition pin a model?
  local t="$1" cwd="$2" f
  case "$t" in ''|*/*|*..*) return 1;; esac
  f="$(_agent_def_file "$t" "$cwd")" || return 1
  awk '{ sub(/\r$/, "") } NR == 1 && $0 != "---" { exit 1 } NR > 1 && $0 == "---" { exit 1 }
       NR > 1 && /^model:[[:space:]]*[^[:space:]]/ && $0 !~ /inherit/ { found = 1; exit 0 }
       END { exit !found }' "$f"
}

# _workflow_unrouted — read a JS script on stdin; print each agent(...) call that is
# not routed (first 100 chars, one per line). Pass 1 builds a same-length copy with
# comments blanked and string / template / regex-literal CONTENTS masked, so text in
# prompts can neither create a call nor satisfy the check. A call is routed when its
# last argument is an object literal with a depth-1 `model` key (also shorthand
# `{ model }`) whose value is not ''/undefined/null — or whose `label` names
# fable/apex (a deliberate top-tier stage). Options passed by variable, or with a
# spread, cannot be seen into and are allowed; so is a call whose parens do not
# close within 20 KB (parse failure fails open, it never blocks).
_workflow_unrouted() {
  perl -e '
    local $/; my $src = <STDIN>; my $n = length $src;
    my ($code, $i, $prev) = ("", 0, "");
    while ($i < $n) {
      my $c = substr($src, $i, 1); my $d = substr($src, $i + 1, 1);
      if ($c eq "/" && $d eq "/") { my $e = index($src, "\n", $i); $e = $n if $e < 0; $code .= " " x ($e - $i); $i = $e; next }
      if ($c eq "/" && $d eq "*") { my $e = index($src, "*/", $i + 2); $e = $e < 0 ? $n : $e + 2;
        (my $seg = substr($src, $i, $e - $i)) =~ s/[^\n]/ /g; $code .= $seg; $i = $e; next }
      if ($c eq "\"" || $c eq "\x27" || $c eq "`") {
        my ($j, $out) = ($i + 1, $c);
        while ($j < $n) { my $x = substr($src, $j, 1);
          if ($x eq "\\") { $out .= "xx"; $j += 2; next }
          last if $x eq $c;
          $out .= ($x eq "\n" ? "\n" : "x"); $j++ }
        if ($j < $n) { $out .= $c; $j++ }
        $code .= $out; $i = $j; $prev = $c; next }
      if ($c eq "/" && ($prev eq "" || $prev =~ /[(,=:\[!&|?{};]/)) {
        my ($j, $out, $cls) = ($i + 1, "/", 0);
        while ($j < $n) { my $x = substr($src, $j, 1);
          if ($x eq "\\") { $out .= "xx"; $j += 2; next }
          last if $x eq "\n";
          if ($cls) { $cls = 0 if $x eq "]" } elsif ($x eq "[") { $cls = 1 } elsif ($x eq "/") { last }
          $out .= "x"; $j++ }
        if ($j < $n && substr($src, $j, 1) eq "/") { $out .= "/"; $j++ }
        $code .= $out; $i = $j; $prev = "/"; next }
      $code .= $c; $prev = $c unless $c =~ /\s/; $i++;
    }
    my @bad;
    while ($code =~ /(?<![\w.\$])agent\s*\(/g) {
      my ($start, $p) = ($-[0], $+[0]); my $depth = 1; my @arg = ($p);
      my $lim = $p + 20000; $lim = $n if $lim > $n;
      while ($p < $lim && $depth > 0) { my $c = substr($code, $p, 1);
        if ($c =~ /[(\[{]/) { $depth++ } elsif ($c =~ /[)\]}]/) { $depth-- }
        elsif ($c eq "," && $depth == 1) { push @arg, $p + 1 }
        $p++ }
      next if $depth > 0;
      my $close = $p - 1;
      my @ends = (map({ $_ - 1 } @arg[1 .. $#arg]), $close);
      my @a = map { [$arg[$_], $ends[$_]] } 0 .. $#arg;
      pop @a if @a > 1 && substr($code, $a[-1][0], $a[-1][1] - $a[-1][0]) =~ /^\s*$/;   # trailing comma
      my $routed = 0;
      if (@a >= 2) {
        my ($s0, $e0) = @{$a[-1]};
        my $t = substr($code, $s0, $e0 - $s0);
        (my $lead) = $t =~ /^(\s*)/; my $b = $s0 + length($lead);
        if (substr($code, $b, 1) ne "{") { $routed = 1 }          # opts by variable or expression
        else {
          my ($flat, $dd) = ("", 0);
          for my $k ($b + 1 .. $e0 - 1) { my $ch = substr($code, $k, 1);
            if ($ch =~ /[\[{(]/) { $dd++; $flat .= " "; next }
            if ($ch =~ /[\]})]/) { last if $dd == 0; $dd--; $flat .= " "; next }
            $flat .= $dd ? " " : $ch }
          if ($flat =~ /\.\.\./) { $routed = 1 }                   # spread: cannot see inside
          elsif ($flat =~ /(?:^|[,\s])model\s*(:|,|$)/g) {
            if ($1 eq ":") { my $v = substr($src, $b + 1 + pos($flat), 40);
              $routed = ($v =~ /^\s*(\x27\x27|""|``|undefined|null)\s*(,|\}|$)/) ? 0 : 1 }
            else { $routed = 1 }                                   # shorthand { model }
          }
          if (!$routed && $flat =~ /(?:^|[,\s])label\s*:/g) {
            my $vs = $b + 1 + pos($flat); my $rest = substr($flat, pos($flat));
            my $len = index($rest, ","); $len = length($rest) if $len < 0;
            $routed = 1 if substr($src, $vs, $len) =~ /fable|apex/i }
        }
      }
      next if $routed;
      (my $one = substr($src, $start, $close - $start + 1)) =~ s/\s+/ /g;
      push @bad, substr($one, 0, 100);
    }
    print "$_\n" for @bad;'
}

cmd_route_guard() {
  local input tool sid cwd model ti st m bad n script sp
  case "${1:-}" in
    on)  rm -f "$ROUTE_OFF"; jq -cn '{routeGuard: true}'; exit 0;;
    off) mkdir -p "$STATE_ROOT" && touch "$ROUTE_OFF"; jq -cn '{routeGuard: false}'; exit 0;;
    status) jq -cn --argjson on "$([ -f "$ROUTE_OFF" ] && echo false || echo true)" '{routeGuard: $on}'; exit 0;;
  esac
  # Run by hand (a terminal, no hook JSON coming): report status instead of waiting.
  if [ -t 0 ]; then jq -cn --argjson on "$([ -f "$ROUTE_OFF" ] && echo false || echo true)" '{routeGuard: $on}'; exit 0; fi
  exec 2>/dev/null
  command -v jq >/dev/null 2>&1 || exit 0
  [ "${OVERCLAUDE_ROUTE_GUARD:-}" = off ] && exit 0
  [ -f "$ROUTE_OFF" ] && exit 0
  input="$(cat)" || exit 0
  tool="$(printf '%s' "$input" | jq -r '.tool_name // empty')"
  sid="$(printf '%s' "$input" | jq -r '.session_id // empty' | tr -cd 'A-Za-z0-9_-')"
  cwd="$(printf '%s' "$input" | jq -r '.cwd // empty')"
  # The session's model: its last assistant entry in the transcript (authoritative and
  # follows /model), else the statusline relay. Unknown → never guess, allow.
  local tp; tp="$(printf '%s' "$input" | jq -r '.transcript_path // empty')"
  model=""
  [ -n "$tp" ] && [ -f "$tp" ] && model="$(tail -n 300 "$tp" 2>/dev/null | /usr/bin/grep '"type":"assistant"' | tail -n 1 |
    jq -r '.message.model // empty' 2>/dev/null)"
  [ -n "$model" ] || model="$(jq -r '.model // empty' "$CTX_DIR/$sid.json" 2>/dev/null)"
  # Only the top tier is guarded: inheriting Sonnet/Opus/Haiku is not a hidden Fable spend.
  case "$(printf '%s' "$model" | tr '[:upper:]' '[:lower:]')" in *fable*|*mythos*) :;; *) exit 0;; esac
  case "$tool" in
    Agent|Task)
      m="$(printf '%s' "$input" | jq -r '.tool_input.model // empty')"
      st="$(printf '%s' "$input" | jq -r '.tool_input.subagent_type // empty')"
      [ -n "$m" ] && exit 0
      case "$st" in
        fork) exit 0;;                              # forks always inherit, by design
        ''|general-purpose|claude|Plan) :;;         # inherit the session model → guarded
        *) # A custom agent file without a model inherits too; other built-in types
           # (Explore, …) carry their own defaults and are left alone.
           _agent_def_file "$st" "$cwd" >/dev/null || exit 0
           _agent_def_has_model "$st" "$cwd" && exit 0;;
      esac
      _route_deny "ULTRACODE rule 1 (overclaude route-guard): this session runs on $model, so an Agent call without \`model\` silently spends the Fable bucket. Set model explicitly — haiku (scouting, extraction), sonnet (finders, scoped edits), opus (verifiers, tricky edits) — or pass model \"fable\" when the top tier is the deliberate choice." ;;
    Workflow)
      script="$(printf '%s' "$input" | jq -r '.tool_input.script // empty')"
      if [ -z "$script" ]; then
        sp="$(printf '%s' "$input" | jq -r '.tool_input.scriptPath // empty')"
        [ -n "$sp" ] && [ -f "$sp" ] && script="$(cat "$sp")"
      fi
      [ -n "$script" ] || exit 0   # a saved workflow by name: its author owns the routing
      bad="$(printf '%s' "$script" | _workflow_unrouted)"
      [ -n "$bad" ] || exit 0
      n="$(printf '%s\n' "$bad" | grep -c .)"
      _route_deny "ULTRACODE rule 1 (overclaude route-guard): this session runs on $model and $n agent() call(s) have no \`model:\` — each would silently spend the Fable bucket. Add model: 'haiku'|'sonnet'|'opus' per the routing table; for a deliberate top-tier stage keep model omitted but put fable in its label (e.g. label: 'synthesize (fable/xhigh)'). Unrouted: $(printf '%s\n' "$bad" | head -n 5 | sed 's/^/• /' | tr '\n' ' ')" ;;
  esac
  exit 0
}

# --- spend: what this usage would have cost at API prices ---------------------------
# Every assistant entry in a transcript carries message.usage (input, cache writes by
# TTL, cache reads, output) and message.model. Rows are extracted per transcript into
# a cache keyed by path and re-extracted only when the file changes (a week of logs
# is hundreds of MB). Streaming writes repeat one message.id per content block, so
# rows are de-duplicated by id. Prices: Anthropic API list prices per million tokens,
# retrieved 2026-10-09 from platform.claude.com/docs/en/about-claude/pricing.
# Accounts: each row is attributed to the account active at its timestamp (cswap's
# switch log); plan cost comes from the account's rate-limit tier (Max 20x $200,
# Max 5x $100, Pro $20 a month), overridable in ~/.claude-swap-backup/plans.json
# as {"<slot>": <usd-per-month>}.
SPEND_DIR="$STATE_ROOT/cache/spend"
SPEND_PRICES_AS_OF="2026-10-09"

_spend_extract() { # _spend_extract <transcript> — TSV rows: id ts model in cw5 cw1h cr out speed session
  # Line by line, so one torn/partial line (a live session mid-write) is skipped
  # instead of failing the whole transcript.
  /usr/bin/grep -h '"output_tokens"' "$1" 2>/dev/null | jq -R -r '
    (fromjson? // empty)
    | select(.type == "assistant" and (.message.usage | type) == "object")
    | .message.usage as $u
    | (($u.cache_creation // null)) as $cc
    | [ (.message.id // .requestId // ""),
        ((.timestamp // "") | sub("\\.[0-9]+Z$"; "Z") | try fromdateiso8601 catch 0),
        (.message.model // "unknown"),
        ($u.input_tokens // 0),
        (if $cc != null then ($cc.ephemeral_5m_input_tokens // 0) else ($u.cache_creation_input_tokens // 0) end),
        (if $cc != null then ($cc.ephemeral_1h_input_tokens // 0) else 0 end),
        ($u.cache_read_input_tokens // 0),
        ($u.output_tokens // 0),
        ($u.speed // "standard"),
        (.sessionId // "") ] | @tsv' 2>/dev/null
}

_spend_plan_usd() { # _spend_plan_usd <slot> — monthly plan price for that account, or 0
  local slot="$1" tier v
  v="$(jq -r --arg s "$slot" '.[$s] | tonumber? // empty' "$STATE_ROOT/plans.json" 2>/dev/null)"
  [ -n "$v" ] && { printf '%s\n' "$v"; return 0; }
  tier="$(cat "$STATE_ROOT"/configs/.claude-config-"$slot"-*.json 2>/dev/null |
    jq -r '.oauthAccount | (.organizationRateLimitTier // .userRateLimitTier // .organizationType // "")' 2>/dev/null | head -n 1)"
  case "$tier" in
    *max_20x*) echo 200;; *max_5x*) echo 100;; *claude_max*) echo 100;; *pro*) echo 20;; *) echo 0;;
  esac
}

cmd_spend() {
  local days=7 a now start f key meta cur rows sw out acct_plans slot n
  while [ $# -gt 0 ]; do
    case "$1" in --days) days="${2:-7}"; shift;; --days=*) days="${1#--days=}";; esac
    shift
  done
  case "$days" in ''|*[!0-9]*) days=7;; esac
  [ "$days" -ge 1 ] || days=1
  now="$(date +%s)"; start=$(( now - days * 86400 ))
  mkdir -p "$SPEND_DIR" || exit 1
  rows="$(mktemp)"; sw="$(mktemp)"
  # 1. Extract (cached per transcript; re-extract only when size/mtime changed).
  find "$HOME/.claude/projects" -name '*.jsonl' -type f -mtime "-$((days + 1))" 2>/dev/null | while IFS= read -r f; do
    key="$(printf '%s' "$f" | /usr/bin/shasum | cut -c1-20)"
    meta="$(stat -f '%m %z' "$f" 2>/dev/null)"
    cur="$(cat "$SPEND_DIR/$key.meta" 2>/dev/null)"
    if [ "$meta" != "$cur" ] || [ ! -f "$SPEND_DIR/$key.tsv" ]; then
      if _spend_extract "$f" > "$SPEND_DIR/$key.tsv.tmp.$$"; then
        mv "$SPEND_DIR/$key.tsv.tmp.$$" "$SPEND_DIR/$key.tsv" && printf '%s\n' "$meta" > "$SPEND_DIR/$key.meta"
      else
        rm -f "$SPEND_DIR/$key.tsv.tmp.$$"
      fi
    fi
    cat "$SPEND_DIR/$key.tsv" 2>/dev/null
  done > "$rows"
  # Drop cache entries for transcripts gone or outside every window we would read.
  find "$SPEND_DIR" -type f -mtime +35 -delete 2>/dev/null
  # 2. Account timeline from cswap's switch log (local time), as "epoch slot".
  # "Added account N: email" (a login) activates N; "Added account N from <source>"
  # (token/import) does not, so only the colon form counts.
  grep -E ' - INFO - (Switched from account [0-9]+ to [0-9]+|Added account [0-9]+:)' "$STATE_ROOT/claude-swap.log" 2>/dev/null |
    while IFS= read -r a; do
      local d to e
      d="$(printf '%s' "$a" | cut -c1-19)"
      to="$(printf '%s' "$a" | sed -nE 's/.*(Switched from account [0-9]+ to |Added account )([0-9]+).*/\2/p')"
      e="$(date -j -f '%Y-%m-%d %H:%M:%S' "$d" +%s 2>/dev/null)" || continue
      printf '%s %s\n' "$e" "$to"
    done | sort -n > "$sw"
  # 3. Price + aggregate (awk: dedupe by id keeping the largest output, then sum).
  # macOS awk has no strftime: bucket days as local day numbers, name them in jq.
  local tzoff; tzoff="$(date +%z | awk '{ s = substr($0, 1, 1) == "-" ? -1 : 1; print s * (substr($0, 2, 2) * 3600 + substr($0, 4, 2) * 60) }')"
  out="$(sort -t "$(printf '\t')" -k2,2n "$rows" | awk -F '\t' -v start="$start" -v swf="$sw" -v tzoff="${tzoff:-0}" '
    function price(m, sp, tot,   r) {
      # returns "in out cw5 cw1h cr" USD per MTok
      if (m ~ /(fable|mythos)-5-1/)        r = "10 50 12.5 20 0.25"
      else if (m ~ /(fable|mythos)-5/)     r = "10 50 12.5 20 1.00"
      else if (m ~ /opus-5-5/)             r = (sp == "fast") ? "8 40 10 16 0.40" : "4 20 5 8 0.20"
      else if (m ~ /opus-5/)               r = (sp == "fast") ? "10 50 12.5 20 1.00" : "5 25 6.25 10 0.50"
      else if (m ~ /opus-4-(5|6|7|8)/)     r = (sp == "fast") ? "10 50 12.5 20 1.00" : "5 25 6.25 10 0.50"
      else if (m ~ /opus-4/)               r = "15 75 18.75 30 1.50"
      else if (m ~ /sonnet-5-5/)           r = "2 10 2.5 4 0.10"
      else if (m ~ /sonnet-5/)             r = "2 10 2.5 4 0.20"
      else if (m ~ /sonnet-4/)             r = "3 15 3.75 6 0.30"
      else if (m ~ /haiku-5-5/)            r = (tot > 100000) ? "0.50 2.50 0.625 1.00 0.05" : "0.10 0.50 0.125 0.20 0.01"
      else if (m ~ /haiku-4-5/)            r = "1 5 1.25 2 0.10"
      else if (m ~ /haiku-3-5|3-5-haiku/)  r = "0.80 4 1 1.6 0.08"
      else r = ""
      return r
    }
    BEGIN { ns = 0; while ((getline l < swf) > 0) { split(l, p, " "); st[ns] = p[1]; ss[ns] = p[2]; ns++ } }
    $2 >= start && $1 != "" {
      id = $1
      if (!(id in seen) || $8 > o[id]) {
        if (!(id in seen)) { order[++nid] = id; seen[id] = 1 }
        if (!(id in ts)) ts[id] = $2    # first-seen time keeps the sorted account sweep valid
        mdl[id] = $3; i[id] = $4; c5[id] = $5; c1[id] = $6; cr[id] = $7; o[id] = $8; spd[id] = $9; ses[id] = $10
      }
    }
    END {
      k = 0
      for (n = 1; n <= nid; n++) {
        id = order[n]; m = mdl[id]
        if (m == "<synthetic>") continue
        r = price(m, spd[id], i[id] + c5[id] + c1[id] + cr[id])
        if (r == "") { unk[m] += i[id] + o[id]; continue }
        split(r, pr, " ")
        usd = (i[id] * pr[1] + o[id] * pr[2] + c5[id] * pr[3] + c1[id] * pr[4] + cr[id] * pr[5]) / 1e6
        # account active at ts (switch timeline is sorted)
        while (k < ns && st[k] <= ts[id]) { cura = ss[k]; k++ }
        a = (cura == "") ? "?" : cura
        tot += usd; bym[m] += usd; bya[a] += usd; bys[ses[id]] += usd
        if (m ~ /fable|mythos/) fs[ses[id]] += usd
        day = int((ts[id] + tzoff) / 86400); byd[day] += usd
        tin += i[id]; tout += o[id]; tcw += c5[id] + c1[id]; tcr += cr[id]; msgs++
      }
      printf "T\t%.4f\t%d\t%d\t%d\t%d\t%d\n", tot, msgs, tin, tout, tcw, tcr
      for (m in bym) printf "M\t%s\t%.4f\n", m, bym[m]
      for (a in bya) printf "A\t%s\t%.4f\n", a, bya[a]
      for (s in bys) printf "S\t%s\t%.4f\t%.4f\n", s, bys[s], fs[s] + 0
      for (d in byd) printf "D\t%s\t%.4f\n", d, byd[d]
      for (m in unk) printf "U\t%s\t%d\n", m, unk[m]
    }')"
  rm -f "$rows" "$sw"
  # 4. Plans per account, then JSON.
  acct_plans="{}"
  for slot in $(jq -r '.accounts | to_entries[] | select(((.value.disabled // false) or ((.value.kind // "") == "api_key")) | not) | .key' "$STATE_ROOT/sequence.json" 2>/dev/null); do
    acct_plans="$(printf '%s' "$acct_plans" | jq -c --arg s "$slot" --argjson p "$(_spend_plan_usd "$slot")" \
      --arg l "$(_label_for "$slot")" '. + {($s): {plan: $p, label: $l}}')"
  done
  printf '%s\n' "$out" | jq -R -s -c --argjson days "$days" --argjson start "$start" --argjson now "$now" \
    --argjson plans "$acct_plans" --arg asof "$SPEND_PRICES_AS_OF" '
    split("\n") | map(select(. != "") | split("\t")) as $r
    | ($r | map(select(.[0] == "T")) | first // ["T","0","0","0","0","0","0"]) as $t
    | ($t[1] | tonumber) as $usd
    | ($days * 12 / 52 / 7) as $weeksOfMonth
    | {days: $days, from: ($start | todate), to: ($now | todate), usd: $usd,
       messages: ($t[2] | tonumber),
       tokens: {input: ($t[3] | tonumber), output: ($t[4] | tonumber), cacheWrite: ($t[5] | tonumber), cacheRead: ($t[6] | tonumber)},
       byModel: ($r | map(select(.[0] == "M") | {model: .[1], usd: (.[2] | tonumber)})
                 | sort_by(-.usd) | map(. + {share: (if $usd > 0 then (.usd / $usd * 100 | floor) else 0 end)})),
       byAccount: ($r | map(select(.[0] == "A") | .[1] as $s
                 | ($plans[$s] // {plan: 0, label: (if $s == "?" then "unknown" else "account " + $s end)}) as $p
                 | {slot: $s, label: $p.label, usd: (.[2] | tonumber),
                    planUsd: (($p.plan * $weeksOfMonth * 100 | round) / 100)}
                 | . + {ratio: (if .planUsd > 0 then ((.usd / .planUsd * 10 | round) / 10) else null end)})
                 | sort_by(-.usd)),
       byDay: ($r | map(select(.[0] == "D") | {day: ((.[1] | tonumber) * 86400 | todate | .[0:10]), usd: (.[2] | tonumber)}) | sort_by(.day)),
       topSessions: ($r | map(select(.[0] == "S") | {sessionId: .[1], usd: (.[2] | tonumber),
                     fableShare: (if (.[2] | tonumber) > 0 then ((.[3] | tonumber) / (.[2] | tonumber) * 100 | floor) else 0 end)})
                     | sort_by(-.usd) | .[:5]),
       unpricedModels: ($r | map(select(.[0] == "U") | {model: .[1], tokens: (.[2] | tonumber)})),
       pricesAsOf: $asof}
    # What the user pays: the plan of every registered account, idle ones included.
    | . + {planUsd: (([$plans | to_entries[] | .value.plan] | add // 0) * $weeksOfMonth * 100 | round / 100)}
    | . + {ratio: (if .planUsd > 0 then ((.usd / .planUsd * 10 | round) / 10) else null end)}' \
    > "$STATE_ROOT/cache/spend.json.tmp.$$"
  # Replace the cache only with a real result, so the statusline never loses the last good value.
  if jq -e '.usd' "$STATE_ROOT/cache/spend.json.tmp.$$" >/dev/null 2>&1; then
    cat "$STATE_ROOT/cache/spend.json.tmp.$$"
    mv "$STATE_ROOT/cache/spend.json.tmp.$$" "$STATE_ROOT/cache/spend.json"
  else
    rm -f "$STATE_ROOT/cache/spend.json.tmp.$$"; echo '{"error":"spend-failed"}'; return 1
  fi
}

cmd_schedule_kill() {
  echo "phase-2 feature not enabled" >&2
  exit 1
}

main() {
  local sub="${1:-}"
  [ $# -gt 0 ] && shift
  case "$sub" in
    whoami)        cmd_whoami "$@";;
    sessions)      cmd_sessions "$@";;
    preflight)     cmd_preflight "$@";;
    path)          cmd_path "$@";;
    flag)          cmd_flag "$@";;
    status)        cmd_status "$@";;
    cancel)        cmd_cancel "$@";;
    history)       cmd_history "$@";;
    restore)       cmd_restore "$@";;
    auto)          cmd_auto "$@";;
    artifacts)
      case "${1:-}" in
        find|list) shift; _art_find "$@"; exit $?;;
        link)      shift; _art_link "$@"; exit $?;;
        index)     _art_backfill; exit $?;;
        *)         cmd_artifacts "$@";;
      esac;;
    artifact-log)  cmd_artifact_log "$@";;
    ratelimit)     cmd_ratelimit "$@";;
    budget)        cmd_budget "$@";;
    login)         cmd_login "$@";;
    version-check) cmd_version_check "$@";;
    codex-usage)   cmd_codex_usage "$@";;
    codex-ready)   cmd_codex_ready "$@";;
    codex-review)  cmd_codex_review "$@";;
    route-guard)   cmd_route_guard "$@";;
    spend)         cmd_spend "$@";;
    schedule-kill) cmd_schedule_kill "$@";;
    *) usage;;
  esac
}

if [ "${BASH_SOURCE[0]:-}" = "$0" ]; then
  main "$@"
fi
