#!/usr/bin/env bash
# cc-progress — progress bars with ETA for the Claude Code status line.
#
# Writers (Claude, a loop, a hook) record progress in one small JSON file per
# bar under $CC_PROGRESS_DIR/<session_id>/<bar_id>.json. The status line polls
# those files on its refreshInterval and derives elapsed time and ETA at render
# time, so the bar keeps moving while a single long command runs.
set -u

VERSION=1.0.0
ROOT="${CC_PROGRESS_DIR:-$HOME/.claude/progress}"
KEEP_DONE="${CC_PROGRESS_KEEP_DONE:-60}"       # seconds a finished bar stays visible
STALE_HIDE="${CC_PROGRESS_STALE_HIDE:-7200}"   # seconds without an update before a running bar is hidden
MUTED="${CC_PROGRESS_MUTED:-90}"               # SGR code for secondary text: 90 = the theme's bright black, 2 = faint
ASCII="${CC_PROGRESS_ASCII:-0}"                # 1 = no braille, no block glyphs (fonts without them)
PRUNE_AFTER="${CC_PROGRESS_PRUNE_AFTER:-600}"  # prune: a running bar with no write for this long is stale

usage() {
  cat <<'EOF'
cc-progress — progress bars with ETA for the Claude Code status line

  cc-progress start [--total N] [--done N] [--label TEXT] [--unit WORD] [--note TEXT]
                    [--weights FILE] [--tasks] [--agents]
                                               --done: units already finished before the bar
                                               started (shown, but not counted into the rate)
                                               --weights: "WEIGHT NAME" per line (wc -l style) or a
                                               JSON object; the ETA is then on work, not on items
                                               --tasks / --agents: follow the task list / subagents
  cc-progress tick  [N] [--item NAME] [--note TEXT]
                                               advance by N (default 1); --item credits NAME's weight
  cc-progress set   DONE [--total N] [--note TEXT]
  cc-progress total N                          correct the total mid-run
  cc-progress note  TEXT                       what is being worked on now (a weighted item name
                                               lets the ETA expect that item's cost)
  cc-progress done  [--note TEXT]              mark finished (kept on screen briefly)
  cc-progress fail  [--note TEXT]              mark failed
  cc-progress clear [--all]                    remove the bar (or every bar of the session)
  cc-progress prune [--all]                    remove finished, failed and stale bars (--all: every bar);
                                               hook-fed bars (--tasks, --agents) are never stale
  cc-progress pipe  [--total N] [--label TEXT] [--match REGEX] [--quiet]
                                               pass stdin through, one tick per (matching) line
  cc-progress show                             print the bars as the status line would
  cc-progress doctor                           check the installation and whether the status line is polling
  cc-progress render                           status line entry point (session JSON on stdin)
  cc-progress hook                             hook entry point (hook JSON on stdin)

Common options:
  --id NAME        bar name, default "main"; use several names for several bars
  --session ID     target session, default $CC_PROGRESS_SESSION or $CLAUDE_CODE_SESSION_ID

Mutating commands never fail the calling job: on an operational error they warn
on stderr and exit 0. Only a malformed invocation exits 2.
EOF
}

die()  { printf 'cc-progress: %s\n' "$*" >&2; exit 2; }
warn() { printf 'cc-progress: %s\n' "$*" >&2; }

is_num() { [[ ${1:-} =~ ^[0-9]+(\.[0-9]+)?$ ]]; }

# ---------------------------------------------------------------- arguments
CMD="${1:-}"; [ $# -gt 0 ] && shift
ID=main SID_OPT="" TOTAL="" LABEL="" UNIT="" NOTE="" MATCH="" DONE0=0 WEIGHTS="" ITEM=""
HAS_TOTAL=0 HAS_LABEL=0 HAS_NOTE=0 TASKS=0 AGENTS=0 ALL=0 QUIET=0
POS=()
while [ $# -gt 0 ]; do
  case "$1" in
    --id)       [ $# -ge 2 ] || die "--id needs a value";      ID=$2; shift 2 ;;
    --session)  [ $# -ge 2 ] || die "--session needs a value"; SID_OPT=$2; shift 2 ;;
    --total)    [ $# -ge 2 ] || die "--total needs a value";   TOTAL=$2; HAS_TOTAL=1; shift 2 ;;
    --done)     [ $# -ge 2 ] || die "--done needs a value";    DONE0=$2; shift 2 ;;
    --label)    [ $# -ge 2 ] || die "--label needs a value";   LABEL=$2; HAS_LABEL=1; shift 2 ;;
    --unit)     [ $# -ge 2 ] || die "--unit needs a value";    UNIT=$2; shift 2 ;;
    --note)     [ $# -ge 2 ] || die "--note needs a value";    NOTE=$2; HAS_NOTE=1; shift 2 ;;
    --match)    [ $# -ge 2 ] || die "--match needs a value";   MATCH=$2; shift 2 ;;
    --tasks)    TASKS=1; shift ;;
    --agents)   AGENTS=1; shift ;;
    --weights)  [ $# -ge 2 ] || die "--weights needs a value"; WEIGHTS=$2; shift 2 ;;
    --item)     [ $# -ge 2 ] || die "--item needs a value";    ITEM=$2; shift 2 ;;
    --all)      ALL=1; shift ;;
    --quiet)    QUIET=1; shift ;;
    -h|--help)  usage; exit 0 ;;
    --)         shift; POS+=("$@"); break ;;
    *)          POS+=("$1"); shift ;;
  esac
