Skip to content

Durable session lifecycle hooks via a unified hook spool - #187

Merged
alexeyzimarev merged 22 commits into
mainfrom
durable-hook-spool
Jun 26, 2026
Merged

alexeyzimarev merged 22 commits into
mainfrom
durable-hook-spool

Conversation

@alexeyzimarev

Copy link
Copy Markdown
Member

Problem

A session that ended cleanly with /exit could stay Active in the Capacitor UI with no SessionEnded recorded. Root cause (diagnosed on session 9dc27753…): a brief server outage during a deploy window coincided with the SessionEnd hook. Claude kills the hook at its ~15s timeout, so the in-hook POST (and even auth/client creation) could be cut off before the server returned — and any retry living inside the hook process can't outlast the outage. The lifecycle event was simply lost. The same fragility applied to session-start (a failed start orphaned the whole session — no record and no transcript capture, since the watcher was only spawned after a successful POST).

Approach

A vendor-neutral, on-disk hook spool. When a lifecycle POST fails transiently (unreachable / 5xx / 408 / 429 / timeout), the hook spools the payload locally and exits cleanly. On every subsequent Claude hook, a cross-session drainer replays pending entries (current-session-first). Server-side idempotency makes replay safe — no server changes.

The key to making it actually work: a process-start hook deadline. Everything before the spool can reach it — bootstrap git, repo enrichment, auth/client creation, and the POST — is bounded by that deadline, and a minimal body is built (and the watcher spawned) before any of it, so the spool is always reached before Claude's kill. The POST is a single bounded attempt (PostOnceAsync); durable replay, not in-hook retry, provides reliability.

What's in here

  • HookSpool — rotate-on-drain (atomic rename → private .draining temp; appends never collide with an in-flight drain), cross-session DrainAllAsync(currentSessionId, …) (current session first; recovered temps before the live file), tri-state DrainOutcome (Delivered / Drop-on-permanent-4xx / TransientStop), 1 MB cap, 30-day reap.
  • Cursor migrated onto the unified spool (keeps all four SpoolOnFailure events; transforming, merging, concurrency-safe migration from the legacy dir).
  • Process-start deadline (HookBudget) anchored in Program.cs before bootstrap; hook-path git/enrichment bounded by it.
  • ClaudeHookCommand — HandleCore test seam; bounded client creation that spools the lifecycle event if auth/creation hangs; session-end (bounded POST + ended_at + spool, generate_whats_done on success); session-start (watcher-first, single bounded POST, spool on failure); cross-session drain step (current-session-first, generate_whats_done on replay).

Recovery for already-stuck sessions

kcap import does not end an already-loaded session (its resume branch only backfills transcript). Replay the SessionEnd hook instead (kcap hook --claude with a session_end payload) — which is exactly what this feature now automates.

Testing

New unit coverage for HookSpool, HookBudget, ClaudeHookCommand (5xx → spooled, hung server → spooled within budget, hung auth → spooled within budget, 4xx → not spooled, current-session ordering, cross-session drain), Cursor migration/regression; an integration outage → recovery round-trip plus the generate_whats_done:true replay path. Full unit suite green except a pre-existing LocalPermissionBridgeTests parallel-load port-conflict flake (passes 19/19 in isolation, unrelated). Integration 39/39. AOT-clean (no IL3050/IL2026).

Design + plan: docs/superpowers/specs/2026-06-26-durable-hook-spool-design.md, docs/superpowers/plans/2026-06-26-durable-hook-spool.md.

Follow-ups (out of scope)

  • Server-side quiet-session sweeper as a backstop for the case where the machine never runs kcap again.
  • Deeper TokenStore auth-timeout cleanup (bounding /auth/refresh itself; here it's covered by the hook's outer cap).
  • The separate watcher defects from the diagnosis (parent-exit watchdog never starting on a dead-at-startup parent PID; abrupt watcher death).

🤖 Generated with Claude Code

