Repository navigation
[AI-2193] CLI: hand Claude's SessionEnd hook off to a detached continuation - #649
Conversation
Claude Code computes the grace it gives SessionEnd hooks from settings.json
hook timeouts only; a plugin's hooks.json timeout is used for matching but
never for that computation, so kcap's SessionEnd hook gets the 1.5 s floor
and is killed ("Hook cancelled") — after it has already killed the watcher
whose parent-exit watchdog would otherwise have posted session-end. Sessions
then sat active until the stale sweep abandoned them.
`kcap hook --claude` now reads its payload, re-invokes itself with
`--detached`, pipes the payload to that child and exits — before the
server-URL git probes and the global spool drain that Program.cs runs ahead
of every hook, either of which alone can spend the grace. The continuation
runs the unchanged session-end path (spool fallback and ended_at idempotency
included) under the 15 s HookBudget that used to be the hook's, with its
output in the session log and its own session so neither Claude's abort nor
a closing terminal reaches it.
Measured with the AOT binary on a 4 MB transcript: 37 ms warm. The
integration test stalls /hooks/session-end for 3 s and asserts the hook
returns in under 1 s while the POST still arrives.
AI-2193
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR Summary by QodoFix Claude SessionEnd hook cancellation via detached continuation hand-off
AI Description
Diagram
High-Level Assessment
Files changed (8)
|
Code Review by Qodo
1.
|
… null writer fallback - TrySpawn kills (best-effort) and disposes a child that started but never received the full payload, so the inline fallback is the only owner. - The continuation's log name is the watcher key only for an alphanumeric session id; anything else uses a fixed name inside the logs directory. - A failed log open falls back to TextWriter.Null rather than leaving the console on the closed pipes. - Shorter comments; the integration test reads the log through SharedFileText. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes AI-2193. (No GitHub issue — the bug was filed in Linear directly.)
What & why
At the end of every interactive Claude Code session:
and the session never receives
/hooks/session-end— it sitsactiveuntil the stale sweep abandons it ~30 min later.Claude Code (2.1.241, verified against the installed binary) runs SessionEnd hooks on shutdown,
/clearand resume underAbortSignal.timeout(getSessionEndHookTimeoutMs()). That function reads hook timeouts fromsettings.jsonand agent hooks only; pluginhooks.jsonentries are merged for matching but not for the grace computation, so kcap's"timeout": 15is never seen and the grace is the 1.5 s floor. The session-end path was budgeted for 15 s: resolve server URL (git) → drain spools → kill the watcher → inline-drain the transcript tail → enrich → POST. On any real session that exceeds 1.5 s, and the ordering made it worse than a lost message: the hook killed the watcher — whose parent-exit watchdog would otherwise have posted session-end — and was then killed itself before its own POST.One thing beyond the ticket: before
ClaudeHookCommandeven runs,Program.csdoesResolveServerUrl(twogitcalls, ≤1 s each) andAgentHookPoster.DrainSpoolsAsync(a 1.5 s network budget). Either alone can spend the grace, so the hand-off lives inProgram.csahead of both.Changes
Harness/Claude/ClaudeSessionEndHandoff.cs(new) — for aSessionEndpayload, the hook re-invokes itself askcap hook --claude --no-update-check --detached, pipes the payload to the child's stdin (no inherited handles, same cwd, noKCAP_URLoverlay — the continuation resolves the URL itself) and exits 0. The continuation redirects its output to the session log (logs/{sid}.log, where the watcher's already goes),setsid()s so a closing terminal can't SIGHUP it, and then runs the unchanged session-end path under the existing 15 sHookBudget— spool fallback andended_atidempotency carry over untouched. A failed spawn falls back to the inline path.Program.cs— readshook --claudestdin once, before URL resolution and the global spool drain; detached → enter; SessionEnd → hand off; everything else replays the body to the dispatcher.WatcherManager.StartProcessmadeinternalso the hand-off shares the existingProcessStarterForTestingseam; stale budget comments onPreHookDrainCap/HookBudgetcorrected;docs/CHANGES.mdentry.Only SessionEnd is handed off: SubagentStop is already
asyncinhooks.json, and the other events honour their timeouts. Not in scope (per the ticket): writing the hook intosettings.json, and the upstream Claude Code bug report.Tests
ClaudeSessionEndHandoffTests(unit, 13) — hand-off decision per event name / malformed payload /--detached; spawn shape (this binary, hook args +--detached, all std streams redirected, noKCAP_URLcontributed); payload reaches the child's stdin (via a/bin/shstand-in); spawn failure is reported, not thrown.ClaudeSessionEndHandoffTests(integration) — real binary against WireMock that stalls/hooks/session-endfor 3 s: the hook must exit 0 in < 1 s with empty stdout, the POST must still arrive withsession_id/reason/ended_at, and the continuation's output lands in the session log. Fails without the fix (hook took ~4 s).Measured
AOT binary, 4 MB transcript, unreachable server: 37 ms warm (756 ms on the first cold exec of a freshly published binary). The continuation ran on its own — pre-drain cap elapsed, session-end spooled for replay — and no process was left behind.
AOT publish clean (no IL warnings). Full CLI unit + integration suites: the only failures (7 + 1, all work-items-nudge session-start output tests) fail identically on untouched
mainon this machine.🤖 Generated with Claude Code