diff --git a/.claude/skills/iterm2/scripts/manifest.txt b/.claude/skills/iterm2/scripts/manifest.txt index 7bfb6f4c053..3811056f1dc 100644 --- a/.claude/skills/iterm2/scripts/manifest.txt +++ b/.claude/skills/iterm2/scripts/manifest.txt @@ -175,3 +175,37 @@ info SUFeedURLForTesting https://iterm2.com/appcasts/testing_modern.xml # --- Separate bundles since 3.5 --------------------------------------- bundle iTermAI.app bundle iTermBrowserPlugin.app + +# --- Claude Code integration (3.7) ------------------------------------- +# iTerm2 3.7 ships cc-status, a hook binary that turns Claude Code hook +# payloads into Session Status rows, and it2, the CLI it drives. Both are +# undocumented on iterm2.com; bin/cc-status-shim sits in front of cc-status +# and depends on exactly these facts: the payload keys cc-status reads, the +# it2 flag it stores the task count under, the detail text and colours it +# paints, and the app's own symlink upkeep. Swift keeps strings of 15 bytes +# or fewer inline, so short literals (--status, --detail, set-status) are +# not findable here; everything below is 16 bytes or longer, or a colour. +util cc-status +util it2 +utilstr cc-status background_tasks +utilstr cc-status notification_type +utilstr cc-status permission_prompt +utilstr cc-status last_assistant_message +utilstr cc-status --background-tasks +utilstr cc-status 1 background task running +utilstr cc-status #00d75f +utilstr cc-status #5f87ff +utilstr cc-status #888888 +utilstr cc-status #ff9500 +utilstr it2 --background-tasks +utilstr it2 ITERM_SESSION_ID +binstr ccStatusSymlinkIsHealthy +binstr ensureCCStatusSymlink +binstr utilities/cc-status +binstr com.iterm2.set-status +binstr Toolbelt.Session Status +binstr * `status=TEXT` +binstr * `indicator=COLOR` +binstr * `status-color=COLOR` +binstr * `detail=TEXT` +binstr Sets optional detail text shown below the status (wraps up to 3 lines). diff --git a/.claude/skills/iterm2/scripts/verify.sh b/.claude/skills/iterm2/scripts/verify.sh index 11c6c0ed102..63b05c876a8 100755 --- a/.claude/skills/iterm2/scripts/verify.sh +++ b/.claude/skills/iterm2/scripts/verify.sh @@ -143,6 +143,22 @@ while IFS= read -r line || [ -n "$line" ]; do record util fail "$rest" fi ;; + utilstr) + # A string inside one bundled utility — what cc-status reads from a + # hook payload, what it passes to it2. The utilities are small, so + # strings(1) runs per fact rather than once like the main executable. + name=${rest%% *}; want=${rest#* } + if ! command -v strings >/dev/null 2>&1; then + record utilstr skip + # Not grep -q: under pipefail its early exit hands strings(1) a + # SIGPIPE on any binary big enough to still be printing, and the + # pipeline fails on a fact that is present. + elif [ -r "$utils/$name" ] && strings -a "$utils/$name" 2>/dev/null | grep -xF -- "$want" >/dev/null; then + record utilstr ok + else + record utilstr fail "$name: $want" + fi + ;; info) key=${rest%% *}; want=${rest#* } got=$(plist_get "$key") @@ -185,7 +201,7 @@ else say "stamp: $stamp — INSTALLED IS $installed" fi -for class in sdef binstr util info tipcount; do +for class in sdef binstr util utilstr info tipcount; do n=${total[$class]:-0} [ "$n" -eq 0 ] && continue s=${skipped[$class]:-0} diff --git a/CLAUDE.md b/CLAUDE.md index 19f35e63157..349fcf71ddd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1507,6 +1507,57 @@ disagreement is half of what makes it worth keeping. Tests: `tests/iterm2-doctor.bats` (fake `iTerm.app` bundles and a stub `defaults`; nothing ever touches the real iTerm2 or opens a dialog). +**Session Status rows come from Claude Code hooks, through a shim.** iTerm2 +3.7 ships `cc-status`, a binary its onboarding symlinks at +`~/.config/iterm2/cc-status` and wires into every hook event of a profile's +`settings.json`; it turns each event into a row (name, status word, +optional detail of up to three lines, dot and text colours). On Stop it +reads the payload's `background_tasks` — the session's whole task registry, +which Claude Code never ages out — and keeps the row at "working" while +that array is non-empty. Measured: a backgrounded shell blocked on stdin +kept a tab "working" for 41 hours; a subagent that never reported back did +the same with no process behind it. A shim, deployed as +`~/.local/libexec/cc-status-shim/cc-status` +(`dot_local/libexec/cc-status-shim/`), takes cc-status's place in each +profile's `settings.json` hook command (here, the overlays write that +line; `cc-status settings` prints the block, the ten events iTerm2 wires +plus `SubagentStart`, with the home path expanded — 3.7.3 tests the literal +string, so `~` fails it) and, on Stop, StopFailure and SubagentStop, tells +**quiet** from **active** tasks. A task's output file, `tasks/.output` +beside the payload's `scratchpad_dir` (a symlink to the transcript for an +agent), is its last sign of life: written within `tabstatus.quiet-minutes` +(git config, default 15) is active, older is quiet, and no file to judge by +counts as active — wrongly active only delays the idle, wrongly idle lies. +cc-status is fed only the active tasks, so a Stop over a running background +agent still reads "working"; when nothing is active, one `it2 set-status` +paints the row idle in amber (`tabstatus.color.quiet`, default `#af8700` — +cc-status's blue is its "waiting" colour) with the quiet tasks in the +detail. The count stored back is **0** on purpose: cc-status's +`Notification(idle_prompt)` handler, a minute after Stop, reads the stored +count with `get-background-tasks` and repaints "working · N background tasks +running", so storing the quiet N would revive the lie. `cc-status explain` +(payload on stdin) prints each task's verdict without calling anything. +Every other event passes through untouched. Two details are load-bearing. +The shim is **named** `cc-status`, in a directory off `PATH`, because iTerm2 3.7.3's health check accepts a hook +only if its command is a bare executable path ending in `/cc-status` +(verified in the app's onboarding source at that tag): a wrapper line or +any other name makes the app report its integration broken, and a +Reinstall then appends a raw cc-status beside it, which repaints "working" +on Stop. And iTerm2's own symlink at `~/.config/iterm2/cc-status` is left +alone — `ensureCCStatusSymlink` removes anything else put there — and is +where the shim finds the real binary, with `it2` taken from beside it; +`PATH` only carries `it2` while an experimental iTerm2 setting is on. +`it2 set-status` keeps every field it is not given — a reset that sends +only the status word leaves the old colours in place — so anything +repainting a row sends the full field set (the shim's paint does). The +shim fails open everywhere (no binary, no it2, no python3, no session id, +bad JSON): the row is cosmetic and must never cost a turn. The facts it rests on — the payload +keys cc-status reads, the it2 flag it stores the count under, its detail +text and colours, the app's symlink upkeep — are `utilstr`/`binstr` lines +in the skill's `manifest.txt`, so `verify.sh` says when a point release +moves them. Tests: `tests/cc-status-shim.bats` (stub cc-status and it2, +side by side like the bundle ships them). + ### Machine-local secrets (~/.extra) `~/.extra` is rendered by chezmoi from `private_dot_extra.tmpl` (mode 0600) diff --git a/dot_claude-shared/CLAUDE.md b/dot_claude-shared/CLAUDE.md index 939325a6fa0..42135c8e589 100644 --- a/dot_claude-shared/CLAUDE.md +++ b/dot_claude-shared/CLAUDE.md @@ -181,6 +181,26 @@ employer-specific — profile-specific memory belongs in that profile's own ## Talking to other Claude Code sessions +- **A repo you cannot see is a session you can talk to.** When I name a + repo, codebase, or directory this session has no access to, I already + know that. I am asking you to find the Claude Code session running + there (`ListAgents`, then `postbox list_agents` / `postbox name `) + and put the question or task to it — not to tell me you lack access. + Say you lack access only when no session is reachable there either. +- **You are the proxy for seats that cannot message.** `plan-fable` and + the other subagents have no `SendMessage` or postbox tools. When I say + something must reach plan-fable, or plan-fable needs an answer from + another session, you carry it: put the inbound message in the brief + (or `SendMessage` to the running agent), and relay its questions and + answers to the other session yourself. Do not report that the agent + cannot reach them. +- When I say "postbox" I mean session-to-session messaging in general, + whichever transport applies. Pick the route by the rules below (native + `SendMessage` first, mail only where it cannot reach) — do not switch to + mail because I used the word, and do not correct the term. While it + works, keep the transport out of your replies: tell me what was sent + and what came back, not which tool carried it. Surface the mechanics + only when a message could not be delivered. - To reach ONE other session, look first with `ListAgents` and use `SendMessage` if it is listed — that is the native path and always the first choice. Sessions in this same profile are listed there. diff --git a/dot_local/libexec/cc-status-shim/executable_cc-status b/dot_local/libexec/cc-status-shim/executable_cc-status new file mode 100755 index 00000000000..f3e8277a977 --- /dev/null +++ b/dot_local/libexec/cc-status-shim/executable_cc-status @@ -0,0 +1,209 @@ +#!/bin/sh +# cc-status (shim) — stands between Claude Code's hooks and iTerm2's cc-status. +# +# iTerm2 3.7 paints a Session Status row per tab from Claude Code hook events +# through its bundled cc-status binary. On Stop it keeps the row at "working" +# whenever the payload's background_tasks array is non-empty — and that array +# is the session's whole task registry, which nothing ages out: a backgrounded +# shell blocked on stdin, or a subagent that never reported back, keeps a tab +# "working" for days. This shim rewrites that one case. Every other event is +# handed to cc-status untouched. +# +# On Stop, StopFailure and SubagentStop with background tasks, each task is +# classified by its output file, /tasks/.output (the +# sibling of the payload's scratchpad_dir; for an agent it is a symlink to the +# transcript): modified within tabstatus.quiet-minutes (default 15) it is +# active, older it is quiet, and a task with no file to judge by counts as +# active — wrongly active only delays the idle, wrongly idle lies. cc-status +# is fed only the active ones, so a Stop over a running background agent still +# paints "working". When nothing is active but something is quiet, one it2 +# call then paints the row idle with the quiet tasks in the detail, in amber +# (tabstatus.color.quiet, default #af8700; cc-status's own blue is its +# "waiting" colour). It stores 0 as the background-task count, never the +# quiet ones: cc-status re-reads the stored count on Notification(idle_prompt) +# a minute after Stop and repaints "working · N background tasks running". +# The paint sends the full field set, so a cc-status that failed cannot leave +# a half-painted row. Items also get the id and status keys that cc-status +# (written against an older Claude Code) reads and 2.1.289 no longer sends. +# +# cc-status hook mode: payload on stdin, exit 0 always +# cc-status explain payload on stdin; print the decision, call nothing +# cc-status settings print the hooks block for a profile's settings.json +# +# Wiring: each profile's settings.json runs cc-status on every hook event; +# point that command at this file instead (`cc-status settings` prints it). +# The file is deliberately NAMED cc-status, in a directory off PATH. iTerm2 +# 3.7.3's own health check accepts a hook only if the command is a bare +# executable path ending in /cc-status (a wrapper line, or any other name, +# makes the app report its integration broken and a Reinstall append a raw +# cc-status beside it). The symlink iTerm2 maintains at +# ~/.config/iterm2/cc-status is left alone — the app recreates it over +# anything else put there — and is where the real binary is found; +# CC_STATUS_BIN overrides that (tests). it2 is taken from beside the real +# binary, where iTerm2 ships it, before PATH is tried: PATH only has it while +# an experimental iTerm2 setting is on. +# +# Fails open: no cc-status, no python3, no it2, no session id, unparsable +# JSON — the payload passes through or the shim exits 0. The status is +# cosmetic and must never cost a turn. +set -u + +real=${CC_STATUS_BIN:-$HOME/.config/iterm2/cc-status} +mode=${1:-} + +if [ "$mode" = settings ]; then + cmd="$HOME/.local/libexec/cc-status-shim/cc-status" + printf 'cc-status settings: the shim does not edit settings.json — each profile'"'"'s\nfile is owned by its overlay. Put this in that file, replacing iTerm2'"'"'s own\ncc-status entries (never beside them):\n\n{\n "hooks": {\n' + sep= + for ev in SessionStart SessionEnd UserPromptSubmit PreToolUse PostToolUse PermissionRequest Notification Stop StopFailure SubagentStart SubagentStop; do + printf '%s "%s": [\n { "hooks": [{ "type": "command", "command": "%s" }] }\n ]' "$sep" "$ev" "$cmd" + sep=,' +' + done + printf '\n }\n}\n\n' + cat <<'TXT' +The command is a bare absolute path: iTerm2 3.7.3's health check tests the +literal string with isExecutableFile, so "~" and "$HOME" fail it and the app +reports the integration broken. iTerm2 > Reinstall rewrites the entry back to +the raw cc-status (re-paste afterwards); Uninstall strips it. SubagentStart is +not one of the ten events iTerm2 wires; cc-status ignores it. +TXT + exit 0 +fi + +target=$(readlink "$real" 2>/dev/null || printf '%s' "$real") +it2="$(dirname "$target")/it2" +[ -x "$it2" ] || it2=$(command -v it2 2>/dev/null || true) +sid=${ITERM_SESSION_ID:-}; sid=${sid#*:} # two steps: dash under set -u rejects ${unset#pat} +[ -x "$real" ] || real= +[ -x "$it2" ] || it2= + +# A leading dash is cc-status's own flag (--agent codex): not ours to read. +case $mode in -*) [ -n "$real" ] && exec "$real" "$@"; exit 0 ;; esac + +if [ "$mode" != explain ]; then + [ -n "$real" ] || exit 0 + case $target in "$0"|*/cc-status-shim/cc-status) exit 0 ;; esac # never recurse into ourselves +fi + +payload=$(cat) +pass() { printf '%s' "$payload" | "$real"; exit 0; } + +# Cheap gate before paying for python3: Claude Code emits compact JSON. +if [ "$mode" != explain ]; then + case $payload in + *'"hook_event_name":"Stop"'*|*'"hook_event_name":"StopFailure"'*|*'"hook_event_name":"SubagentStop"'*) + case $payload in *'"background_tasks":[{'*) ;; *) pass ;; esac ;; + *) pass ;; + esac + if [ -z "$sid" ] || [ -z "$it2" ] || ! command -v python3 >/dev/null 2>&1; then pass; fi +elif ! command -v python3 >/dev/null 2>&1; then + echo 'cc-status explain: needs python3' >&2; exit 0 +fi + +# The script goes in via -c so stdin stays free for the payload: `python3 -` +# with a heredoc would make the heredoc the stdin and the payload vanish. +py=$(cat <<'PY' +import json, os, re, subprocess, sys, time + +mode, real, it2, sid = sys.argv[1:5] +explain = mode == "explain" +raw = sys.stdin.read() + +def call(argv, data=None, timeout=5, silent=False): + # A hung or missing helper must never cost the turn. + kw = {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL} if silent else {} + kw["input" if data else "stdin"] = data or subprocess.DEVNULL + try: + subprocess.run(argv, timeout=timeout, **kw) + except (OSError, subprocess.SubprocessError): + pass + +cfg = {} +try: + out = subprocess.run(["git", "config", "--get-regexp", r"^tabstatus\."], + capture_output=True, text=True, timeout=5).stdout + for line in out.splitlines(): + k, _, v = line.partition(" ") + cfg[k] = v.strip() +except (OSError, subprocess.SubprocessError): + pass + +quiet_minutes, quiet_color = 15, "#af8700" +v = cfg.get("tabstatus.quiet-minutes") +if v is not None: + if re.fullmatch(r"[0-9]+", v): + quiet_minutes = int(v) + else: + print(f"cc-status: ignoring tabstatus.quiet-minutes={v!r}", file=sys.stderr) +v = cfg.get("tabstatus.color.quiet") +if v is not None: + if re.fullmatch(r"#[0-9a-fA-F]{6}", v): + quiet_color = v + else: + print(f"cc-status: ignoring tabstatus.color.quiet={v!r}", file=sys.stderr) + +def finish(action, event="?", rows=()): + if explain: + print(f"event={event}") + print(f"session={sid or 'none'}") + for r in rows: + print(r) + print(f"action={action}") + print(f"cc-status={real or 'absent'}") + print(f"it2={it2 or 'absent'}") + sys.exit(0) + +try: + payload = json.loads(raw) + event, tasks = payload.get("hook_event_name", "?"), payload.get("background_tasks") +except (ValueError, AttributeError): + event, tasks = "?", None +if event not in ("Stop", "StopFailure", "SubagentStop") or not isinstance(tasks, list) or not tasks: + if not explain: + call([real], raw.encode(), 10) + finish("passthrough", event) + +if event == "SubagentStop" and payload.get("agent_id"): + tasks = [t for t in tasks if not (isinstance(t, dict) and t.get("task_id") == payload["agent_id"])] + +now = time.time() +scratch = payload.get("scratchpad_dir") +active, quiet, rows = [], [], [] +for t in tasks: + tid = t.get("task_id") if isinstance(t, dict) else None + state, age = "unknown", None + if tid and isinstance(tid, str) and "/" not in tid and isinstance(scratch, str) and scratch: + p = os.path.join(os.path.dirname(scratch.rstrip("/")), "tasks", tid + ".output") + try: + age = now - os.stat(p).st_mtime + state = "active" if age < quiet_minutes * 60 else "quiet" + except OSError: + if os.path.islink(p): # an agent whose transcript is gone + state = "quiet" + (quiet if state == "quiet" else active).append(t) + kind, d = (t.get("task_type"), t.get("description")) if isinstance(t, dict) else (None, None) + rows.append(f"task {tid} {kind} {state} age={'-' if age is None else int(age)} {d or ''}") + +fed = [{**t, "id": t["task_id"], "status": "running"} if isinstance(t, dict) and "task_id" in t else t + for t in active] +paint_idle = bool(quiet) and not active +if explain: + finish("rewrite+idle" if paint_idle else "rewrite", event, rows) + +payload["background_tasks"] = fed +call([real], json.dumps(payload).encode(), 10) + +if paint_idle: + n = len(quiet) + what = ", ".join(str(t.get("description") or t.get("task_type") or "?") if isinstance(t, dict) else "?" + for t in quiet) + detail = " ".join(f"idle · {n} quiet background task{'s' if n != 1 else ''}: {what}".split()) + if len(detail) > 180: + detail = detail[:180] + "…" + call([it2, "set-status", "--session", sid, "--status", "idle", "--detail", detail, + "--dot-color", quiet_color, "--text-color", "#888888", "--background-tasks", "0"], silent=True) +PY +) +printf '%s' "$payload" | python3 -c "$py" "$mode" "$real" "$it2" "$sid" +exit 0 diff --git a/tests/cc-status-shim.bats b/tests/cc-status-shim.bats new file mode 100644 index 00000000000..cd95a948551 --- /dev/null +++ b/tests/cc-status-shim.bats @@ -0,0 +1,271 @@ +#!/usr/bin/env bats + +# The cc-status shim (dot_local/libexec/cc-status-shim/) sits between Claude +# Code's hooks and iTerm2's cc-status. Both cc-status and it2 are stubbed in +# one directory, the way iTerm2 ships them side by side: cc-status records +# the payload it was fed, it2 records its argv. No real iTerm2 is touched. + +bats_require_minimum_version 1.5.0 + +setup() { + STUB="$BATS_TEST_TMPDIR/utilities" + mkdir -p "$STUB" + printf '#!/bin/sh\ncat > "%s/fed"\n' "$BATS_TEST_TMPDIR" > "$STUB/cc-status" + printf '#!/bin/sh\nprintf "%%s\\n" "$@" > "%s/argv"\necho x >> "%s/calls"\n' "$BATS_TEST_TMPDIR" "$BATS_TEST_TMPDIR" > "$STUB/it2" + chmod +x "$STUB/cc-status" "$STUB/it2" + # The real wiring is a symlink to the bundle; it2 is found beside its target. + ln -s "$STUB/cc-status" "$BATS_TEST_TMPDIR/cc-status-link" + export CC_STATUS_BIN="$BATS_TEST_TMPDIR/cc-status-link" + export ITERM_SESSION_ID="w0t1p0:ABC-123" + SHIM="$BATS_TEST_DIRNAME/../dot_local/libexec/cc-status-shim/executable_cc-status" + # Config comes from git; keep the machine's own out of it. + export HOME="$BATS_TEST_TMPDIR/home" GIT_CONFIG_GLOBAL="$BATS_TEST_TMPDIR/gitconfig" GIT_CONFIG_NOSYSTEM=1 + mkdir -p "$HOME" + : > "$GIT_CONFIG_GLOBAL" + SESS="$BATS_TEST_TMPDIR/sess" + mkdir -p "$SESS/scratchpad" "$SESS/tasks" +} + +# age_file : a task output file last written that long ago. +age_file() { + : > "$SESS/tasks/$1.output" + python3 -c 'import os,sys,time; t=time.time()-int(sys.argv[2]); os.utime(sys.argv[1],(t,t))' "$SESS/tasks/$1.output" "$2" +} + +# payload [extra-json-members]: a compact payload, as Claude Code writes it. +payload() { printf '{"hook_event_name":"%s","scratchpad_dir":"%s/scratchpad","background_tasks":%s%s}' "$1" "$SESS" "$2" "${3:+,$3}"; } + +it2_argv() { paste -sd'|' "$BATS_TEST_TMPDIR/argv"; } +fed_tasks() { python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1]))["background_tasks"],sort_keys=True))' "$BATS_TEST_TMPDIR/fed"; } + +run_shim() { run sh "$SHIM"; } + +@test "shim: a non-Stop event passes through byte for byte and never calls it2" { + local p='{"hook_event_name":"PreToolUse","tool_name":"Bash","background_tasks":[{"task_id":"t1"}]}' + run_shim <<< "$p" + [ "$status" -eq 0 ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] + [ ! -e "$BATS_TEST_TMPDIR/argv" ] +} + +@test "shim: Stop with nothing in flight passes through unchanged" { + local p='{"hook_event_name":"Stop","stop_hook_active":false,"background_tasks":[]}' + run_shim <<< "$p" + [ "$status" -eq 0 ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] + [ ! -e "$BATS_TEST_TMPDIR/argv" ] +} + +@test "shim: a quiet shell paints idle in amber, stores 0, and feeds cc-status an empty array" { + age_file a 7200 + run_shim <<< "$(payload Stop '[{"task_id":"a","task_type":"local_bash","description":"tail -F build.log"}]')" + [ "$status" -eq 0 ] + [ "$(fed_tasks)" = "[]" ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|idle|--detail|idle · 1 quiet background task: tail -F build.log|--dot-color|#af8700|--text-color|#888888|--background-tasks|0" ] +} + +@test "shim: an active task alongside a quiet one is fed to cc-status with id and status, and it2 is left alone" { + age_file a 7200 + age_file b 30 + run_shim <<< "$(payload Stop '[{"task_id":"a","task_type":"local_bash","description":"old"},{"task_id":"b","task_type":"local_agent","description":"busy"}]')" + [ "$status" -eq 0 ] + [ "$(fed_tasks)" = '[{"description": "busy", "id": "b", "status": "running", "task_id": "b", "task_type": "local_agent"}]' ] + [ ! -e "$BATS_TEST_TMPDIR/calls" ] +} + +@test "shim: an agent's symlinked output follows the transcript: fresh active, old quiet, dangling quiet" { + : > "$BATS_TEST_TMPDIR/agent.jsonl" + ln -s "$BATS_TEST_TMPDIR/agent.jsonl" "$SESS/tasks/ag.output" + run_shim <<< "$(payload Stop '[{"task_id":"ag","task_type":"local_agent"}]')" + [ "$(fed_tasks | grep -c '"ag"')" -eq 1 ] + [ ! -e "$BATS_TEST_TMPDIR/calls" ] + + python3 -c 'import os,sys,time; t=time.time()-7200; os.utime(sys.argv[1],(t,t))' "$BATS_TEST_TMPDIR/agent.jsonl" + run_shim <<< "$(payload Stop '[{"task_id":"ag","task_type":"local_agent"}]')" + [ "$(fed_tasks)" = "[]" ] + [ "$(wc -l < "$BATS_TEST_TMPDIR/calls")" -eq 1 ] + + rm "$BATS_TEST_TMPDIR/agent.jsonl" + rm "$BATS_TEST_TMPDIR/calls" + run_shim <<< "$(payload Stop '[{"task_id":"ag","task_type":"local_agent"}]')" + [ "$(fed_tasks)" = "[]" ] + [ "$(wc -l < "$BATS_TEST_TMPDIR/calls")" -eq 1 ] +} + +@test "shim: a task with no output file is unknown and counts as active" { + run_shim <<< "$(payload Stop '[{"task_id":"ghost","task_type":"monitor"}]')" + [ "$status" -eq 0 ] + [ "$(fed_tasks | grep -c '"ghost"')" -eq 1 ] + [ ! -e "$BATS_TEST_TMPDIR/calls" ] +} + +@test "shim: a payload with no scratchpad_dir leaves every task active" { + run_shim <<< '{"hook_event_name":"Stop","background_tasks":[{"task_id":"a","task_type":"local_bash"}]}' + [ "$status" -eq 0 ] + [ "$(fed_tasks | grep -c '"a"')" -eq 1 ] + [ ! -e "$BATS_TEST_TMPDIR/calls" ] +} + +@test "shim: StopFailure is handled like Stop" { + age_file a 7200 + run_shim <<< "$(payload StopFailure '[{"task_id":"a","task_type":"local_bash"}]')" + [ "$status" -eq 0 ] + [ "$(fed_tasks)" = "[]" ] + grep -qx 'idle · 1 quiet background task: local_bash' "$BATS_TEST_TMPDIR/argv" +} + +@test "shim: SubagentStop drops the agent that just stopped" { + age_file ag1 5 + age_file b 5 + run_shim <<< "$(payload SubagentStop '[{"task_id":"ag1","task_type":"local_agent"},{"task_id":"b","task_type":"local_bash"}]' '"agent_id":"ag1"')" + [ "$status" -eq 0 ] + [ "$(fed_tasks | grep -c '"b"')" -eq 1 ] + [ "$(fed_tasks | grep -c '"ag1"')" -eq 0 ] +} + +@test "shim: several quiet tasks are plural, and a long detail is cut" { + age_file a 7200 + age_file b 7200 + run_shim <<< "$(payload Stop '[{"task_id":"a","description":"first"},{"task_id":"b","task_type":"local_agent"}]')" + grep -qx 'idle · 2 quiet background tasks: first, local_agent' "$BATS_TEST_TMPDIR/argv" + local long; long=$(printf 'x%.0s' $(seq 1 300)) + run_shim <<< "$(payload Stop "[{\"task_id\":\"a\",\"description\":\"$long\"}]")" + # Code points, counted by python: wc -m depends on the locale (a C locale, + # as in the Rocky CI container, counts the ellipsis as three) and the + # detail's "·" makes a byte count wrong everywhere. + grep -A1 '^--detail$' "$BATS_TEST_TMPDIR/argv" | tail -1 \ + | python3 -c 'import sys; s=sys.stdin.buffer.read().decode().rstrip("\n"); assert len(s)==181 and s.endswith("…"), len(s)' +} + +@test "shim: tabstatus config overrides the window and the colour; an invalid colour falls back with a note" { + age_file a 600 + git config -f "$GIT_CONFIG_GLOBAL" tabstatus.quiet-minutes 5 + git config -f "$GIT_CONFIG_GLOBAL" tabstatus.color.quiet '#112233' + run_shim <<< "$(payload Stop '[{"task_id":"a","task_type":"local_bash"}]')" + grep -qx '#112233' "$BATS_TEST_TMPDIR/argv" + + git config -f "$GIT_CONFIG_GLOBAL" tabstatus.color.quiet 'blue' + run --separate-stderr sh "$SHIM" <<< "$(payload Stop '[{"task_id":"a","task_type":"local_bash"}]')" + grep -qx '#af8700' "$BATS_TEST_TMPDIR/argv" + [[ $stderr == *"tabstatus.color.quiet"* ]] +} + +@test "shim: a hung it2 costs about its timeout, and the shim still exits 0" { + age_file a 7200 + printf '#!/bin/sh\nexec sleep 10\n' > "$STUB/it2" + local t0=$SECONDS + run_shim <<< "$(payload Stop '[{"task_id":"a","task_type":"local_bash"}]')" + [ "$status" -eq 0 ] + [ $((SECONDS - t0)) -lt 9 ] +} + +@test "shim: a flag argument goes to cc-status untouched, with the payload" { + printf '#!/bin/sh\nprintf "%%s\\n" "$@" > "%s/ccargv"; cat > "%s/fed"\n' "$BATS_TEST_TMPDIR" "$BATS_TEST_TMPDIR" > "$STUB/cc-status" + run sh "$SHIM" --agent codex <<< '{"hook_event_name":"Stop"}' + [ "$status" -eq 0 ] + [ "$(paste -sd' ' "$BATS_TEST_TMPDIR/ccargv")" = "--agent codex" ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = '{"hook_event_name":"Stop"}' ] +} + +@test "explain: prints the decision and calls neither cc-status nor it2" { + age_file a 7200 + age_file b 5 + run sh "$SHIM" explain <<< "$(payload Stop '[{"task_id":"a","task_type":"local_bash","description":"old"},{"task_id":"b","task_type":"local_agent","description":"busy"},{"task_id":"c","task_type":"monitor","description":"ghost"}]')" + [ "$status" -eq 0 ] + [[ $output == *"event=Stop"* ]] + [[ $output == *"session=ABC-123"* ]] + [[ $output == *"task a local_bash quiet age=7200"*"old"* ]] + [[ $output == *"task b local_agent active age=5"*"busy"* ]] + [[ $output == *"task c monitor unknown age=-"*"ghost"* ]] + [[ $output == *"action=rewrite"* && $output != *"idle"* ]] + [[ $output == *"cc-status=$CC_STATUS_BIN"* ]] + [[ $output == *"it2=$STUB/it2"* ]] + [ ! -e "$BATS_TEST_TMPDIR/fed" ] + [ ! -e "$BATS_TEST_TMPDIR/calls" ] +} + +@test "explain: all quiet says rewrite+idle; a non-Stop event says passthrough" { + age_file a 7200 + run sh "$SHIM" explain <<< "$(payload Stop '[{"task_id":"a","task_type":"local_bash"}]')" + [[ $output == *"action=rewrite+idle"* ]] + run sh "$SHIM" explain <<< '{"hook_event_name":"PreToolUse"}' + [[ $output == *"event=PreToolUse"* && $output == *"action=passthrough"* ]] +} + +@test "settings: valid JSON naming all eleven events, each with the bare expanded shim path" { + run sh "$SHIM" settings + [ "$status" -eq 0 ] + printf '%s\n' "$output" | sed -n '/^{/,/^}/p' > "$BATS_TEST_TMPDIR/settings.json" + python3 - "$BATS_TEST_TMPDIR/settings.json" "$HOME" <<'PY' +import json, sys +h = json.load(open(sys.argv[1]))["hooks"] +assert len(h) == 11 and "SubagentStart" in h and "SubagentStop" in h, sorted(h) +for ev, entries in h.items(): + for e in entries: + for hook in e["hooks"]: + assert hook["command"] == sys.argv[2] + "/.local/libexec/cc-status-shim/cc-status", (ev, hook) +PY +} + +@test "shim: it2 is taken from beside the real binary, not from PATH" { + # A different it2 on PATH must lose to the one shipped beside cc-status. + mkdir -p "$BATS_TEST_TMPDIR/path" + printf '#!/bin/sh\necho wrong > "%s/argv"\n' "$BATS_TEST_TMPDIR" > "$BATS_TEST_TMPDIR/path/it2" + chmod +x "$BATS_TEST_TMPDIR/path/it2" + age_file a 7200 + PATH="$BATS_TEST_TMPDIR/path:$PATH" run sh "$SHIM" <<< "$(payload Stop '[{"task_id":"a"}]')" + [ "$status" -eq 0 ] + grep -q '^set-status$' "$BATS_TEST_TMPDIR/argv" +} + +@test "shim: without any it2 the payload passes through and nothing else is tried" { + rm "$STUB/it2" + age_file a 7200 + local p; p=$(payload Stop '[{"task_id":"a"}]') + PATH="/usr/bin:/bin" run sh "$SHIM" <<< "$p" + [ "$status" -eq 0 ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] +} + +@test "shim: the event name only inside message text does not count as Stop" { + local p='{"hook_event_name":"PostToolUse","tool_response":"the \"hook_event_name\":\"Stop\" payload","background_tasks":[{"task_id":"a"}]}' + run_shim <<< "$p" + [ "$status" -eq 0 ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] + [ ! -e "$BATS_TEST_TMPDIR/argv" ] +} + +@test "shim: without an iTerm2 session id it passes through" { + unset ITERM_SESSION_ID + age_file a 7200 + local p; p=$(payload Stop '[{"task_id":"a"}]') + run_shim <<< "$p" + [ "$status" -eq 0 ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] + [ ! -e "$BATS_TEST_TMPDIR/argv" ] +} + +@test "shim: unparsable JSON passes through" { + run_shim <<< '{"hook_event_name":"Stop","background_tasks":[oops' + [ "$status" -eq 0 ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = '{"hook_event_name":"Stop","background_tasks":[oops' ] + [ ! -e "$BATS_TEST_TMPDIR/argv" ] +} + +@test "shim: no cc-status at all exits 0 and touches nothing" { + export CC_STATUS_BIN="$BATS_TEST_TMPDIR/absent" + run_shim <<< '{"hook_event_name":"Stop","background_tasks":[{"task_id":"a"}]}' + [ "$status" -eq 0 ] + [ ! -e "$BATS_TEST_TMPDIR/fed" ] + [ ! -e "$BATS_TEST_TMPDIR/argv" ] +} + +@test "shim: pointed at itself it exits 0 instead of recursing" { + mkdir -p "$BATS_TEST_TMPDIR/cc-status-shim" + cp "$SHIM" "$BATS_TEST_TMPDIR/cc-status-shim/cc-status" + chmod +x "$BATS_TEST_TMPDIR/cc-status-shim/cc-status" + export CC_STATUS_BIN="$BATS_TEST_TMPDIR/cc-status-shim/cc-status" + run "$BATS_TEST_TMPDIR/cc-status-shim/cc-status" <<< '{"hook_event_name":"Stop","background_tasks":[]}' + [ "$status" -eq 0 ] + [ ! -e "$BATS_TEST_TMPDIR/fed" ] +} diff --git a/tests/iterm2-doctor.bats b/tests/iterm2-doctor.bats index bac103f8001..6da060e31ef 100644 --- a/tests/iterm2-doctor.bats +++ b/tests/iterm2-doctor.bats @@ -220,6 +220,10 @@ make_verifiable_app() { sed -n 's/^sdef //p' "$manifest" > "$APP/Contents/Resources/iTerm2.sdef" while read -r u; do : > "$APP/Contents/Resources/utilities/$u"; done \ < <(sed -n 's/^util //p' "$manifest") + # utilstr facts are strings inside a named utility; a text file stands in + # for the binary, one recorded string per line. + while read -r u s; do printf '%s\n' "$s" >> "$APP/Contents/Resources/utilities/$u"; done \ + < <(sed -n 's/^utilstr //p' "$manifest") # it2tip carries the app's own feature tour; the manifest records how many # entries it has, so the fake needs exactly that many. local n @@ -241,7 +245,7 @@ make_verifiable_app() { [[ "$output" == *"— matches"* ]] [[ "$output" == *"OK - every recorded fact still holds"* ]] # Each class of fact was actually exercised, not silently skipped. - for class in sdef binstr util info; do + for class in sdef binstr util utilstr info; do [[ "$output" == *"$class:"*"/"*" verified"* ]] done } @@ -256,6 +260,9 @@ binstr KeptKey binstr RemovedKey util it2kept util it2removed +util it2strs +utilstr it2strs KeptUtilString +utilstr it2strs RemovedUtilString tipcount 4 info SUFeedURLForFinal https://example.invalid/kept EOF @@ -266,7 +273,7 @@ EOF head -2 "$APP/Contents/Resources/utilities/it2tip" > "$APP/it2tip.short" mv "$APP/it2tip.short" "$APP/Contents/Resources/utilities/it2tip" sed -i.bak '/removed/d;/Removed/d' "$APP/Contents/MacOS/iTerm2" \ - "$APP/Contents/Resources/iTerm2.sdef" + "$APP/Contents/Resources/iTerm2.sdef" "$APP/Contents/Resources/utilities/it2strs" write_info_plist "$APP" 9.9.9 "SUFeedURLForFinal https://example.invalid/moved" run bash "$SKILL/scripts/verify.sh" --manifest "$M" @@ -279,6 +286,8 @@ EOF # there reports a missing package as a broken verifier. if command -v strings > /dev/null 2>&1; then [[ "$output" == *"binstr: RemovedKey"* ]] + [[ "$output" == *"utilstr: it2strs: RemovedUtilString"* ]] + [[ "$output" != *"KeptUtilString"* ]] fi [[ "$output" == *"util: it2removed"* ]] [[ "$output" == *"tipcount: it2tip lists 2 features, recorded as 4"* ]]