The session filled its context window. Claude Code printed its own recovery instruction, then ran automatic compaction, and lifecycle/pre-compact-guard vetoed it.
Context limit reached · /compact or /clear to continue
Compaction blocked, 1 background agent(s) still running (ckleg2).
Wait for completion or set CLAUDE_CODE_ALLOW_COMPACT_DURING_AGENTS=1.
/clear as the only exit, which discards the session state the guard exists to protect. The hook is most destructive exactly where it matters least. It is also newly more reachable: #3427 armed autoCompactWindow one day earlier, so automatic compaction now runs at a chosen fill level rather than only at the model limit.
| # | Defect | Evidence |
|---|---|---|
| 1 | Cannot tell auto from manual. The handler signature was (_input, ctx), so the payload was never read | trigger ('auto' | 'manual') has been typed and binary-verified in types.ts:187 since 2.1.227. The field was there all along. |
| 2 | The remedy is unreachable from where it prints. Now fixed by repeat-to-confirm, below | The env var is read from the hook process env, inherited fixed at CC launch. Acting on that sentence needs a restart, which loses the same context. |
| 3 | "Active" was recency, not liveness, and undeduped | The spawn log has no completion records at all, so a finished agent blocks for the full 5 minutes. One agent is logged twice by design. |
.claude/logs/subagent-spawns.jsonl is a spawn-intent log. Two writers, no closer. The SubagentStop hooks write to entirely different files, so nothing ever marks an agent finished:
$ node -e '...tally sources...' → { start: 694, pretool: 145 } zero terminal entries $ wc -l .claude/logs/subagent-spawns.jsonl → 839 the two writers subagent-validator source: 'start' pretool/task/spawn-intent-logger source: 'pretool'
So now - spawn.timestamp < 5min answers "was one started recently", never "is one running". The fix prefers input.background_tasks, CC's authoritative live list since 2.1.145, which subagent-stop/watchdog already consumes for this same class of phantom-agent bug (#1882).
background_tasks is trusted as "nothing running." Treating empty as "no signal" and falling back to the timestamp window would reinstate the defect in the exact case that matters, so presence of the array, not its length, selects the source.
The obvious repair for defect 3 is to write completion records into the spawn log. That trades one bug for another:
watchdog.getActiveSpawns() reads the last 20 lines and treats EVERY entry as a live spawn append {source:'stop'} ──▶ watchdog reports completed agents as hung
Hence background_tasks from the payload, with the timestamp window surviving only as the pre-2.1.145 fallback.
Auto passing through does not help the case that started this: the operator typed /compact by hand, at the ceiling, and got stopped. Defect 2 said the printed remedy was unreachable. So the manual path now confirms by repetition.
/compact ──▶ BLOCKED 2 agents running (test-generator, cover). Run /compact again within 60s to compact anyway. /compact (again) ──▶ ALLOWED marker consumed, proceeding with 2 agents live
The marker is one session-keyed file, .claude/state/precompact-confirm-<sessionId>.json, following the existing nudge-outcome-state.ts convention. Writes are best-effort by design: a failed write costs one extra /compact, it must never change the verdict or throw out of the hook. Auto deliberately does not consume the marker, since it never blocks and so never needs a confirm; a test pins that, or an auto pass could silently spend a confirm a manual attempt was holding.
× never blocks automatic compaction, even with active agents
× allows when background_tasks is empty despite a recent spawn entry
× blocks from background_tasks when CC reports a live agent
× counts one agent once when the spawn log holds duplicate entries
× blocks the first manual compact and leaves a confirm marker
× allows a repeat compact inside the confirm window
6 of 8 new tests fail against the code they replace
The other two confirm tests (re-block after the window expires, auto does not consume the marker) pass vacuously against the prior code, which blocked unconditionally and never unlinked. They are recorded as regression guards, not controls. Counting them as controls would be the exact green-test-beside-an-inert-control failure this repo keeps finding in its own work.
The pre-existing five passed before and still pass, and they are why this survived: every one builds an input with no trigger and no background_tasks, so all five only ever exercised the fallback path. Behaviour under a real CC payload was never asserted.
/compact with a live agent still blocks once, and now has a confirm that works from inside the sessionbackground_tasks is delivered on the PreCompact payload is confirmed for Stop and SubagentStop, and assumed here. The code is written so it does not matter: if CC omits the field, Array.isArray is false and the hook uses the deduped timestamp fallback, which is strictly better than today. The auto-compaction fix, which is the one that unblocks a stuck session, does not depend on it at all.