done

[[ $ID =~ ^[A-Za-z0-9_.-]+$ ]] || die "invalid --id '$ID' (letters, digits, _ . - only)"
[ "$HAS_TOTAL" = 0 ] || is_num "$TOTAL" || die "--total must be a non-negative number"
is_num "$DONE0" || die "--done must be a non-negative number"

SID=""
need_session() {
  SID="${SID_OPT:-${CC_PROGRESS_SESSION:-${CLAUDE_CODE_SESSION_ID:-}}}"
  [ -n "$SID" ] || die "no session id: pass --session or run inside Claude Code"
  [[ $SID =~ ^[A-Za-z0-9_-]+$ ]] || die "invalid session id '$SID'"
}

# ------------------------------------------------------------------- state
JQ_LIB='
# samples = [[time, done], ...]: the progress history the renderer derives rate and
# ETA from. updated_at is only a heartbeat (the writer is alive); a `set` to the
# same value, a `note` or a `total` never add a sample, so they cannot move the
# ETA anchor or inflate the rate.
# samples are [time, done, weighted_done]; weighted_done equals done when the bar has no weights.
def wmean: if (.weights | length) > 0 then ((.weights | add) / (.weights | length)) else 1 end;
def bump($d; $w):
  if $d != .done then
    .done = $d | .wdone = $w
    | .samples = (
        # a jump of more than one unit within 2 s of start is work that was finished
        # before the bar existed: make it the baseline, do not count it as rate
        if (.samples | length) == 1 and (now - .samples[0][0]) < 2 and ($d - .samples[0][1]) > 1
        then [[now, $d, $w]]
        # bursts: keep at most one sample per second, the first sample never moves
        elif (.samples | length) > 1 and (now - .samples[-2][0]) < 1
        then .samples[:-1] + [[now, $d, $w]]
        else .samples + [[now, $d, $w]] end
        | .[-30:])
  else . end
  | .updated_at = now;