alexeyzimarev and others added 19 commits June 26, 2026 10:12
Spec for spooling failed session-start/session-end POSTs to disk and
draining them cross-session on subsequent hook invocations, so a brief
server outage (e.g. a deploy window) during a lifecycle hook no longer
strands sessions as Active. CLI-side only; unifies the existing Cursor
spool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Root cause: bounded lifecycle POST (single PostOnceAsync clamped to a shared
  per-invocation hook deadline) so a hung server is spooled, not killed mid-POST
- Drain before the current event (preserves lifecycle order; matches Cursor test)
- Replayed session-end side effects (generate_whats_done) handled in the
  vendor-owned poster closure
- Cursor keeps all four SpoolOnFailure events (no narrowing)
- Migration transforms old {hook_event_name} lines to {route} so the backlog
  is not discarded
- Add tests: slow/hung server, drain-before ordering, generate_whats_done
  replay, Cursor regression + migration transform

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Deadline discipline now covers auth/client creation (15s token-lock wait +
  untimed /auth/refresh) via a hard outer cap; spool prepared before the client
  so delivery failure never loses data
- DrainAllAsync drains the current session's file first and spools the fresh
  event if that backlog can't fully drain (guarantees same-session order)
- Atomic rotate-on-drain replaces snapshot-then-rewrite (fixes concurrent
  append-during-drain loss); orphaned temps recovered next run
- Tri-state DrainOutcome (Delivered/Drop/TransientStop) so a permanent 4xx
  can't poison the head of the queue
- Concurrency-safe Cursor migration; clarify "remaining" = to safety-adjusted
  deadline; add tests for each

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kcap import does NOT end an already-loaded session — its resume branch
(ImportCommand.cs:2124) backfills transcript only; session-end is posted just
on the New-session branch. Verified empirically recovering 9dc27753 (import
"Resumed 1" left it Active; replaying the SessionEnd hook ended it). Manual
hatch is the hook replay; the drainer auto-recovers by re-POSTing session-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Deadline starts at Program.cs Main (before ResolveServerUrl's git shell-outs),
  not in Handle; hook-path server-URL resolution bounded by it
- Build a minimal normalized body + spawn the watcher BEFORE git/gh/auth;
  repo enrichment only with remaining headroom (repo also arrives via watcher)
- Rotate-on-drain: recovered .draining temps drain before the live file,
  oldest-first (preserves same-session FIFO across the rotate boundary)
- Cursor migration merges via HookSpool.Append (no skip-or-clobber/loss)
- Auth covered by a LOCAL hard cap; TokenStore unchanged (deeper cleanup is a
  follow-up)
- Tests for budget-pressure-at-entry minimal-body/enrichment-skip

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8 TDD tasks: HookSpool component, Cursor migration, process-start deadline +
bootstrap bounding, ClaudeHookCommand test seam, session-end/session-start
durability, cross-session drain, integration round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ri-state)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…richment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract HandleCore(HttpClient, HookSpool, long, string, TextReader, Task?)
as an internal test seam (injected client, no CreateAuthenticatedClientAsync),
add HandleWithDeps for production client construction, keep Handle() as the
public entry with spool init and delegation. Add WithHardCap identical in
semantics to CursorHookCommand.WithHardCap. No behavior change for real
invocations; 3 new unit tests confirm session-start route + hard-cap semantics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Restructures session-start handling in ClaudeHookCommand: watcher is
spawned before any slow enrichment/POST so transcript capture is never
lost. Bounded POST via PostLifecycleBoundedAsync; transient failures are
spooled (session-start body with session_id) and return 0. On success a
second PostOnceAsync reads the response for envelope/plan-content
emission (preserving existing stdout behavior). EnrichWithRepositoryInfo
for session-start now receives HookBudget.Remaining budget so it
self-skips under deadline pressure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…, whats-done on replay)

- HookSpool: expose internal Dir property so callers/tests can locate the spool dir
- ClaudeHookCommand: add ClaudePoster closure (maps HTTP outcomes to DrainOutcome, handles generate_whats_done on replayed session-end)
- ClaudeHookCommand: drain stranded lifecycle events before each fresh hook POST (current session first; capped at min(2s, remaining budget); fail-open)
- ClaudeHookCommand: CurrentSessionHasBacklog ordering guard in session-start and session-end blocks prevents a fresh event from overtaking a stranded predecessor
- Tests: three new TDD tests (backlog drained on next hook, ordering guarantee, whats-done on replay); fixture RespondJson for stub response body control

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds SpoolOutageRecoveryTests with two end-to-end scenarios:

1. Outage_then_recovery_drains_spooled_session_end — session-end POST
   against a 503 server is spooled; a subsequent Stop hook against a 200
   server drains the backlog (server receives /hooks/session-end, spool
   file deleted).

2. Replayed_session_end_with_generate_whats_done_true_drains_without_error
   — pre-seeded spool entry, server responds {"generate_whats_done":true};
   asserts POST received and spool emptied (the SpawnWhatsDoneGenerator
   side effect is fire-and-forget and not asserted).

Uses HandleCore + a real HttpClient pointing at WireMock and a HookSpool
pinned to a temp dir — hermetic, no auth, no live server.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tcher-before-enrichment

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Durable lifecycle hook delivery via on-disk HookSpool + hook-time budgets
🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

Description

• Persist failed session-start/session-end hook payloads to disk and replay cross-session on later
 hooks.
• Enforce per-hook time budgets from process start to guarantee spooling before Claude kills the
 hook.
• Migrate Cursor hook spooling onto the unified spool; add unit + integration outage-recovery
 coverage.
Diagram