# unweighted advance: weighted progress moves by the mean weight per unit
def bump($d): bump($d; (.wdone // .done) + ($d - .done) * wmean);
def need: if . == null then error("no such bar; run `cc-progress start` first") else . end;
def running: .state = "running" | .finished_at = null;
'

# mutate FILTER [jq args...] — apply FILTER to the bar (null when absent), atomically.
mutate() {
  local filter=$1; shift
  local dir="$ROOT/$SID" file tmp err rc
  mkdir -p "$dir" 2>/dev/null || { warn "cannot create $dir"; return 0; }
  file="$dir/$ID.json"
  (
    flock -w 5 9 || { warn "lock timeout on $dir"; exit 0; }
    tmp=$(mktemp "$dir/.$ID.XXXXXX") || { warn "cannot write in $dir"; exit 0; }
    if [ -s "$file" ]; then
      err=$(jq -c "$@" "$JQ_LIB $filter" "$file" 2>&1 >"$tmp"); rc=$?
    else
      err=$(jq -nc "$@" "$JQ_LIB $filter" 2>&1 >"$tmp"); rc=$?
    fi
    if [ $rc -eq 0 ] && [ -s "$tmp" ]; then
      mv -f "$tmp" "$file"
    else
      rm -f "$tmp"
      warn "${err#jq: error (at *): }"
    fi
  ) 9>"$dir/.lock"
  return 0
}

gc() { # drop session directories untouched for more than two days
  [ -d "$ROOT" ] || return 0
  find "$ROOT" -mindepth 1 -maxdepth 1 -type d -mtime +2 -exec rm -rf {} + 2>/dev/null
  return 0
}

cmd_start() {
  need_session; gc
  local total=null mode=manual weights='{}'
  [ "$HAS_TOTAL" = 1 ] && total=$TOTAL
  [ "$TASKS" = 1 ] && mode=tasks
  [ "$AGENTS" = 1 ] && mode=agents
  [ "$ID" = main ] && [ "$mode" != manual ] && ID=$mode
  [ "$HAS_LABEL" = 1 ] || LABEL=$ID
  if [ -n "$WEIGHTS" ]; then
    [ -r "$WEIGHTS" ] || die "--weights: cannot read $WEIGHTS"
    weights=$(jq -c 'if type == "object" then map_values(tonumber) else error("x") end' "$WEIGHTS" 2>/dev/null) \
      || weights=$(awk 'NF >= 2 && $1 ~ /^[0-9.]+$/ { w = $1; $1 = ""; sub(/^ +/, ""); printf "%s%s\t%s", (n++ ? "\n" : ""), w, $0 }' "$WEIGHTS" \
                   | jq -Rsc 'split("\n") | map(select(length > 0) | split("\t") | {key: .[1], value: (.[0] | tonumber)}) | from_entries')
    [ -n "$weights" ] && [ "$weights" != "{}" ] || die "--weights: no \"WEIGHT NAME\" lines or JSON object in $WEIGHTS"
    [ "$HAS_TOTAL" = 1 ] || total=$(jq 'length' <<<"$weights")
  fi
  mutate '{id: $id, label: $label, unit: $unit, total: $total, done: $done0, state: "running",
           note: $note, mode: $mode, started_at: now, updated_at: now, finished_at: null,
           weights: $weights, wdone: null, samples: [], credited: [],
           tasks: {created: 0, completed: [], deleted: [], subjects: {}}}
          | .wdone = $done0 * wmean | .samples = [[now, $done0, .wdone]]' \
    --arg id "$ID" --arg label "$LABEL" --arg unit "$UNIT" --arg note "$NOTE" \
    --arg mode "$mode" --argjson total "$total" --argjson done0 "$DONE0" --argjson weights "$weights"
}

cmd_tick() {
  need_session
  local n=${POS[0]:-1}
  is_num "$n" || die "tick: N must be a non-negative number"
  mutate 'need | running
          | if $item != "" and (.weights[$item] != null) then
              bump(.done + $n; (.wdone // .done) + .weights[$item]) | .credited += [$item]
            else bump(.done + $n) end
          | if $hasnote then .note = $note else . end' \
    --argjson n "$n" --arg note "$NOTE" --argjson hasnote "$HAS_NOTE" --arg item "$ITEM"
}

cmd_set() {
  need_session
  local n=${POS[0]:-}
  is_num "$n" || die "set: DONE must be a non-negative number"
  local total=null; [ "$HAS_TOTAL" = 1 ] && total=$TOTAL
  mutate 'need | running | (if $total != null then .total = $total else . end) | bump($n)
          | if $hasnote then .note = $note else . end' \
    --argjson n "$n" --argjson total "$total" --arg note "$NOTE" --argjson hasnote "$HAS_NOTE"
}

cmd_total() {
  need_session
  local n=${POS[0]:-}
  is_num "$n" || die "total: N must be a non-negative number"
  mutate 'need | .total = $n | .updated_at = now' --argjson n "$n"
}

cmd_note() {
  need_session
  [ ${#POS[@]} -gt 0 ] && NOTE="${POS[*]}"
  mutate 'need | .note = $note | .updated_at = now' --arg note "$NOTE"
}

cmd_finish() { # $1 = done | error
  need_session
  mutate 'need | .state = $state | .finished_at = now | .updated_at = now
          | if $state == "done" and (.weights | length) > 0 then .wdone = (.weights | add) else . end
          | if $hasnote then .note = $note else . end' \
    --arg state "$1" --arg note "$NOTE" --argjson hasnote "$HAS_NOTE"
}

cmd_prune() { # finished, failed, or running with no write for PRUNE_AFTER seconds; --all: everything
  need_session
  local f n=0
  if [ "$ALL" = 1 ]; then rm -rf "${ROOT:?}/$SID"; echo "removed every bar of the session"; return 0; fi
  shopt -s nullglob
  for f in "$ROOT/$SID"/*.json; do
    if jq -e --argjson stale "$PRUNE_AFTER" '
         .state != "running"
         or (.total != null and .total > 0 and .done >= .total)
         or (.mode == "manual" and (now - .updated_at) > $stale)' "$f" >/dev/null 2>&1; then
      rm -f "$f"; n=$((n + 1)); echo "removed $(basename "$f" .json)"
    fi
  done
  shopt -u nullglob
  [ "$n" -gt 0 ] || echo "nothing to prune"
  return 0
}

cmd_clear() {
  need_session
  if [ "$ALL" = 1 ]; then rm -rf "${ROOT:?}/$SID"; else rm -f "$ROOT/$SID/$ID.json"; fi
}

cmd_pipe() {
  need_session
  local own=0 pending=0 last=$SECONDS line
  if [ "$HAS_TOTAL" = 1 ] || [ "$HAS_LABEL" = 1 ] || [ ! -s "$ROOT/$SID/$ID.json" ]; then
    cmd_start; own=1
  fi
  while IFS= read -r line || [ -n "$line" ]; do
    [ "$QUIET" = 1 ] || printf '%s\n' "$line"
    if [ -z "$MATCH" ] || [[ $line =~ $MATCH ]]; then pending=$((pending + 1)); fi
    if [ "$pending" -gt 0 ] && [ "$SECONDS" != "$last" ]; then
      POS=("$pending"); cmd_tick; pending=0; last=$SECONDS
    fi
  done
  if [ "$pending" -gt 0 ]; then POS=("$pending"); cmd_tick; fi
  [ "$own" = 1 ] && cmd_finish done
  return 0
}

# ------------------------------------------------------------------ render
JQ_RENDER='
def rep($n): if $n <= 0 then "" else . * $n end;
def pad2: tostring | if length < 2 then "0" + . else . end;
def dur: (if . < 0 then 0 else . end | floor) as $s
  | if $s < 60 then "\($s)s"
    elif $s < 3600 then "\($s / 60 | floor)m\($s % 60 | pad2)s"
    else "\($s / 3600 | floor)h\($s % 3600 / 60 | floor | pad2)m" end;
def num: if . == floor then floor | tostring else (. * 10 | round / 10 | tostring) end;
# Colours are the 16 ANSI roles only (green, yellow, red, cyan, bright black, bold),
# so the terminal theme decides what they look like; nothing is hard-coded.
def c($code): if $color and length > 0 then "\u001b[\($code)m" + . + "\u001b[0m" else . end;
def muted: c($muted);
def padr($n): length as $l | . + (" " | rep($n - $l));
def singular: if test("s$") and length > 1 then .[:-1] else . end;
# glyphs; CC_PROGRESS_ASCII=1 for fonts without braille or block characters
def G: if $ascii then {full: "#", empty: "-", parts: [""], scan: "===", spin: ["|", "/", "-", "\\"],
                       ok: "+", bad: "x", wait: ">", note: ">", rule: "-", ellipsis: "...", more: ".."}
       else {full: "█", empty: "░", parts: ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"], scan: "▓▓▓",
             spin: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
             ok: "✔", bad: "✘", wait: "▶", note: "›", rule: "─", ellipsis: "…", more: "…"} end;
def clean: gsub("\u001b\\[[0-9;?]*[A-Za-z]"; " ") | gsub("[[:cntrl:]]"; " ") | gsub(" +"; " ") | gsub("^ | $"; "");
def gauge($f; $w; $tone):
  ($f * $w * (G.parts | length) | floor) as $e | (G.parts | length) as $n
  | ($e / $n | floor) as $full | ($e % $n) as $p
  | (if $full >= $w or $p == 0 then "" else G.parts[$p] end) as $part
  | ((G.full | rep($full)) + $part | c($tone)) + (G.empty | rep($w - $full - ($part | length)) | muted);
# no total: a block sweeping back and forth, one cell per poll
def scanner($now; $w; $tone):
  (($w - 3) * 2) as $period | (($now | floor) % $period) as $i
  | (if $i < $w - 2 then $i else $period - $i end) as $pos
  | (G.empty | rep($pos) | muted) + (G.scan | c($tone)) + (G.empty | rep($w - 3 - $pos) | muted);
# one frame per poll (refreshInterval 1 s): a bar that is not moving is visibly still watched
def spinner($now): G.spin[($now | floor) % (G.spin | length)];
def line($now; $labelw):
  . as $b
  | ($b.state == "running") as $run
  | ((if $run then $now else ($b.finished_at // $b.updated_at) end) - $b.started_at) as $elapsed
  | ($b.weights // {} | length > 0 and (($b.weights | add) > 0)) as $weighted   # all-zero weights: unweighted
  | (if $weighted then ($b.weights | add) else $b.total end) as $wtotal
  | (if $weighted then ($b.wdone // $b.done) else $b.done end) as $wdone
  | ($b.samples[0] // [$b.started_at, 0, 0]) as $s0
  | ($b.samples[-1] // $s0) as $last                 # last real progress, not the last heartbeat
  | ($b.done - $s0[1]) as $gained                    # units completed while the bar was watching
  | (if $weighted then $wdone - ($s0[2] // $s0[1]) else $gained end) as $wgained   # the same, in work units
  | (if $run then
       (if $wgained > 0 and ($last[0] - $s0[0]) >= 1 then $wgained / ($last[0] - $s0[0]) else null end)
     else (if $wgained > 0 and $elapsed > 0 then $wgained / $elapsed else null end) end) as $rate
  | ($b.total != null and $b.total > 0 and $wtotal > 0) as $bounded
  | (if $bounded then $wtotal - $wdone else 0 end) as $left
  # the current item: its weight if the note names a weighted item, else the mean weight
  | (if $weighted then ($b.weights[$b.note // ""] // (($b.weights | add) / ($b.weights | length))) else 1 end) as $wcur
  # the current unit is expected to finish wcur/rate after the last one; while it is
  # overdue the ETA floors at what the rest will take, and is shown as "≥"
  | (if $run and $rate != null and $bounded and $left > 0
     then ($last[0] + $wcur / $rate - $now) else null end) as $next
  | ($next != null and $next < 0) as $overdue
  # once overdue, the average counts the unit in flight (as tqdm does), so a workload
  # of unequal items pushes the estimate out instead of pretending
  | (if $overdue then $wgained / ($now - $s0[0]) else $rate end) as $rate
  | (if $next != null then ([$next, 0] | max) + ([$left - $wcur, 0] | max) / $rate else null end) as $eta
  | ($now - $b.updated_at) as $idle
  # hook-fed bars (tasks, agents) are written only when something happens: silence is not a stall
  | ($run and $b.mode == "manual" and $idle > ([120, (if $rate != null then 3 * $wcur / $rate else 0 end)] | max)) as $stalled
  | (if $b.unit != "" then " " + $b.unit else "" end) as $unit
  | (if $run then (if $stalled then G.wait else spinner($now) end) elif $b.state == "done" then G.ok else G.bad end) as $icon
  | (if $run then (if $stalled then "33" else "36" end) elif $b.state == "done" then "1;32" else "1;31" end) as $tone
  | (if $run then "32" elif $b.state == "done" then "32" else "31" end) as $fill
  | ($b.label | clean | if length > 32 then .[:31] + G.more else . end | padr($labelw)) as $label
  | (if $cols > 0 and $cols < 80 then 10 else 20 end) as $w
  | (if $bounded then "\([$wdone / $wtotal * 100, 100] | min | floor)%" else "" end) as $pct
  | (if $bounded then "\($b.done | num)/\($b.total | num)\($unit) " + ($pct | c("1"))
     else "\($b.done | num)\($unit)" end) as $counts
  | (if $run then ($elapsed | dur)
     elif $b.state == "done" then "done in " + ($elapsed | dur)
     else "failed after " + ($elapsed | dur) end) as $time
  | (if $eta != null then
       "ETA " + (if $overdue then "≥" else "~" end) + ($eta | dur)
       + (if $eta >= 60 then " at " + ($now + $eta | strflocaltime("%H:%M")) else "" end)
     elif $run and $bounded and $left > 0 then "ETA " + G.ellipsis
     else "" end) as $etatxt
  | (if $rate == null or $weighted then ""
     elif $rate >= 1 then "\($rate | num)/s"
     else (1 / $rate | if . < 10 then num + "s" else dur end)
          + (if $b.unit != "" then "/" + ($b.unit | singular) else " each" end) end) as $ratetxt
  | (if $stalled then "no update for " + ($idle | dur) else "" end) as $stalltxt
  | ([$time, $etatxt, $ratetxt] | map(select(length > 0)) | join(" · ")) as $meta
  | ($b.note // "" | clean) as $rawnote
  | (if $overdue and ($rawnote | length) > 0 then " " + ($now - $last[0] | dur) else "" end) as $onit
  | (2 + ($label | length) + (if $bounded then $w + 1 else 0 end) + 1 + ($counts | length)
     + 2 + ($meta | length) + (if $stalled then 3 + ($stalltxt | length) else 0 end) + ($onit | length)) as $used
  | (if $cols > 0 then $cols - $used - 6 else 60 end) as $room
  | (if ($rawnote | length) == 0 or $room < 4 then ""
     elif ($rawnote | length) > $room then $rawnote[:$room - 1] + G.more
     else $rawnote end) as $note
  | ($icon | c($tone)) + " " + ($label | c("1"))
    + " " + (if $bounded then gauge([$wdone / $wtotal, 1] | min; $w; $fill)
             elif $run then scanner($now; $w; $fill) else (G.empty | rep($w) | muted) end)
    + " " + $counts
    + "  " + ($meta | muted)
    + (if $stalled then ("  " + $stalltxt | c("33")) else "" end)
    + (if $note != "" then ("  " + G.note + " " | muted) + $note + ($onit | c("33")) else "" end);
# visible width of a rendered line
def width: gsub("\u001b\\[[0-9;]*m"; "") | length;
now as $now
| map(select(type == "object" and has("started_at")))
# a bar that reached its total counts as finished even if `done` was never called
| map(if .state == "running" and .total != null and .total > 0 and .done >= .total
      then .state = "done" | .finished_at = .updated_at
           | if (.weights // {} | length) > 0 then .wdone = (.weights | add) else . end
      else . end)
| map(select(
    (.state == "running" and ($now - .updated_at) < $stalehide)
    or (.state != "running" and ($now - (.finished_at // .updated_at)) < $keep)))
| sort_by(.started_at)
| (map(.label | clean | length) | max // 0 | if . > 32 then 32 else . end) as $labelw
# one corrupt file degrades one line, never the whole block
| [.[] | . as $b | try line($now; $labelw) catch ((G.bad | c("1;31")) + " " + ($b.label // $b.id // "?" | tostring | clean) + " cannot be rendered")]
# a rule after the bars, as wide as the widest bar, so the rows above read as one
# block (progress) and the rows below as another (the usual status line)
| if length == 0 or ($divider | not) then .[]
  else (map(width) | max) as $wmax
     | .[], (G.rule | rep(if $cols > 0 then ([$wmax, $cols] | min) else $wmax end) | muted) end
'

render_session() { # SID COLS COLOR DIVIDER
  local files
  shopt -s nullglob
  files=("$ROOT/$1"/*.json)
  shopt -u nullglob
  [ ${#files[@]} -gt 0 ] || return 0
  jq -rs --argjson cols "$2" --argjson color "$3" --argjson divider "${4:-false}" \
     --arg muted "$MUTED" --argjson ascii "$([ "$ASCII" = 1 ] && echo true || echo false)" \
     --argjson keep "$KEEP_DONE" --argjson stalehide "$STALE_HIDE" \
     "$JQ_RENDER" "${files[@]}" 2>/dev/null
  return 0
}

cols() { local c=${COLUMNS:-0}; [[ $c =~ ^[0-9]+$ ]] || c=0; printf '%s' "$c"; }

cmd_render() {
  local input sid color=true
  input=$(cat)
  sid=$(jq -r '.session_id // empty' <<<"$input" 2>/dev/null)
  [ -n "$sid" ] || sid=${CLAUDE_CODE_SESSION_ID:-}
  [[ $sid =~ ^[A-Za-z0-9_-]+$ ]] || return 0
  [ -n "${NO_COLOR:-}" ] && color=false
  # stamp each poll so `doctor` can tell whether the status line is really refreshing
  [ -d "$ROOT/$sid" ] && : > "$ROOT/$sid/.rendered" 2>/dev/null
  render_session "$sid" "$(cols)" "$color" true
}

cmd_doctor() {
  local settings="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json" script interval bad=0 age
  ok()  { printf 'ok    %s\n' "$*"; }
  bad() { printf 'FAIL  %s\n' "$*"; bad=1; }
  command -v jq >/dev/null && ok "jq present" || bad "jq missing"
  command -v flock >/dev/null && ok "flock present" || bad "flock missing"
  SID="${SID_OPT:-${CC_PROGRESS_SESSION:-${CLAUDE_CODE_SESSION_ID:-}}}"
  [ -n "$SID" ] && ok "session id $SID" || bad "no session id (not inside Claude Code?)"
  script=$(jq -r '.statusLine.command // empty' "$settings" 2>/dev/null); script=${script/#\~/$HOME}
  if [ -f "$script" ] && grep -q 'CC-PROGRESS' "$script"; then ok "status line block in $script"
  else bad "status line block missing — run install.sh"; fi
  interval=$(jq -r '.statusLine.refreshInterval // empty' "$settings" 2>/dev/null)
  if [ -z "$interval" ]; then bad "statusLine.refreshInterval unset — the bar would only redraw when a message arrives"
  elif [ "$interval" -gt 1 ] 2>/dev/null; then printf 'note  statusLine.refreshInterval = %ss; 1 s gives a smooth spinner and a live countdown\n' "$interval"
  else ok "statusLine.refreshInterval = ${interval}s"; fi
  jq -e '[.hooks.PostToolUse[]?.hooks[]?.command // "" | test("cc-progress")] | any' "$settings" >/dev/null 2>&1 \
    && ok "task-list hook registered" || bad "task-list hook missing — run install.sh"
  jq -e '[.hooks.SessionEnd[]?.hooks[]?.command // "" | test("cc-progress")] | any' "$settings" >/dev/null 2>&1 \
    && ok "session-end cleanup hook registered" || bad "session-end hook missing — run install.sh"
  jq -e '[.hooks.SubagentStop[]?.hooks[]?.command // "" | test("cc-progress")] | any' "$settings" >/dev/null 2>&1 \
    && ok "subagent hooks registered" || bad "subagent hooks missing — run install.sh"
  if [ -n "$SID" ] && [ -e "$ROOT/$SID/.rendered" ]; then
    age=$(( $(date +%s) - $(stat -c %Y "$ROOT/$SID/.rendered") ))
    if [ "$age" -le $(( ${interval:-2} * 3 + 2 )) ]; then ok "status line polled this session ${age}s ago"
    else printf 'note  last status line poll for this session was %ss ago (normal if no bar has been active)\n' "$age"; fi
  else
    printf 'note  status line has not polled a bar in this session yet\n'
  fi
  return $bad
}

cmd_show() {
  need_session
  local color=false
  [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && color=true
  render_session "$SID" "$(cols)" "$color" false
}

# -------------------------------------------------------------------- hook
# PostToolUse on TaskCreate|TaskUpdate|TodoWrite (and TaskCreated/TaskCompleted) keep a
# `--tasks` bar in step with Claude's own task list; SubagentStart/SubagentStop keep an
# `--agents` bar in step with running subagents; SessionEnd removes the session's bars.
# Every handler is a few milliseconds of jq on a local file: nothing reaches the model.
cmd_hook() {
  local sid=${CLAUDE_CODE_SESSION_ID:-} input event f
  input=$(cat)
  [ -n "${CC_PROGRESS_HOOK_LOG:-}" ] && printf '%s\n' "$input" >> "$CC_PROGRESS_HOOK_LOG"  # debugging aid
  if [ -z "$sid" ]; then sid=$(jq -r '.session_id // empty' <<<"$input" 2>/dev/null); fi
  [[ $sid =~ ^[A-Za-z0-9_-]+$ ]] || return 0
  [ -d "$ROOT/$sid" ] || return 0
  event=$(jq -r '.hook_event_name // empty' <<<"$input" 2>/dev/null)
  if [ "$event" = SessionEnd ]; then rm -rf "${ROOT:?}/$sid"; return 0; fi
  SID=$sid
  case "$event" in
    SubagentStart|SubagentStop)
      for f in "$ROOT/$sid"/*.json; do
        [ -e "$f" ] || continue
        grep -q '"mode":"agents"' "$f" && grep -q '"state":"running"' "$f" || continue
        ID=$(basename "$f" .json)
        mutate '($h.agent_id // "" | tostring) as $a
          | (.tasks.completed + (if $event == "SubagentStop" then [$a] else [] end)) as $done
          | if $event == "SubagentStart" then
              .tasks.created += 1 | .total = .tasks.created
              | .tasks.subjects[$a] = ($h.agent_type // "agent")
              | .note = (.tasks.subjects | to_entries | map(select(.key as $k | $done | index($k) | not)) | map(.value) | unique | join(", "))
              | .updated_at = now
            else
              .tasks.completed |= (. + [$a] | unique) | .total = ([.tasks.created, (.tasks.completed | length)] | max)
              | .note = (.tasks.subjects | to_entries | map(select(.key as $k | $done | index($k) | not)) | map(.value) | unique | join(", "))
              | bump(.tasks.completed | length)
            end' --argjson h "$input" --arg event "$event"
      done
      return 0 ;;
    TaskCreated|TaskCompleted)
      # field names read defensively: these events are newer than the payloads captured in tests/
      for f in "$ROOT/$sid"/*.json; do
        [ -e "$f" ] || continue
        grep -q '"mode":"tasks"' "$f" && grep -q '"state":"running"' "$f" || continue
        ID=$(basename "$f" .json)
        mutate '($h.task_id // $h.task.id // $h.tool_input.taskId // $h.taskId // "" | tostring) as $k
          | ($h.task_subject // $h.task.subject // $h.subject // null) as $subj
          | if $event == "TaskCreated" then
              (if $k != "" and (.tasks.subjects[$k] == null) then .tasks.created += 1 | .tasks.subjects[$k] = ($subj // "task \($k)") else . end)
            else
              (if $k != "" then .tasks.completed |= (. + [$k] | unique) else . end)
            end
          | .total = ([.tasks.created - (.tasks.deleted | length), (.tasks.completed | length)] | max)
          | bump(.tasks.completed | length)' --argjson h "$input" --arg event "$event"
      done
      return 0 ;;
    PostToolUse) ;;
    *) return 0 ;;
  esac
  for f in "$ROOT/$sid"/*.json; do
    [ -e "$f" ] || continue
    grep -q '"mode":"tasks"' "$f" && grep -q '"state":"running"' "$f" || continue
    ID=$(basename "$f" .json)
    mutate '
      def retotal: .total = ([.tasks.created - (.tasks.deleted | length), (.tasks.completed | length)] | max);
      ($h.tool_name // "") as $t | ($h.tool_input // {}) as $i
      | if $t == "TodoWrite" then
          ($i.todos // []) as $td
          | .total = ($td | length)
          | .note = (($td | map(select(.status == "in_progress")) | .[0] | (.activeForm // .content)) // .note)
          | bump($td | map(select(.status == "completed")) | length)
        elif $t == "TaskCreate" then
          ($h.tool_response.task.id? // null | if . == null then null else tostring end) as $k
          # the TaskCreated event may have counted this task already
          | if $k != null and (.tasks.subjects[$k] != null) then . else
              .tasks.created += 1
              | (if $k != null then .tasks.subjects[$k] = ($i.subject // "task \($k)") else . end)
            end
          | retotal | .updated_at = now
        elif $t == "TaskUpdate" and ($i.taskId != null) then
          ($i.taskId | tostring) as $k
          | (if $i.status == "completed" then .tasks.completed |= (. + [$k] | unique)
             elif $i.status == "deleted" then .tasks.deleted |= (. + [$k] | unique) | .tasks.completed -= [$k]
             elif $i.status == "in_progress" or $i.status == "pending" then
               .tasks.completed -= [$k]
               | (($i.activeForm // $i.subject // .tasks.subjects[$k]) as $what
                  | if $i.status == "in_progress" and $what != null then .note = $what else . end)
             else . end)
          | retotal | bump(.tasks.completed | length)
        else . end' --argjson h "$input"
  done
  return 0
}

case "$CMD" in
  start)   cmd_start ;;
  tick)    cmd_tick ;;
  set)     cmd_set ;;
  total)   cmd_total ;;
  note)    cmd_note ;;
  done)    cmd_finish done ;;
  fail)    cmd_finish error ;;
  clear)   cmd_clear ;;
  prune)   cmd_prune ;;
  pipe)    cmd_pipe ;;
  show)    cmd_show ;;
  render)  cmd_render ;;
  hook)    cmd_hook ;;
  doctor)  cmd_doctor; exit $? ;;
  version|--version) echo "cc-progress $VERSION" ;;
  ""|help|-h|--help) usage ;;
  *)       die "unknown command '$CMD' (try: cc-progress help)" ;;
esac
exit 0