graph TD
  A["kcap hook entry"] --> B["HookBudget (remaining)"] --> C[["ClaudeHookCommand"]]
  D[["CursorHookCommand"]] --> E[("HookSpool (jsonl)")] --> F{{"Capacitor server"}}
  C --> E --> F
  C --> G[["WatcherManager"]]

  subgraph Legend
    direction LR
    _p["Process"] ~~~ _c[["Component"]] ~~~ _db[("Disk spool")] ~~~ _ext{{"External"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Server-side lifecycle sweeper/backstop
  • ➕ Works even if the client never runs hooks again (e.g., machine offline)
  • ➕ Centralized observability and retries
  • ➖ Requires server changes + deployment coordination
  • ➖ Harder to infer true client-side session termination without reliable signals
2. In-hook aggressive retry / longer hook timeout
  • ➕ Smaller code footprint than durable spool
  • ➕ No disk state to manage
  • ➖ Cannot outlast hook kill window; still loses events during longer outages
  • ➖ Longer hook runtime risks degrading agent UX and increases kill risk elsewhere
3. Background local drainer daemon/service
  • ➕ Can retry independently of hook invocations
  • ➕ More flexible backoff and delivery guarantees
  • ➖ Adds persistent process management complexity and platform-specific concerns
  • ➖ Harder to keep fail-open semantics and avoid resource conflicts

Recommendation: The PR’s unified on-disk spool + cross-session drain is the best tradeoff: it needs no server changes, survives hook-process termination, and uses explicit per-invocation budgets to preserve the fail-open hook contract. A server-side sweeper is still a valid follow-up for the “client never runs again” gap, but shouldn’t block this reliability fix.

Files changed (18) +2778 / -154

Enhancement (2) +181 / -0
HookBudget.csAdd per-hook ceiling + remaining-time computation with safety margin +22/-0

Add per-hook ceiling + remaining-time computation with safety margin

• Defines hook timeout ceilings (e.g., 15s for session-end, 5s default) and computes Remaining() from a process-start timestamp minus a fixed safety margin. Used to bound auth/client creation, repo enrichment, draining, and lifecycle POST attempts so the hook can always reach spooling + exit.

src/Capacitor.Cli/Commands/HookBudget.cs

HookSpool.csIntroduce vendor-neutral rotate-on-drain HookSpool with tri-state outcomes +159/-0

Introduce vendor-neutral rotate-on-drain HookSpool with tri-state outcomes

• Adds an on-disk per-session JSONL spool storing {route, body} entries, with a 1MB cap and 30-day reap. Implements rotate-on-drain via atomic rename to private .draining temps, drains current session first, and uses DrainOutcome (Delivered/Drop/TransientStop) to advance, discard permanent 4xx, or stop on transient failures/budget exhaustion.

src/Capacitor.Cli/Commands/HookSpool.cs

Bug fix (1) +322 / -107
ClaudeHookCommand.csDurable session-start/session-end delivery with budgeted drain + spooling +322/-107

Durable session-start/session-end delivery with budgeted drain + spooling

• Introduces HookSpool + HookBudget integration: drain pending entries before handling the current event (current-session-first), and spool lifecycle events on transient failures. Reworks session-start to spawn the watcher before any slow work and uses bounded PostOnceAsync attempts; session-end stamps ended_at, uses bounded POST, spools on transient failure, and preserves generate_whats_done behavior for replays. Adds test seams (HandleCore/HandleWithDeps) and hard caps for hung auth/client creation so spooling is still reached.

src/Capacitor.Cli/Commands/ClaudeHookCommand.cs

Refactor (6) +59 / -31
CopilotImportSource.csAdjust repo detector default to match new DetectRepositoryAsync signature +1/-1

Adjust repo detector default to match new DetectRepositoryAsync signature

• Updates the default delegate wiring to call RepositoryDetection.DetectRepositoryAsync(cwd) after adding an optional budget parameter. Keeps existing behavior for import sources while compiling with the new API shape.

src/Capacitor.Cli/Commands/CopilotImportSource.cs

CursorHookCommand.csMigrate Cursor hook delivery onto unified HookSpool + legacy spool migration +51/-24

Migrate Cursor hook delivery onto unified HookSpool + legacy spool migration

• Replaces CursorHookSpool usage with HookSpool, spooling by route segment and draining via DrainAllAsync with shared transient/permanent outcome mapping. Adds best-effort migration that transforms legacy {hook_event_name, body} lines into {route, body} entries and merges them into the new spool directory before deleting legacy files.

src/Capacitor.Cli/Commands/CursorHookCommand.cs

CursorImportSource.csAdjust repo detector default to match new DetectRepositoryAsync signature +1/-1

Adjust repo detector default to match new DetectRepositoryAsync signature

• Updates the default repo detector delegate to call RepositoryDetection.DetectRepositoryAsync(cwd) after the API gained an optional budget parameter. No functional change intended beyond signature compatibility.

src/Capacitor.Cli/Commands/CursorImportSource.cs

KiroImportSource.csAdjust repo detector default to match new DetectRepositoryAsync signature +1/-1

Adjust repo detector default to match new DetectRepositoryAsync signature

• Updates the default repo detector delegate to call RepositoryDetection.DetectRepositoryAsync(cwd) after the API gained an optional budget parameter. Preserves existing import behavior while enabling budget-aware calls elsewhere.

src/Capacitor.Cli/Commands/KiroImportSource.cs

PiImportSource.csAdjust repo detector default to match new DetectRepositoryAsync signature +1/-1

Adjust repo detector default to match new DetectRepositoryAsync signature

• Updates the default repo detector delegate to call RepositoryDetection.DetectRepositoryAsync(cwd) after the API gained an optional budget parameter. No intended behavioral change beyond compilation compatibility.

src/Capacitor.Cli/Commands/PiImportSource.cs

RepositoryDetection.csAllow repository detection/enrichment to self-skip under budget pressure +4/-3

Allow repository detection/enrichment to self-skip under budget pressure

• Extends EnrichWithRepositoryInfo and DetectRepositoryAsync with an optional budget and returns early when the remaining time is exhausted. Enables hook paths (notably session-start) to avoid delaying watcher startup or spooling due to slow git/gh probes.

src/Capacitor.Cli/RepositoryDetection.cs

Tests (5) +568 / -7
SpoolOutageRecoveryTests.csAdd integration tests for outage spooling and cross-hook recovery +185/-0

Add integration tests for outage spooling and cross-hook recovery

• Adds WireMock-based end-to-end tests verifying that a transient outage during session-end spools the event and a subsequent hook invocation replays it successfully. Covers the replay path where generate_whats_done is returned for a drained session-end.

test/Capacitor.Cli.Tests.Integration/SpoolOutageRecoveryTests.cs

ClaudeHookCommandTests.csAdd unit tests for Claude durable spooling, budgets, and replay behavior +203/-0

Add unit tests for Claude durable spooling, budgets, and replay behavior

• Introduces tests validating bounded PostOnce behavior, spooling on 5xx/hung server, non-spooling on permanent 4xx, drain ordering (start before end), and budgeted auth/client creation that still spools lifecycle events. Also covers hard-cap helpers and replayed generate_whats_done handling.

test/Capacitor.Cli.Tests.Unit/ClaudeHookCommandTests.cs

CursorHookCommandTests.csUpdate Cursor hook tests for unified spool routes and legacy migration +27/-7

Update Cursor hook tests for unified spool routes and legacy migration

• Adjusts existing Cursor hook tests to use HookSpool route segments and verifies drain-before-post ordering. Adds coverage that legacy Cursor spool files are transformed into the new route-based format and merged into the unified spool directory.

test/Capacitor.Cli.Tests.Unit/Cursor/CursorHookCommandTests.cs

HookBudgetTests.csAdd unit tests for HookBudget ceilings and remaining-time clamping +24/-0

Add unit tests for HookBudget ceilings and remaining-time clamping

• Validates that session-end has a larger ceiling than other hooks and that Remaining() subtracts elapsed time plus safety while never going negative. Ensures budget math behaves as expected for tight hook windows.

test/Capacitor.Cli.Tests.Unit/HookBudgetTests.cs

HookSpoolTests.csAdd unit tests for HookSpool ordering, rotation, outcomes, and reaping +129/-0

Add unit tests for HookSpool ordering, rotation, outcomes, and reaping

• Covers current-session-first drain ordering, Delivered/Drop/TransientStop behavior, concurrent append during drain (rotate-on-drain safety), skipping malformed/legacy lines, draining recovered .draining temps before live files, and stale-file reaping.

test/Capacitor.Cli.Tests.Unit/HookSpoolTests.cs

Documentation (2) +1637 / -0
2026-06-26-durable-hook-spool.mdAdd detailed implementation plan for durable hook spool rollout +1192/-0

Add detailed implementation plan for durable hook spool rollout

• Introduces a step-by-step implementation plan covering HookSpool semantics, budgeting, Claude/Cursor wiring, migration strategy, and required test scenarios. Captures constraints (AOT safety, fail-open behavior) and task breakdown used to drive the change set.

docs/superpowers/plans/2026-06-26-durable-hook-spool.md

2026-06-26-durable-hook-spool-design.mdAdd design spec for durable lifecycle hook spooling and replay +445/-0

Add design spec for durable lifecycle hook spooling and replay

• Documents the outage-driven failure mode for Claude lifecycle hooks and defines the durable spool + replay approach. Specifies on-disk format, rotation-on-drain behavior, ordering guarantees, deadline discipline, and replay side-effects like generate_whats_done.

docs/superpowers/specs/2026-06-26-durable-hook-spool-design.md

Other (2) +11 / -9
AppConfig.csMake ResolveServerUrl git probing timeout configurable (shorter for hooks) +7/-7

Make ResolveServerUrl git probing timeout configurable (shorter for hooks)

• Adds a gitTimeoutMs parameter to ResolveServerUrl and threads it into git repo-root/remote discovery to avoid blocking hook execution. Ensures hooks can fast-fail git probing under tight budgets while normal CLI commands keep the original timeout.

src/Capacitor.Cli.Core/Config/AppConfig.cs

Program.csAnchor hook process-start timestamp and tighten hook-path git timeouts +4/-2

Anchor hook process-start timestamp and tighten hook-path git timeouts

• Captures a process-start timestamp early and passes it into ClaudeHookCommand to compute remaining budgets deterministically. Uses a shorter gitTimeoutMs for hook invocations when resolving server URL to avoid spending hook budget on git probing.

src/Capacitor.Cli/Program.cs

@qodo-code-review

qodo-code-review Bot commented Jun 26, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Repo budget not enforced ✓ Resolved 🐞 Bug ☼ Reliability
Description
ClaudeHookCommand passes HookBudget.Remaining into RepositoryDetection for session-start, but
RepositoryDetection does not use that budget to bound git/gh work, so repo enrichment can still
consume multiple seconds and overrun the hook deadline. This undermines the PR’s guarantee that the
hook reaches the bounded POST/spool path before Claude kills the process.
Code

src/Capacitor.Cli/Commands/ClaudeHookCommand.cs[R172-176]

+        if (command == "session-start") {
+            // Budgeted so a slow git/gh probe self-skips under deadline pressure (repo info
+            // still arrives via the watcher's own detection). Awaited INSIDE the session-start
+            // block after EnsureWatcherRunning so it never delays transcript-capture start.
+            deferredRepoTask = RepositoryDetection.EnrichWithRepositoryInfo(body, HookBudget.Remaining(processStart, command));
Evidence
ClaudeHookCommand explicitly treats repo enrichment as “budgeted”, but RepositoryDetection only uses
the budget to early-return when it’s already <= 0, and otherwise still runs fixed 5s git and 2s gh
commands that can exceed the hook’s remaining time.

src/Capacitor.Cli/Commands/ClaudeHookCommand.cs[172-180]
src/Capacitor.Cli/RepositoryDetection.cs[73-103]
src/Capacitor.Cli/RepositoryDetection.cs[125-131]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`RepositoryDetection.EnrichWithRepositoryInfo(..., budget)` is intended to self-skip/shorten work when close to the hook deadline, but the current implementation only checks `budget <= 0` and then runs fixed-timeout git/gh commands. This can still exceed the remaining hook time, preventing the hook from reaching the POST/spool path before it is killed.

## Issue Context
- `ClaudeHookCommand` relies on a budgeted repo enrichment for `session-start` to avoid delaying the durable lifecycle handling.
- `RepositoryDetection.DetectRepositoryAsync` currently always uses `TimeSpan.FromSeconds(5)` for git and `TimeSpan.FromSeconds(2)` for gh regardless of the `budget` provided.

## Fix Focus Areas
- src/Capacitor.Cli/Commands/ClaudeHookCommand.cs[172-176]
- src/Capacitor.Cli/RepositoryDetection.cs[11-30]
- src/Capacitor.Cli/RepositoryDetection.cs[73-131]

## Suggested fix
- Actually consume/enforce the budget inside `RepositoryDetection`:
 - Start a stopwatch at entry and derive remaining time for each subprocess call: `remaining = budget - elapsed`.
 - Clamp each command timeout to `min(existingTimeout, remaining)` and skip the call if `remaining <= 0`.
 - Optionally, refactor `RunCommandAsync` to accept a `CancellationToken` (from `CancellationTokenSource(budget)`) instead of per-command CTS to guarantee the entire detection respects the overall budget.
- Ensure both the git and gh probes honor the same overall budget (not just per-command timeouts).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Hook spool undocumented in README ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
This PR changes kcap hook behavior to persist and replay lifecycle events via an on-disk spool
(~/.config/kcap/spool), which is a user-visible behavior change (disk usage + new recovery
semantics). README.md is not updated to document this change as required.
Code

src/Capacitor.Cli/Commands/ClaudeHookCommand.cs[R26-30]

+    public static Task<int> Handle(string baseUrl, TextReader stdin, Task? updateCheckTask = null, long processStart = 0) {
+        var spool = new HookSpool(PathHelpers.ConfigPath("spool"));
+        spool.ReapOlderThan(TimeSpan.FromDays(30));
+        var ps = processStart == 0 ? Stopwatch.GetTimestamp() : processStart;
+        return HandleWithDeps(spool, ps, baseUrl, stdin, updateCheckTask);
Evidence
PR Compliance ID 3 requires updating README.md for user-facing CLI behavior changes. The diff
shows kcap hook now creates and uses an on-disk HookSpool and appends lifecycle payloads for
later replay, but the README section discussing hook-based integrations does not mention the new
spool mechanism or its storage/behavior.

CLAUDE.md: Update README.md in the same PR for any user-facing CLI surface change
src/Capacitor.Cli/Commands/ClaudeHookCommand.cs[26-60]
src/Capacitor.Cli/Commands/CursorHookCommand.cs[50-61]
src/Capacitor.Cli/Commands/HookSpool.cs[14-43]
README.md[440-451]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PR introduces a durable on-disk hook spool (automatic spooling + cross-session draining/replay) for `kcap hook` execution, but the README is not updated to explain the new behavior, storage location, and operational notes.

## Issue Context
`ClaudeHookCommand` and `CursorHookCommand` now create/use `HookSpool(PathHelpers.ConfigPath("spool"))`, which writes per-session `.jsonl` files under the kcap config directory and replays them on subsequent hooks. This changes default runtime behavior and can affect troubleshooting and disk footprint.

## Fix Focus Areas
- README.md[110-140]
- README.md[390-460]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Unobserved factory task faults ✓ Resolved 🐞 Bug ☼ Reliability
Description
CreateClientWithinBudgetAsync only attaches a continuation for the "ran to completion" case when the
clientFactory times out, so a later-faulted factory task is never observed and can trigger
UnobservedTaskException noise and hide useful diagnostics. This is especially likely during outages
(the exact scenario this PR targets).
Code

src/Capacitor.Cli/Commands/ClaudeHookCommand.cs[R78-85]

+    internal static async Task<HttpClient?> CreateClientWithinBudgetAsync(Func<Task<HttpClient>> factory, TimeSpan cap) {
+        if (cap <= TimeSpan.Zero) return null;
+        var task = factory();
+        var winner = await Task.WhenAny(task, Task.Delay(cap));
+        if (winner != task) {
+            _ = task.ContinueWith(static t => { _ = t.Exception; t.Result.Dispose(); },
+                CancellationToken.None, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Default);
+            return null;
Evidence
The timeout branch only schedules a continuation with OnlyOnRanToCompletion, so faulted/canceled
factory tasks after the timeout have no observer and their exceptions can go unobserved.

src/Capacitor.Cli/Commands/ClaudeHookCommand.cs[76-88]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
When client creation exceeds the cap, the code abandons the factory task and only attaches a continuation for successful completion. If the factory task later faults/cancels, its exception is never observed.

## Issue Context
This path is exercised specifically under partial outages/hangs, so faulted tasks are plausible and should be observed to avoid unobserved-exception events and to keep diagnostics accessible.

## Fix Focus Areas
- src/Capacitor.Cli/Commands/ClaudeHookCommand.cs[78-87]

## Suggested fix
- Attach a continuation that runs for *all* terminal states and:
 - Disposes `t.Result` only when `t.Status == RanToCompletion`.
 - Observes `t.Exception` when `t.IsFaulted`.
 - Optionally handles cancellation.
 - Prefer `TaskContinuationOptions.ExecuteSynchronously`.

Example shape:
```csharp
_ = task.ContinueWith(t => {
   if (t.IsFaulted) _ = t.Exception;
   if (t.Status == TaskStatus.RanToCompletion) t.Result.Dispose();
}, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
```
- (Optional, larger change) switch to a `Func<CancellationToken, Task<HttpClient>>` factory and cancel the factory work with a CTS instead of abandoning it.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

4. Spool cap miscomputed ✓ Resolved 🐞 Bug ☼ Reliability
Description
HookSpool’s cap enforcement mixes byte-based file lengths (FileInfo.Length) with char-based counts
(string.Length), so the 1MB cap can be exceeded for non-ASCII payloads and trimming decisions can be
inaccurate. This can allow the spool file to grow beyond the intended bound.
Code

src/Capacitor.Cli/Commands/HookSpool.cs[R34-52]

+    public void Append(string sessionId, string route, string rawPayloadJson) {
+        var path = LivePathFor(sessionId);
+        if (path is null) return;
+        try {
+            Directory.CreateDirectory(spoolDir);
+            var line = new JsonObject { ["route"] = route, ["body"] = rawPayloadJson }.ToJsonString();
+            EnsureUnderCap(path, line.Length + 1);
+            File.AppendAllText(path, $"{line}\n");
+        } catch { /* best effort */ }
+    }
+
+    void EnsureUnderCap(string path, int incomingBytes) {
+        try {
+            if (!File.Exists(path)) return;
+            if (new FileInfo(path).Length + incomingBytes <= capBytes) return;
+            var lines = File.ReadAllLines(path).ToList();
+            while (lines.Count > 0 && lines.Sum(l => l.Length + 1) + incomingBytes > capBytes)
+                lines.RemoveAt(0);
+            File.WriteAllText(path, lines.Count > 0 ? string.Join('\n', lines) + "\n" : "");
Evidence
The spool size check uses FileInfo.Length (bytes) but incomingBytes and per-line sizes are
computed via string.Length (characters), which is not comparable under UTF-8 output and can lead
to overshooting the intended cap.

src/Capacitor.Cli/Commands/HookSpool.cs[34-52]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`EnsureUnderCap` compares `FileInfo.Length` (bytes) against `incomingBytes` computed from `string.Length` (chars). With UTF-8 encoding, non-ASCII characters can be >1 byte, so the spool can exceed `capBytes` even after trimming.

## Issue Context
`File.AppendAllText` writes UTF-8 by default, so byte counts should use `Encoding.UTF8.GetByteCount(...)`.

## Fix Focus Areas
- src/Capacitor.Cli/Commands/HookSpool.cs[34-53]

## Suggested fix
- Replace `line.Length + 1` with `Encoding.UTF8.GetByteCount(line) + 1`.
- In the trimming loop, compute total bytes using UTF-8 byte counts rather than `l.Length`.
- (Optional) avoid repeated `lines.Sum(...)` in the while-loop by maintaining a running total in bytes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread src/Capacitor.Cli/Commands/ClaudeHookCommand.cs
alexeyzimarev and others added 3 commits June 26, 2026 13:46
# Conflicts:
#	src/Capacitor.Cli/Commands/ClaudeHookCommand.cs
…ol cap, README

- CreateClientWithinBudgetAsync: the abandoned (timed-out) client-factory task is now observed for
  ALL terminal states — a late fault (likely during the outage this guards) no longer surfaces as
  an UnobservedTaskException; the client is disposed only on RanToCompletion.
- HookSpool cap: count UTF-8 bytes (not chars) so the 1 MB cap holds for non-ASCII payloads
  (FileInfo.Length is byte-measured; char counts under-counted). Adds a regression test.
- README: document durable lifecycle delivery (failed SessionStart/SessionEnd hooks spool to
  ~/.config/kcap/spool and replay on the next hook; reaped after 30 days) per CLAUDE.md.

(qodo finding #1 'repo budget not enforced' was already resolved by the prior merge-fix commit.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- session-end/subagent-stop deferred repo enrichment is now budgeted (was unbudgeted), and ALL
  three enrichment awaits (session-end/subagent-stop/session-start) are bounded by the remaining
  hook budget via AwaitEnrichmentWithinBudget — falling back to the un-enriched body so the bounded
  POST/spool path is always reached before Claude kills the hook (repo info still arrives via the
  watcher's own detection).
- RepoExclusion.IsExcludedAsync takes an optional budget, threaded into DetectRepositoryAsync, so the
  exclusion check's fallback repo detection can't delay the session-start watcher spawn (fail-open to
  capture on timeout; the per-cwd cache resolves excluded repos on subsequent sessions).
- RepositoryDetection.RunCommandAsync now kills the child process tree on timeout/failure, so a slow
  git/gh probe doesn't outlive the hook and accumulate across repeated invocations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alexeyzimarev
alexeyzimarev merged commit f0d78fd into main Jun 26, 2026
5 checks passed
@alexeyzimarev
alexeyzimarev deleted the durable-hook-spool branch June 26, 2026 12:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant