Auto-install Claude Code plugin during setup - #1
Merged
Conversation
`kapacitor setup` step 4 now writes the plugin registration directly into Claude Code's settings.json instead of printing a /plugin install command for the user to copy-paste. Users choose the scope (user-wide, project-only, or skip for manual install). Supports --plugin-scope flag for non-interactive mode. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
alexeyzimarev
added a commit
that referenced
this pull request
Apr 19, 2026
Findings #1–4 from qodo-code-review bot on PR #22: 1. Wrong auth base URL — CreateAuthenticatedClientAsync() defaulted its auth-discovery baseUrl to AppConfig/KAPACITOR_URL/localhost, which can diverge from the daemon's --server-url. Pass _baseUrl explicitly in all three phase handlers. 2. Cache TTL breaks long evals — the 15-min hard TTL is shorter than worst-case eval runtime (13 questions × 5 min each). Switch to 30-min sliding expiration keyed off last Get(), so only abandoned entries age out. 3. Abandoned contexts never reaped — expiry was only checked inside Get(), so a server crash between Prepare and Finalize leaked TraceJson until daemon restart. Add a 5-min sweep Timer; make the cache IDisposable so DI shutdown cleans it up. 4. Finalize exception leaks cache — _cache.Remove() was only on the success path; a throw from FinalizeAsync left the entry behind. Move the removal into a finally block. Finding #5 (Cancel handler doesn't stop in-flight work) is deferred to PR 3, which wires user-facing cancellation end-to-end; the PR description already calls this out as a known gap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
alexeyzimarev
added a commit
that referenced
this pull request
Apr 19, 2026
) * [DEV-1463] Add per-question eval command + result DTOs * [DEV-1463] Add EvalContextCache for per-run daemon state * [DEV-1463] Thread taxonomy through EvalContext for per-phase access * [DEV-1463] Rewrite daemon handlers for per-question eval dispatch * [DEV-1463] Remove obsolete RunEvalCommand from CLI * [DEV-1463] Address Qodo review findings on eval context cache Findings #1–4 from qodo-code-review bot on PR #22: 1. Wrong auth base URL — CreateAuthenticatedClientAsync() defaulted its auth-discovery baseUrl to AppConfig/KAPACITOR_URL/localhost, which can diverge from the daemon's --server-url. Pass _baseUrl explicitly in all three phase handlers. 2. Cache TTL breaks long evals — the 15-min hard TTL is shorter than worst-case eval runtime (13 questions × 5 min each). Switch to 30-min sliding expiration keyed off last Get(), so only abandoned entries age out. 3. Abandoned contexts never reaped — expiry was only checked inside Get(), so a server crash between Prepare and Finalize leaked TraceJson until daemon restart. Add a 5-min sweep Timer; make the cache IDisposable so DI shutdown cleans it up. 4. Finalize exception leaks cache — _cache.Remove() was only on the success path; a throw from FinalizeAsync left the entry behind. Move the removal into a finally block. Finding #5 (Cancel handler doesn't stop in-flight work) is deferred to PR 3, which wires user-facing cancellation end-to-end; the PR description already calls this out as a known gap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
alexeyzimarev
added a commit
that referenced
this pull request
Apr 20, 2026
Findings #1–3 from qodo-code-review bot on PR #23: 1. Empty CSV treated as unset — Parse("") used to return null, conflating "flag absent" with "flag present, empty value". Resolver then fell through to the full-catalog path instead of the zero- selection error. Parse now returns null only when csv itself is null; "" and ",,," now produce an empty array, flow into Resolve, and HandleEval exits 2 with "selection resolved to zero questions". 2. Missing flag value misparsed — `kapacitor eval --questions --skip safety <sid>` silently treated "--skip" as the value of --questions, producing a confusing "unknown token" error from the resolver. Guard in Program.cs now detects values starting with "--" and exits 2 with a "requires a value" message before dispatch. 3. Duplicate question IDs can crash — Resolve previously used ToDictionary(q => q.Id) which throws ArgumentException on duplicate keys, crashing the CLI if a misbehaving server returns a malformed catalog. Replaced with a TryAdd loop that surfaces a controlled error ("catalog contains duplicate id '...'") instead. Added tests for all three: Parse's null/empty/commas/whitespace behavior, Resolve's empty-selection and duplicate-ID paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3 tasks
alexeyzimarev
added a commit
that referenced
this pull request
Apr 20, 2026
* [DEV-1463] Add CLI eval question selection resolver * [DEV-1463] Add --questions / --skip / --list-questions to kapacitor eval * [DEV-1463] Address Qodo review findings on CLI eval flags Findings #1–3 from qodo-code-review bot on PR #23: 1. Empty CSV treated as unset — Parse("") used to return null, conflating "flag absent" with "flag present, empty value". Resolver then fell through to the full-catalog path instead of the zero- selection error. Parse now returns null only when csv itself is null; "" and ",,," now produce an empty array, flow into Resolve, and HandleEval exits 2 with "selection resolved to zero questions". 2. Missing flag value misparsed — `kapacitor eval --questions --skip safety <sid>` silently treated "--skip" as the value of --questions, producing a confusing "unknown token" error from the resolver. Guard in Program.cs now detects values starting with "--" and exits 2 with a "requires a value" message before dispatch. 3. Duplicate question IDs can crash — Resolve previously used ToDictionary(q => q.Id) which throws ArgumentException on duplicate keys, crashing the CLI if a misbehaving server returns a malformed catalog. Replaced with a TryAdd loop that surfaces a controlled error ("catalog contains duplicate id '...'") instead. Added tests for all three: Parse's null/empty/commas/whitespace behavior, Resolve's empty-selection and duplicate-ID paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
alexeyzimarev
added a commit
that referenced
this pull request
May 8, 2026
#1: StartForegroundAsync's finally block deleted agent.pid unconditionally, which orphaned a concurrent legitimate daemon's PID file in this race: 1. Process A's daemon-A exits cleanly. Process A enters finally. 2. Process B's `kapacitor agent start` reads the PID file, sees PID-A; IsOurDaemon returns false (PID-A's process is gone), guard passes. 3. Process B spawns daemon-B, writes PID-B. 4. Process A's finally deletes the PID file — orphaning daemon-B. Now we re-read agent.pid in finally and only delete if it still matches the process we spawned. Belt-and-braces against PID race losses. #2: StatusCommand had its own PID-file parser that did ReadAllText().Trim() + int.TryParse(...) — which fails on the multi-line PID|StartTicks format AgentCommands.WritePidFile produces. Without the foreground guard this only triggered for `-d` daemons; this PR makes foreground daemons hit it too. Parse the first non-empty line instead. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4 tasks
alexeyzimarev
added a commit
that referenced
this pull request
May 8, 2026
… is alive (#51) * [AI-78] Refuse foreground `kapacitor agent start` when another daemon is alive The detached path (`-d`/`--detach`) has guarded against duplicate launches via the PID file since day one. The foreground path didn't, so a second `kapacitor agent start` (run by mistake, by an automation, by a parallel terminal) would happily spawn a fresh kapacitor-daemon process. That fresh process calls DaemonConnect with empty live_agents (orchestrator not yet wired → GetLiveAgentIds returns []), the server's Register silently replaces the active daemon's slot, ReconcileDaemon([]) flips every running hosted agent to Failed, and the displaced real daemon keeps its WebSocket open without ever noticing. Mirror the detached path's PID-file check into StartForegroundAsync, write the PID file when the foreground daemon launches (so subsequent starts in any mode see it), and clean it up on exit. IsOurDaemon's StartTime check already handles the recycled-PID case if the parent dies hard and leaves a stale file. Server-side belt: kurrent-io/Kurrent.Capacitor PR 590 also tightens the ReconcileDaemon guard to ignore empty live_agents, so the cascade becomes impossible regardless of what produced the second DaemonConnect. Either fix alone closes the bug; both together make it double-safe. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [AI-78] Address Qodo review: PID-file race + status parser #1: StartForegroundAsync's finally block deleted agent.pid unconditionally, which orphaned a concurrent legitimate daemon's PID file in this race: 1. Process A's daemon-A exits cleanly. Process A enters finally. 2. Process B's `kapacitor agent start` reads the PID file, sees PID-A; IsOurDaemon returns false (PID-A's process is gone), guard passes. 3. Process B spawns daemon-B, writes PID-B. 4. Process A's finally deletes the PID file — orphaning daemon-B. Now we re-read agent.pid in finally and only delete if it still matches the process we spawned. Belt-and-braces against PID race losses. #2: StatusCommand had its own PID-file parser that did ReadAllText().Trim() + int.TryParse(...) — which fails on the multi-line PID|StartTicks format AgentCommands.WritePidFile produces. Without the foreground guard this only triggered for `-d` daemons; this PR makes foreground daemons hit it too. Parse the first non-empty line instead. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [AI-78] Add OS-level exclusive lock around daemon start Previous PID-file guard had a TOCTOU window: two concurrent `kapacitor agent start` invocations could both observe no live daemon, both spawn daemons, and only one's PID would end up in the file. The loser's daemon would then race-write DaemonConnect with empty live_agents and trip the cascade we're meant to prevent. Add agent.start.lock opened with FileShare.None (POSIX flock(LOCK_EX), Windows native sharing constraint). Foreground holds the lock for the daemon's entire lifetime; detached holds it just for the check + spawn + WritePidFile window before the parent exits. Two concurrent starts now serialize at the OS level: the second's TryAcquireStartLock returns null (IOException from FileShare.None) and it refuses with "Another `kapacitor agent start` is already in progress …" The lock is per-open-handle so even SIGKILL on the parent releases it cleanly. Refactored StartForegroundAsync into two parts: the lock-acquiring guard wrapper and the original spawn body (now SpawnForegroundAsync) to keep the lock-acquire/release scope easy to read. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
alexeyzimarev
added a commit
that referenced
this pull request
May 8, 2026
#1: Reliability — TickAsync now total. Both recovery actions (ReRegisterAsync, ForceReconnectAsync) ultimately call into SignalR (InvokeAsync / StopAsync) and can throw on transient transport state (invalid hub state, mid-reconnect cancellation). With the previous code those exceptions escaped TickAsync and faulted the unobserved RunDaemonHeartbeatLoopAsync background Task — silently disabling the daemon's liveness probing forever. Each recovery call now runs through a guarded helper (SafeReRegisterAsync / SafeForceReconnectAsync). A failed re-register escalates to forced reconnect; a failed forced reconnect logs and lets the next 15 s tick retry. Belt-and-suspenders try/catch in RunDaemonHeartbeatLoopAsync as defence-in-depth so a future change accidentally rethrowing from TickAsync still doesn't kill the loop. Two new tests pin the contract: TickAsync must not rethrow when ForceReconnect fails, and must not rethrow when both ReRegister AND ForceReconnect fail. #2: Maintainability — remove ServerConnection.SendHeartbeatAsync. Its last caller was the pre-AI-566 fire-and-forget heartbeat loop, which this branch replaced with the round-trip Ping path. The server-side DaemonHeartbeat hub method stays as a wire-compat alias for older deployed daemons, but on the daemon side the local method is genuinely dead. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3 tasks
alexeyzimarev
added a commit
that referenced
this pull request
May 8, 2026
* [AI-566] Daemon round-trip Ping replaces fire-and-forget heartbeat Pairs with the server-side DaemonPing hub method + EvalRunOrchestrator fast-fail on slot replacement (server PR). The current SendAsync heartbeat is one-way, so when DaemonRegistry.Register silently displaces this connection's slot (the staging incident), the daemon never notices — it keeps pumping heartbeats the server drops and the orchestrator's in-flight calls hang for the full per-question timeout. DaemonHeartbeatLoop runs every 15s (under the server's default 30s ClientTimeoutInterval) with a 10s ping deadline. Ping returning false → ReRegisterAsync (slot was displaced under us). Ping throwing/timing out → ForceReconnectAsync (transport is hung; stop the hub so OnClosed → ConnectWithRetryAsync builds a fresh conn and re-registers). Outer cancel exits cleanly so process shutdown doesn't trigger a reconnect storm. The loop sits behind a small IDaemonHeartbeatPort interface so the unit tests can exercise the four classify cases without spinning up a real SignalR HubConnection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [AI-566] Address Qodo review: harden heartbeat tick, drop dead method #1: Reliability — TickAsync now total. Both recovery actions (ReRegisterAsync, ForceReconnectAsync) ultimately call into SignalR (InvokeAsync / StopAsync) and can throw on transient transport state (invalid hub state, mid-reconnect cancellation). With the previous code those exceptions escaped TickAsync and faulted the unobserved RunDaemonHeartbeatLoopAsync background Task — silently disabling the daemon's liveness probing forever. Each recovery call now runs through a guarded helper (SafeReRegisterAsync / SafeForceReconnectAsync). A failed re-register escalates to forced reconnect; a failed forced reconnect logs and lets the next 15 s tick retry. Belt-and-suspenders try/catch in RunDaemonHeartbeatLoopAsync as defence-in-depth so a future change accidentally rethrowing from TickAsync still doesn't kill the loop. Two new tests pin the contract: TickAsync must not rethrow when ForceReconnect fails, and must not rethrow when both ReRegister AND ForceReconnect fail. #2: Maintainability — remove ServerConnection.SendHeartbeatAsync. Its last caller was the pre-AI-566 fire-and-forget heartbeat loop, which this branch replaced with the round-trip Ping path. The server-side DaemonHeartbeat hub method stays as a wire-compat alias for older deployed daemons, but on the daemon side the local method is genuinely dead. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 14, 2026
5 tasks
alexeyzimarev
added a commit
that referenced
this pull request
Jun 26, 2026
…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>
realtonyyoung
added a commit
that referenced
this pull request
Jun 27, 2026
…ations #2: replace the ledger's parent line-count key with a SHA-256 content fingerprint over the parent transcript + children, so a same-line-count mutation (tool part completing, in-place edit, changed/added child) invalidates the skip and re-imports. Fingerprint computed at classify, carried on SourceMeta, recorded after session-end. Document #1 (server returns 200 on swallowed per-event write failure) and #3 (subagent lifecycle hooks return OK on write failure) as known limitations with server-repo follow-ups. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
realtonyyoung
added a commit
that referenced
this pull request
Jun 27, 2026
…20) (#192) * Surface OpenCode in `kcap status` hooks line AI-921 wired OpenCode end-to-end (installer, plugin, hook dispatcher, import-less live ingest) but left it off the `kcap status` Hooks line and the `kcap status --help` text — the same vendor-surface gap that hit Gemini and Kiro before (PR #169). The vendor fully works; it just wasn't reported, so nothing failed and the miss was invisible to build/tests. - StatusCommand.BuildHooksStatusLine: add `opencode` param + `OpenCode ✓/✗` entry (canonical order, last — like Pi it ships a live-ingest plugin file, not shell hooks), detected via OpenCodeExtensionInstaller.IsInstalled(OpenCodePaths.KcapPlugin()). - help-status.txt: Hooks line now reads "Pi / OpenCode live-ingest extensions". - StatusCommandHooksTests: cover OpenCode in both BuildHooksStatusLine tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * README: list OpenCode in the setup-wizard detection paragraph The `## CLI commands` setup section enumerated detected agents only through Pi, omitting SST OpenCode — the quick-start paragraph already lists it, and CLAUDE.md requires both stay in sync. Mirrors the existing phrasing and notes that, like Pi, OpenCode has no shell hooks so the wizard installs a live-ingest plugin. Flagged by PR review on #178. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Design spec: kcap import --opencode (historical OpenCode import from SQLite) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Revise OpenCode import spec per Codex design review - Fix subagent lifecycle order (children before parent session-end) - Resolve subagent watermark via Gemini import precedent (startLine 0 + idempotency) - Soften byte-match claim to final-state/normalizer-compatible; document watermark caveat - Pin part ordering (time_created,id) with empirical validation note - Move SQLite dependency to CLI project (keep daemon/Core AOT-clean) - Use /hooks/set-title for native title; ms epoch conversion - Add edge-cases section; accept shared send-failure behavior (idempotency) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Revise OpenCode import spec per Codex 2nd-pass review (rev3) - Replace line-number resume with binary New/AlreadyLoaded classification (live snapshot vs import final-state line spaces are incompatible) - Add importable-line predicate (Pi IsImportRelevantLine analog) for MinLines - Fix synthesis query: order by message chronology, not lexical message_id; LEFT JOIN so empty messages aren't dropped - Strengthen part-ordering pre-merge verification requirements - Document OpenCode-specific send-failure consequence (summary/model recompute) - Make parent/child lifecycle sequencing explicit; deterministic child order - Fix Architecture/Core-vs-CLI contradiction; expand edge cases (no-canonical-event messages, grandchildren, mixed live/historical) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Implementation plan: kcap import --opencode (TDD, 10 tasks) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Revise OpenCode import plan per Codex plan review - Add opt-in strict transcript sender (failOnError) — OpenCode aborts before session-end/subagent-stop on batch failure (binary policy has no resume) - Make IsImportRelevantLine role-aware + hidden-aware to match server normalizer - Subagent start/stop use the real temp transcript path; stronger ordering test (agent_id, agent_type, vendor, full POST order) - Order-sensitive assertions (string.Join+IsEqualTo) instead of IsEquivalentTo - Harden AOT gate: explicit RIDs + pipefail, no masked publish failures - Add WAL-writer read test; null-dir / zero-message / timestamp-magnitude cases - Cleaner fixture JSON via JsonObject - Reconcile spec (strict sender, role-aware predicate) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Revise plan+spec per Codex 2nd plan review: completeness-gated repair Blocker fix: binary AlreadyLoaded couldn't repair a partial multi-batch import (server HWM advances on transcript ingest, not session-end). Replace with completeness-gated classification: - New / AlreadyLoaded(ended) / Partial-repair(watermark, not ended) / TooShort - repair replays full transcript with line numbers offset above HWM (lineNumberOffset param added to SendTranscriptBatches); dedup by canonical prt_ id - strict sender's role clarified: keeps session not-ended on failure so re-run repairs - per-subsession gating via ?agentId= (skip if SubagentCompleted, else repair) - new Task 0: confirm server contract (ended signal, dedup-by-id, HWM filter) + fallback Also from review: align IsImportRelevantLine with server normalizer (Length>0, assistant id requirement, tool fields null-checked); AOT gate exit 1 not break; WAL test asserts sidecars + settles Cache=Private; timestamp seconds test; tighten subagent test (child transcript between start/stop); repair integration test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Revise plan+spec per Codex 3rd plan review: CLI-side import ledger Codex confirmed no data-loss blocker remains, but the current server exposes no ended signal — so the completeness-gated design would run in always-replay mode (re-send every session each run). Per decision, add a client-side import ledger as the completeness signal instead: - OpenCodeImportLedger (Task 4b): per-machine, per-server record of fully-imported sessions (keyed by server URL + reconstructed line count), AOT-safe source-gen JSON - classification: ledger hit -> AlreadyLoaded (skip); else New / Partial-repair - ledger written only after session-end succeeds; strict sender keeps partials unrecorded - drop the speculative server ended-signal plumbing (ServerState); Task 0 now confirms only dedup-by-id + HWM filter - children: no per-child gate (complete parent skipped wholesale via ledger) - tests isolated via fixture LedgerPath; add ledger round-trip, second-run-skip, and batch2-failure-then-rerun-repairs (WireMock scenario, 150 lines) tests Also from review: HasField -> string-kind (server Str parity); checked offset arithmetic for overflow; fix stale no-watermark-left comment; Task 0 reframed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Task 0 confirmed: kcap-server repair contract verified against source Verified against kurrent-io/kcap-server@main: - dedup by canonical EventId (HashGuid of prt_/message id), line-number-independent - HWM filter drops line_number <= currentHwm before normalization - last-line returns last_line_number only (no ended field -> ledger is required); reads max lineNumber over last 50 events backward (under-report caveat noted) - HWM + dedup keyed sessionId|agentId; last-line accepts ?agentId= Repair design inherits the live watcher reconnect-resend idempotency profile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * build: add Microsoft.Data.Sqlite to CLI for OpenCode import Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: OpenCodeDb read-only reader, line reconstruction, importable predicate Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: OpenCode import ledger (client-side completeness record) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: OpenCodeImportSource discovery + ledger-gated classify + import + subagents Includes opt-in failOnError/lineNumberOffset on SessionImporter.SendTranscriptBatches (defaulted; peers unchanged). Parent + child import with strict send + repair-above-HWM. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: wire --opencode import filter + register OpenCodeImportSource Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test: OpenCode import integration tests (lifecycle, subagents, repair, ledger) Also fix IL2026/IL3050 in OpenCodeDb: cast to JsonNode so Add(JsonNode?) is chosen over the AOT-unsafe generic Add<T> (per CLAUDE.md). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: document kcap import --opencode (help + README) Corrects the README claim that OpenCode capture is live-only / has no import. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * harden OpenCode import per code review: discovery guard, cancellation, overflow - DiscoverAsync: guard db open/query so a corrupt/schema-drifted opencode.db warns and skips OpenCode instead of crashing the whole import run (other vendors) - QuerySessions: skip malformed/null rows instead of aborting the scan - propagate OperationCanceledException out of the import catches (was swallowed as Failed) - checked() on repair line-number offsets (parent + child) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: ledger keyed by content fingerprint; document server-side limitations #2: replace the ledger's parent line-count key with a SHA-256 content fingerprint over the parent transcript + children, so a same-line-count mutation (tool part completing, in-place edit, changed/added child) invalidates the skip and re-imports. Fingerprint computed at classify, carried on SourceMeta, recorded after session-end. Document #1 (server returns 200 on swallowed per-event write failure) and #3 (subagent lifecycle hooks return OK on write failure) as known limitations with server-repo follow-ups. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * review: fix NUL byte in source, propagate cancellation in helpers, cite AI-1023 Codex final pre-merge review (no blockers): - replace the literal NUL fingerprint separator with a backslash-u0000 escape so the .cs file is text (rg/tools no longer treat it as binary); runtime unchanged - rethrow OperationCanceledException in the watermark-probe and PostHookAsync catches so cancellation is not masked as ProbeError/hook-failure - name AI-1023 in the spec server-side limitation notes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
realtonyyoung
added a commit
that referenced
this pull request
Jul 3, 2026
…ailure (review round 2) Addresses follow-up review on the proactive-refresh change: - Legacy fallback (Qodo #2): RefreshIfExpiringAsync read only the per-profile token file, so a pre-upgrade install with only tokens.json got no proactive refresh. Extract LoadWithLegacyFallbackAsync (shared with LoadAsync) and use it, so the legacy token is refreshed — and migrated into the per-profile store when the refresh persists under the lock. - Lock contention (Qodo #3): a 15s cross-process-lock acquisition timeout returned null, which the daemon reported as a refresh Failure (misleading "run kcap login" warning + backoff) even though no endpoint call was made. Add an onLockContended callback (proactive path only; reactive GetValidTokensAsync is unaffected — default null) and a ProactiveRefreshOutcome.Contended that the loop logs at Debug with no warning and no backoff (contention is transient). Qodo #1 (profile-switch miswrite) was already resolved in 783b65b. Adds a TokenRefreshLoop test for the Contended path; legacy fallback is covered via the shared LoadWithLegacyFallbackAsync (LoadAsync's existing fallback tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alexeyzimarev
pushed a commit
that referenced
this pull request
Jul 4, 2026
* feat(daemon): proactively refresh auth tokens ahead of expiry Auth tokens were refreshed only lazily (on the next call after the access token had already expired), so after an idle period the next hook hit a 401 and forced a `kcap login`. The daemon now runs a low-frequency loop that refreshes the active profile's token *ahead* of expiry, keeping a WorkOS sliding-inactivity session alive for as long as the daemon runs. - TokenStore.RefreshIfExpiringAsync refreshes within a window via the existing cross-process lock (rotation-safe re-read under the lock); no-op for the None provider and when no tokens are stored. Resolves the active profile once and threads it through the read + lock. - TokenRefreshLoop rate-limits attempts to at most one per interval, so a failing refresh (dead/rotated token) or a short-lived token that keeps re-entering the window can't hammer the refresh endpoint every tick. - Wired into AgentOrchestrator alongside the existing heartbeat loops. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(auth): persist refreshed token under the locked profile; skip double-refresh after a peer Addresses two review findings on the proactive-refresh change: 1. Profile-switch miswrite (high): RefreshWorkOSAsync/RefreshGitHubAsync persisted via the active-profile-resolving SaveAsync(StoredTokens) overload, so a `kcap profile switch` while a refresh was in flight could write the locked profile's rotated token into a *different* profile's file — without that profile's lock, corrupting its credentials and leaving the original stale. The refresh delegates now return without persisting; RefreshWithCrossProcessLockAsync persists via SaveAsync(profile, refreshed) under the same profile it locked. Fixes both the proactive and the pre-existing reactive (GetValidTokensAsync) path. 2. Double-rotation after a peer refresh (medium): the under-lock re-read only suppressed a refresh when the token was no longer "due". For a short-lived token (or JwtExpiry's now+5min parse fallback), a token a peer had just refreshed was still inside the proactive window, so the proactive caller rotated it again. Now, if the re-read token changed from the one we read and is still valid, we return it without re-refreshing. The reactive path is unaffected (its predicate is IsExpired, already false for a valid token). Exposes RefreshWithCrossProcessLockAsync as internal for unit testing; adds CrossProcessRefreshTests covering persist-under-locked-profile, peer-refresh suppression, and reactive-path preservation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(auth): legacy-token fallback + distinguish lock contention from failure (review round 2) Addresses follow-up review on the proactive-refresh change: - Legacy fallback (Qodo #2): RefreshIfExpiringAsync read only the per-profile token file, so a pre-upgrade install with only tokens.json got no proactive refresh. Extract LoadWithLegacyFallbackAsync (shared with LoadAsync) and use it, so the legacy token is refreshed — and migrated into the per-profile store when the refresh persists under the lock. - Lock contention (Qodo #3): a 15s cross-process-lock acquisition timeout returned null, which the daemon reported as a refresh Failure (misleading "run kcap login" warning + backoff) even though no endpoint call was made. Add an onLockContended callback (proactive path only; reactive GetValidTokensAsync is unaffected — default null) and a ProactiveRefreshOutcome.Contended that the loop logs at Debug with no warning and no backoff (contention is transient). Qodo #1 (profile-switch miswrite) was already resolved in 783b65b. Adds a TokenRefreshLoop test for the Contended path; legacy fallback is covered via the shared LoadWithLegacyFallbackAsync (LoadAsync's existing fallback tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
alexeyzimarev
added a commit
that referenced
this pull request
Jul 7, 2026
…fault - McpFlowsServer: resolving requester_machine_id no longer aborts the flow. MachineId.Get() can throw on first-run create (unwritable config dir), and the field is optional on the wire, so degrade to null (server falls back to the mirror) instead of failing start_review_flow. (Qodo #295 #2) - README: same-host review flows now run read-only in the requester's live checkout (borrow), not a mirrored worktree — update the start_flow mode description to match the new default. (Qodo #295 #1) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
alexeyzimarev
added a commit
that referenced
this pull request
Jul 7, 2026
…nc (#295) * feat(cli): stamp requester_machine_id on the flow request — activates borrow-cwd [AI-1207] The server's borrow-cwd resolver (kcap-server AI-1207 Phase B) picks the read-only borrow path over a mirrored worktree only when it can prove the reviewer would run on the SAME host as the requester — by matching the request's requester_machine_id against each connected daemon's registration id. Until now the CLI never sent that id, so RequesterMachineId was always null server-side and every flow fell back to the (fragile) mirror. Stamp MachineId.Get() — the same stable machine id the daemon reports at registration (ServerConnection) — onto every start_review_flow request. This is the last piece that activates borrow end to end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(daemon): remove dead mirror-sync now that the server borrows the cwd [AI-1207 Phase C] With borrow activated, the reviewer runs read-only directly in the requester's checkout, so the server (kcap-server AI-1207 Phase B) no longer invokes RefreshAgentWorktree or sends syncFromRepoRoot. The daemon-side mirror machinery is now dead code — remove it in full: - AgentOrchestrator: HandleRefreshAgentWorktree, TrySyncWorktreeAtLaunchAsync, TryReSyncWorktreeForRoundAsync, IsAllowedSyncSourceAsync, the launch/round call sites, DaemonManagedWorktreeExcludes, RoundResyncTimeout, the three Log* mirror partials, and AgentInstance.SyncSourceRepoRoot. - WorktreeManager: SyncFromSourceAsync + its now-orphaned IsUnderExcluded helper and LogSyncCompleted. - ServerConnection: the RefreshAgentWorktree handler property + hub On<> registration. - Models: RefreshAgentWorktreeCommand/Result records + JsonSerializable registrations, and LaunchAgentCommand.SyncFromRepoRoot. The borrow path (BorrowAuthorizer, ProbeBorrowSource, WorkLocation, the origin check GetOriginRemoteAsync) is untouched. Deleted the per-round-resync test file and the SyncFromSource / SyncFromRepoRoot tests; the frozen OldLaunchAgentCommand wire snapshot keeps its historical field. Daemon unit suite: 2620 passed / 0 failed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Address Qodo review: degrade MachineId failure + fix README borrow default - McpFlowsServer: resolving requester_machine_id no longer aborts the flow. MachineId.Get() can throw on first-run create (unwritable config dir), and the field is optional on the wire, so degrade to null (server falls back to the mirror) instead of failing start_review_flow. (Qodo #295 #2) - README: same-host review flows now run read-only in the requester's live checkout (borrow), not a mirrored worktree — update the start_flow mode description to match the new default. (Qodo #295 #1) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
realtonyyoung
added a commit
that referenced
this pull request
Jul 17, 2026
…byte checkpoint, heartbeat-aware idle ceiling, delivery-boundary re-checks Addresses findings #2, #3, #6, and the watcher-side half of #8/#1 from kcap-cli PR #324 review: - #2: CursorRewriteGuard.VerifyFullPrefix existed but was never called from the poll loop; wire it in on a periodic cadence (CursorFullPrefixVerifyEveryNPolls). Also add VerifyNotShrunk — the guard's zone checks were previously gated entirely behind "did the file grow", so a shrink or in-place same-length rewrite slipped through undetected. - #3: checkpoint the Cursor byte cursor using the SAME capped snapshot length ReadNewCompleteLinesAsync sampled (NewTranscriptLines.SnapshotByteLength), not a fresh FileInfo.Length re-sample racing a concurrent append. Advance the checkpoint only as far as the server's acked LINE count actually covers (ByteOffsetForAckedLines), not the full capped range, so a partially-disposed D3 batch never has its unacked tail checkpointed as delivered. - #6: the Cursor idle clock (ShouldEndOnIdle) is now the later of transcript activity and the hook heartbeat mtime (ResolveCursorIdleClock), and child (subagent) watchers are idle-ceiling eligible without requiring ThresholdReached, which they never set. - #8 (watcher half): re-check the quarantine/barrier markers immediately before SendTranscriptBatchAcked, not only at the top of the poll. - #1 (backfill half): CursorTranscriptBackfill re-checks quarantine/barrier immediately before the POST, closing the same race window on that path. DrainNewLines is now internal (was private) so the guard wiring is directly regression-testable without a live SignalR server — every path exercised trips before ever touching the HubConnection argument. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
realtonyyoung
added a commit
that referenced
this pull request
Jul 17, 2026
…ical import skips quarantined sessions (incl. correlated children) Addresses the replay half of finding #1 and finding #7 from kcap-cli PR #324 review: - #1 (replay half): LifecycleSpoolDrain's transcript poster now parses the batch's vendor/session_id and drops (permanently discards) a Cursor batch whose session is quarantined, and treats a pending side-effect barrier as transient (retry later). This is the only delivery-time check the shutdown-spool-replay path has — a batch queued before a runtime rewrite-guard trip could otherwise still be replayed later. - #7: CursorImportSource.ClassifyAsync now skips (ProbeError) any session whose quarantine IDENTITY is marked — resolved via the already-computed subagentLinks map so a correlated child is filtered under its PARENT's quarantine marker (CursorRewriteGuard is always keyed on the family/parent id for a spawned child watcher), not its own id. Previously `kcap import` had no awareness of the marker at all and could feed the exact corrupted line-number source the guard exists to shut off. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
realtonyyoung
added a commit
that referenced
this pull request
Jul 17, 2026
… it actually sends Three related TOCTOU-closing fixes to the D0 rewrite guard, all stemming from the same theme: the guard's hash must be bound to the exact bytes actually processed/sent, and its baseline must be real from the start. 1. Bind-to-sent-bytes (finding #1): ReadNewCompleteLinesAsync now optionally (captureRawBytes: true, wired for vendor=="cursor") captures the raw byte buffer of the SAME capped read that decodes the batch's lines (NewTranscriptLines.SnapshotBytes). DrainNewLines's guard block now hashes directly from that snapshot instead of reopening the file separately to record the new-range/prior-zone hashes — closing the window where a rewrite landing between the decode read and the old reopen produced a hash for bytes the batch never actually came from. The post-ack checkpoint's trailing hash is now derived from the same already-verified snapshot instead of reopening the file after the RPC returns (a rewrite in flight during the ack could otherwise be blessed as the new baseline). 2. Reconnect rewind atomicity (finding #2): a reconnect discovering the server is behind the client now rewinds state.CursorByteOffset and the guard's checkpoint ATOMICALLY with the line cursor, via the extracted ApplyReconnectRewindAsync (testable without a live SignalR reconnect). The true byte offset of the rewound line is resolved by scanning the transcript (ResolveByteOffsetForLineAsync) rather than leaving the byte checkpoint at the later, too-far-ahead offset (which left the replayed line gap's new-range verification starting past the bytes it actually occupies). 3. Real full-prefix baseline (finding #3): the periodic full-prefix re-hash now seeds on the REAL first poll (not just lazily on the guard's own first VerifyFullPrefix call, which previously never happened before poll N) — a same-length rewrite of an already-checkpointed middle region landing in polls 1..N-1 now has a real baseline to be caught against at poll N, instead of poll N seeding the already-rewritten file as if it were the original. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
realtonyyoung
added a commit
that referenced
this pull request
Jul 17, 2026
… polls, cadence, and resume Finding #1 - a poll that consumed only blank/whitespace lines advanced state.LinesProcessed without advancing state.CursorByteOffset (or the rewrite guard's checkpoint) to match. The next poll's bounded decoder then re-read the same already-"processed" blank bytes but seeded its line numbering from the already-advanced LinesProcessed, inflating the line count on every idle poll and mis-mapping the next real batch's ack byte frontier. DrainNewLines' "no content, no repo change" branch now maps the same blank-line count to its true byte offset (ByteOffsetForAckedLines, mirroring the ack path) and re-checkpoints the guard, so a truly idle follow-up poll decodes nothing new. Finding #4 - CursorGuardPollCount (the periodic full-prefix cadence counter) incremented unconditionally before the guarded file was even opened. A transient IOException on that read still consumed the cadence slot, so the next successful poll silently skipped its due full-prefix baseline. The cadence decision is now peeked without mutating state, and only committed once the guarded read actually completes without throwing. Finding #5 - ResolveByteOffsetForLineAsync silently clamped to EOF when the server's acknowledged resume line exceeded the local transcript's line count (a transcript truncated/replaced while the watcher was offline), and SeedCursorByteOffsetAsync/ApplyReconnectRewindAsync treated that clamp as a valid baseline — seeding the rewrite guard against the wrong (truncated) file while the line cursor advanced past it. Both methods now return a typed "could not resolve exactly" result; the seed path quarantines the session instead of seeding a bogus baseline, and both the initial WatcherConnect resume and reconnect-rewind call sites exit (cts.Cancel()) rather than proceed on an unresolved rewind. Regression tests (CursorGuardWiringTests, CursorReconnectRewindTests): a blank-only poll advances both frontiers together and a subsequent genuinely-idle poll re-decodes nothing; a guarded read that throws IOException never consumes the full-prefix cadence; a resume/reconnect frontier beyond the local transcript's line count quarantines instead of seeding a clamped baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
realtonyyoung
added a commit
that referenced
this pull request
Jul 17, 2026
…iled drain branch too The "repo-only batch failed" catch branch in DrainNewLines advanced state.LinesProcessed past blank/whitespace-only lines without advancing state.CursorByteOffset (or the rewrite guard's checkpoint) to match - the same byte/line-frontier drift finding #1 fixed for the "no content, no repo change" early return, on the other path that moves the line cursor without an acked send. This branch is reached only when a repo change was pending (repoToSend != null) and the repo-only RPC threw with newLines.Count == 0 (any lines read were blank); left unfixed, the next poll's bounded decoder re-read those same blank bytes under an inflated line number. Extracted the finding-#1 lockstep logic into a shared local function (AdvanceCursorBlankByteFrontierInLockstep) so both call sites - the early return and this catch branch - map the consumed blank-line count to its true byte offset via ByteOffsetForAckedLines (fed from the already-captured cursorGuardSnapshot) and re-checkpoint the guard byte-for-byte identically. Regression test: a blank-only poll with a pending repo change whose repo-only send fails advances byte AND line frontier together, and a subsequent idle poll (repo change still pending, still failing) re-decodes nothing / does not inflate the line number. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
realtonyyoung
added a commit
that referenced
this pull request
Jul 17, 2026
…omment Qodo finding #1 (Review recommended): the doc comment above ReconcileOrphanedCursorSubagentChildren carried narrative history (two separate review-round call-outs, a <para> aside) well past the repo's keep-comments-concise convention. Trimmed to the essential contract — what counts as an orphan, why it must import standalone, and why SubagentChildren is pruned — and dropped the embedded Linear identifiers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alexeyzimarev
pushed a commit
that referenced
this pull request
Jul 18, 2026
* Fix Kiro parent-PID watchdog name match (kiro-cli)
Adds a bounded `-cli` suffix tolerance to MatchesAgentName so the by-name
ancestry walk identifies the durable `kiro-cli` process for vendor `kiro`,
instead of falling back to the fragile getpgrp/getppid heuristic.
Refs AI-1359.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Freeze the watcher idle clock while disconnected
Subtracts SignalR-outage time (accrued since last activity) from the idle
measure so a transient disconnect can't false-idle-end a Codex/Antigravity
session, while repeated reconnects with no new lines still idle-end after
the connected budget.
Refs AI-1359.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add staged parent-dead / wedged-watcher recovery
On ParentAlreadyDead the watcher now periodically re-resolves + re-arms the
parent-exit watchdog (using the vendor process-name alias); it ends the
session only after a long, configurable ceiling with no transcript progress
and continued resolution failure, and any new progress resets the window.
This is the only end path for a wedged, alive-but-connected watcher that the
server stale sweep can't see. New env var KCAP_PARENT_DEAD_CEILING_MINUTES.
Refs AI-1359.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: AgentHookPoster.Spooled + PostOrSpoolAsync spool-on-lapse
Adds a Spooled outcome and PostOrSpoolAsync: on lapsed-auth or transient
(5xx/408/429/unreachable) failure the lifecycle payload is durably spooled
(HookSpool) for a later drain pass, so live capture can start regardless of
hook-POST delivery (spawn-before-post). Refs AI-1357.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: TranscriptSpool — bounded, no-drop, needs-import marker
AI-1357 Task 2: dedicated on-disk spool for undelivered transcript-tail
batches captured during a watcher outage. Unlike HookSpool (1 MB,
drop-oldest — fine for small lifecycle POSTs), this is bounded at 8 MB
per session with NO SILENT DROP: on cap exhaustion it stops appending
and writes a needs-import marker instead of truncating history, so the
session surfaces as requiring `kcap import` rather than silently losing
transcript content.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: TranscriptSpool no silent drop on I/O failure + Ignored result
Review fixes for AI-1357 Task 2:
- Append now marks needs-import (not phantom Appended) when the live
write throws — the silent-drop this class exists to prevent.
- MarkNeedsImport returns bool and logs to stderr on failure so callers
don't trust an unpersisted marker.
- Add AppendResult.Ignored for the malformed-session-id drop so it can't
be mistaken for a real append.
- Tests for the I/O-failure path and the malformed-id path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: LifecycleSpoolDrain — global cross-spool ordered drain pass
Adds a session-agnostic drain pass to run at the start of every kcap
invocation (and periodically by the daemon), enforcing per-session
ordering across the lifecycle (HookSpool) and transcript (TranscriptSpool)
spools: spooled session-start -> transcript tail (+ needs-import marker,
delivered even over cap) -> spooled session-end. Adds the route-filtered
HookSpool.DrainRoutesAsync/SessionIdsWithBacklog and
TranscriptSpool.SessionIdsWithBacklog helpers this needs; HookSpool's
existing route-agnostic DrainAllAsync is untouched (still used by
Claude/Cursor).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: spawn-before-post + Spooled for Kiro/OpenCode/Pi/Copilot hooks
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: throttle spool drain per-prompt; ShouldSpawnAfter drops AuthLapsed
AI-1357 review: (1) DrainSpoolsAsync now self-throttles via an on-disk
.last-drain stamp (30s) so Kiro agentSpawn / OpenCode idle re-fires can't
attempt a network drain every prompt during an outage; reaps moved inside.
(2) ShouldSpawnAfter spawns only on Posted/Spooled — AuthLapsed spools
nothing, so spawning would orphan a session with a dropped SessionStarted.
(3) Documented the fresh-client deviation (no reusable vendor client exists
at the pre-POST drain point).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: Codex spawn-before-post with stdout-first carve-out
Auth lapse now spools + spawns via PostOrSpoolAsync/ShouldSpawnAfter
(matching Kiro/OpenCode/Pi/Copilot) instead of dropping the session and
skipping the watcher. The global lifecycle/transcript spool drain runs
fire-and-forget AFTER Codex's blocking `{"continue":true}` stdout
handshake — never before, never gating it — so a large/unreachable spool
backlog can't stall the parent process. Extracted WriteSessionScopedOutput
and RunSessionStartHandshakeForTest as the seam CodexStdoutContractTests
uses to prove the ordering without a live server.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: Antigravity spawn-before-post + Gemini awaited EnsureWatcherRunning
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: Gemini session-start spawn-before-post + gated spool drain
Migrate Gemini's session-start off the POST-only PostAsync path to
AgentHookPoster.PostOrSpoolAsync + ShouldSpawnAfter, so an auth lapse or
transient outage spools the SessionStarted payload and STILL spawns the
watcher (was: returned without reaching EnsureWatcherRunning on anything
but Posted). Wire the throttled DrainSpoolsAsync gated to the two lifecycle
events (SessionStart/SessionEnd) so the per-turn Notification path adds no
network cost. Fix a dangling "PostHookAsync (below)" comment in
AntigravityHookCommand left by the Task 6 deletion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: shutdown final-line completion signal (newline/parseable-JSON)
The shutdown-during-outage final drain (idle-timeout / parent-exit) used
to disable the half-written-line holdback unconditionally, which could
consume a line the agent was still mid-write on, or drop a complete
newline-less final line. Add a pure IsFinalLineComplete signal (empty /
newline-terminated / last line parses as JSON — length-stability alone is
NOT proof, since a large write can pause mid-record past any bounded
window) plus a bounded (<=2s) WaitForFinalLineCompletionAsync wait in the
final-drain path. Complete -> send the newline-less final line as before;
incomplete -> keep the holdback on (never send-and-advance a truncated
line) and flag the session needs-import via TranscriptSpool so `kcap
import` can recover it later.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: close final-drain completeness TOCTOU; re-validate at consume time
Review found the completeness decision and the consuming read were two
separate reads: WaitForFinalLineCompletionAsync probed once, then
DrainNewLines consumed with holdback disabled — so a final line that
resumed growing in the gap was sent-and-advanced anyway, violating "never
consume a still-growing line."
Move the decision into the consuming read via an IncompleteFinalLinePolicy
(Hold / ConsumeIfComplete). Under ConsumeIfComplete the unterminated final
line is consumed ONLY IF the exact bytes read parse as a complete JSON
record; otherwise it is held and NewTranscriptLines.HeldIncompleteFinalLine
is set. The parse check runs on the same bytes being consumed, so there is
no TOCTOU with the bounded pre-wait (now purely advisory — gives the writer
time). RunWatch flags needs-import off the actual consume-time held result
via state.FinalDrainHeldIncompleteLine.
Also updates the stale ApplyPartialLineHoldback comment that claimed the
final drain opts out of holdback because "the file is static then" — the
exact wrong assumption this task fixes.
Tests: SplitNewCompleteLines + ReadNewCompleteLinesAsync gain parseable-
consume / unparseable-held cases, HeldIncompleteFinalLine parity, and a
grew-after-the-completeness-check TOCTOU guard asserting the resumed
partial is held, not sent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: spool undelivered transcript tail on shutdown-during-outage
At final drain during idle-timeout/parent-exit shutdown, DrainNewLines only
advances LinesProcessed past lines the hub confirmed sent. If the hub is
down (or still reconnecting) at that point, any transcript lines from
LinesProcessed to EOF were never delivered and the process exits right
after — silently dropping the tail.
Adds BuildTranscriptSpoolBatch (pure TranscriptBatch JSON builder) and
SpoolUndeliveredTranscriptTailAsync, which re-reads the undelivered tail
using the same ConsumeIfComplete completion decision as the final drain
and spools it into the dedicated TranscriptSpool (task 2) so the global
drain (task 3) replays it after recovery, without a manual `kcap import`.
Cap exhaustion still marks the session needs-import rather than dropping.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: spool tail unconditionally + redact secrets (task-8 review)
Two Critical review findings on the shutdown-during-outage tail spool:
1. Insufficient trigger → silent drop. Gating the spool on
hubConnection.State != Connected missed a HubException thrown by
SendTranscriptBatch2 while the connection stayed Connected (the generic
catch does not change connection state) — the tail was undelivered but
never spooled. Call SpoolUndeliveredTranscriptTailAsync unconditionally
on the shutdown path; it is already a no-op when position == EOF.
2. SECURITY: spooled tail skipped secret redaction. The live/inline drains
run lines through SecretRedactor.RedactLine, but the spooled tail was
written raw → secrets on disk and POSTed unredacted on replay. Redact
each spooled line exactly as the live drain does before writing.
Tests: undelivered_tail_spooled_even_though_connection_stayed_up (state-
agnostic spooling) and spooled_tail_is_secret_redacted (ghp_ token →
[REDACTED], raw secret absent from disk).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: watcher heartbeat + lock-guarded staleness reap+respawn
AI-1357 task 9. WatcherManager.IsWatcherAlive only checked the PID, so a
wedged (hung-but-alive) watcher was never restarted. The watcher now
touches a per-key heartbeat file every main-loop iteration — including
no-content drains and while disconnected/reconnecting — via the pure
WatcherHeartbeat helper (Core). IsWatcherAlive requires both a live PID
and a non-stale heartbeat (past a 30s startup grace, 20s threshold).
EnsureWatcherRunning reaps a wedged watcher (kill + respawn) under a
cross-platform spawn lock — same FileShare.None/flock primitive as
DaemonLock — so concurrent hooks racing the same key can't double-spawn.
The whole decide-and-spawn sequence is locked, not just the reap: killing
the old watcher deletes its pid file before the respawn's new one lands,
so guarding only the reap step would leave a window where a second hook
sees "no pid" and spawns unguarded (caught by the new concurrency test).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: keep heartbeat fresh during connect-retry + purge watcher sidecar files
AI-1357 task 9 review. Two Important issues.
1. Startup/reconnect outage looked "wedged" → false reap. The SignalR
connect-retry loop never touched the heartbeat, so a server outage
>=~50s at startup was indistinguishable from a wedged loop and the
hook probe would repeatedly reap+respawn a healthy-but-reconnecting
watcher. RunWatch now touches the heartbeat at the top of every
connect-retry iteration and waits via DelayWithHeartbeatAsync, which
chunks the backoff (grows to 30s > the 20s threshold) into <=5s slices
through the new pure HeartbeatSlices helper, touching before each — so
no wait window can ever cross the staleness threshold. The automatic-
reconnect path was already covered by the main loop's per-iteration
touch in the disconnected branch.
2. Sidecar file leak. Only .pid was deleted. KillWatcher now removes the
{key}.heartbeat and {key}.started markers, but deliberately NOT the
{key}.spawnlock: KillWatcher runs from inside WithSpawnLock on the reap
path, and unlinking a held lock file on POSIX lets a racing hook open a
fresh non-conflicting flock (the DaemonLock unlink-race). Spawn locks
are swept by the new WatcherManager.PurgeAuxiliaryFiles(), called by
kcap cleanup (holds no lock; also mops up orphans). CleanupCommand now
uses GetWatcherDir() so it honors KCAP_WATCHER_DIR.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: Kiro live usage-backfill synthetic lines (credits/context%)
AI-1357 task 10: the live kcap watch path never emitted Kiro's per-turn
credits/context% (the AI-1196 hedge only fires on import, which reads the
sibling {id}.json up front). A live drain has usually already sent the
turn's anchor AssistantMessage line by the time Kiro flushes that sidecar,
so inline enrichment can't reach it there.
Add a synthetic KiroUsageBackfilled line per turn anchor instead, mirroring
the Antigravity synthetic-USAGE-line pattern: WatchCommand.
BuildKiroUsageBackfillLine builds the JSONL; AppendKiroUsageBackfillLines
reads the sidecar via KiroUsage.AnchorMap and appends one line per anchor
not yet in the new WatchState.KiroUsageEmittedAnchors, staging them on
KiroUsagePendingAnchors for the caller to commit only after a successful
send (so a failed batch re-reads and re-stages instead of losing them).
Server-side event fold is a separate follow-up (task 13).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: Cursor live subagent split via hook/backfill path (AI-1151)
CursorSubagentCorrelator only ran on the historical import path, so a live
Cursor Task/Agent subagent was ingested as its own top-level session instead
of nesting under its parent. Cursor is not watcher-backed (CursorHookCommand
backfills each session's transcript over HTTP as hooks arrive), so the
correlation now runs inline in that per-hook dispatcher via a new thin
wrapper, CursorLiveSubagentLinker.
At a child's sessionStart, ResolveParent (reusing
CursorSubagentCorrelator.Correlate) checks the sibling transcripts under the
same agent-transcripts workspace dir; a match is persisted to a small
on-disk marker (the CLI is a fresh process per hook) and diverts subsequent
hooks for that session: subagent-start replaces the top-level sessionStart,
transcript backfill is routed under the parent with agent_id=child (mirrors
CursorImportSource.SendSubagentLifecycleAsync's watermark + POST shape), and
subagent-stop replaces sessionEnd. Mid-lifecycle hooks
(beforeSubmitPrompt/afterAgentThought/telemetry) are not forwarded for a
linked child, matching the import path (which has no side channel for them
either) instead of writing to a phantom AgentSession stream that never got a
SessionStarted.
Dashless ids are used throughout so a live-then-import of the same session
converges on the same AgentSubsession-{parent}-{child} stream rather than
duplicating it (ties to AI-1358 A1). Known eventual-consistency gap
(documented in code): if the parent's Task tool_use isn't yet flushed to
disk at the child's first hook, the child is temporarily ingested top-level
until a later `kcap import --cursor` converges it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: enforce start-before-stop spool ordering on Cursor live subagent path (AI-1151)
HandleSubagentChildEventAsync posted subagent-stop (and ran the sessionStart
backfill) with no spool.HasBacklog check, unlike the sibling top-level path.
Under a transient failure that left a subagent-start undelivered in the spool,
a later subagent-stop could be POSTed ahead of its own subagent-start, leaving
the AgentSubsession stream mis-ordered / never opened.
Mirror the top-level guard: after the HandleCore drain, if the child still has
spool backlog (drain hit a transient failure), spool the fresh lifecycle event
behind it and skip the agent-routed backfill so the next hook re-drains
start-first. Also gate the sessionStart backfill on the subagent-start POST
succeeding, so the transcript can't be routed to an AgentSubsession the
now-spooled start hasn't opened yet.
Adds a regression test: a child whose subagent-start is spooled (transient
failure) does not get subagent-stop delivered ahead of it, and a later
recovered drain delivers start before stop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: wire global spool-drain pass into hook entry + daemon-periodic
AI-1357 Task 12. Runs LifecycleSpoolDrain centrally in Program.cs's
`case "hook":` (before dispatch, non-Codex; throttled via the existing
AgentHookPoster.DrainSpoolsAsync) instead of per-vendor, and adds a 60s
daemon-periodic sweep (SpoolDrainLoop) for backlogs no later hook process
ever touches (Kiro/OpenCode watcher-owned session-end, GUI idle/parent-exit).
Moves HookSpool/TranscriptSpool/LifecycleSpoolDrain to Capacitor.Cli.Core so
the daemon can share them without referencing the CLI's exe project.
Resolves three ordering hazards surfaced by this wiring (each covered by a
new test): the ordered drain's withheld temp files now use a distinct
".ordered-*" namespace so Claude/Cursor's unrelated FIFO drain can never
cross-consume them (BLOCKER-1); the generic drain now fires the
generate_whats_done side effect on any vendor's session-end, not just
Claude's own poster (BLOCKER-2); and a session whose session-end was already
delivered is durably marked ended so a later straggler entry is dropped
instead of replayed out of order (BLOCKER-3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: Claude ordering guard must see the .ordered-* backlog namespace
AI-1357 Task 12 review fix. ClaudeHookCommand.CurrentSessionHasBacklog was a
stale private duplicate that only checked {sid}.jsonl + {sid}.*.draining, not
the .ordered-* namespace this task introduced. Since the centralized ordered
drain now runs on every non-Codex invocation (incl. --claude), a Claude
session-end withheld in .ordered-* pending the transcript tail was invisible
to the guard, so a later Claude subagent-stop for the same session posted
directly — ahead of the still-withheld session-end (the exact BLOCKER-1/3
cross-spool ordering violation).
Delegate CurrentSessionHasBacklog to the public HookSpool.HasBacklog (which
covers all three namespaces), matching CursorHookCommand. Add a test proving a
subagent-stop spools behind a session-end withheld in .ordered-*.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: AI-1357 CLI acceptance tests (spawn-before-post, drain order, heartbeat)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* AI-1358: add import origin marker constant (CLI)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* AI-1358: shared guarded discovery helper (A4)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* AI-1358: adopt guarded discovery in all import sources (A4)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* AI-1358: restore flat discovery scope for Kiro/Gemini (A4 fix)
GuardedDiscovery.EnumerateFiles gains a recursive flag (default true); the
two originally-TopDirectoryOnly call sites pass recursive:false so A4 adds
symlink/cycle/inaccessible/per-entry safety without widening discovery scope.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* AI-1358: OpenCode ended_at null/0 not 1970 (A3)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* AI-1358: per-vendor ended_at resolvers (A3)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* AI-1358: confirm/extend Codex ExtractLastTimestamp shape (A3, open Q2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* AI-1358: Claude/Codex resume end-only reassert + fail-closed tail (A2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* AI-1358: routed sources send historical-import origin marker (A1)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* AI-1358: Cursor import no stale PR + per-cwd repo cache (item 5)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* AI-1358: Antigravity import usage pass, USAGE-before-end, all classifications (item 7)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Task 7: CLI phase-0 append-only harness + runtime rewrite guard + quarantine (D0)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Task 8: CLI side-effect barrier + hook heartbeat (D1)
Adds CursorMarkers.CreateBarrier/BarrierPending/ClearBarrier (thin wrappers
over WatcherHeartbeat's atomic timestamp read/write) and TouchHeartbeat.
CursorHookCommand.HandleCore now touches the per-session heartbeat on every
invocation carrying a session id (including telemetry-only hooks), creates
the barrier before beforeSubmitPrompt's own POST (clearing it on a 2xx from
either that live POST or a later hook-spool drain delivery of the same
spooled entry), and relies on the existing spool-drain-before-transcript-
drain ordering for sessionEnd (already correct — no reorder needed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Task 9: CLI per-session Cursor watcher spawn, precedence-ordered (D1)
Adds "cursor" to WatchCommand.KnownVendors and the watch --vendor usage
string. CursorHookCommand gains the pure ShouldSpawnWatcher(eventName,
isSubagentChild) precedence predicate (terminal hooks never spawn;
a correlated subagent child never spawns a top-level watcher — routed via
the gated parent-child key in a later task) and MaybeSpawnWatcherAsync,
which additionally gates on the quarantine marker and a non-empty
transcript path before calling WatcherManager.EnsureWatcherRunning(vendor:
"cursor"). Wired into HandleCore: sessionStart spawns before its POST
(reusing the workspace_roots-derived cwd already computed for repository
enrichment); every other non-terminal hook spawns only after its own
lifecycle POST has succeeded (recovery spawn).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Task 10: CLI hook-backfill hardening — shared reader, Hold/ConsumeIfComplete, barrier+quarantine-aware (D2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Task 11: CLI watcher exit conditions + acked-batch cursor (D1/D3)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Task 12: CLI child subagent watchers gated on acked subagent-start
HandleSubagentChildEventAsync now spawns the child (subagent) Cursor
watcher — key `{parentSessionId}-{childSessionId}`, tailing the child's
own transcript file, agentId=childSessionId/sessionIdOverride=parentSessionId
— only once the diverted subagent-start POST is acknowledged (2xx).
A spooled start (POST failure) defers the spawn entirely: no code path
spawns a child watcher for an unacked start, preserving the invariant
that no child transcript line reaches the server before SubagentStarted
is appended.
The deferred half is wired into HandleCore's generic top-of-method spool
drain (which runs before the isSubagentChild divert, keyed on the same
childSessionId): when it redelivers a previously-spooled subagent-start
entry and gets a 2xx, it parses the parent/child/transcript-path triple
back out of the entry's own payload and performs the spawn then — the
"later invocation whose spool drain delivers the start" the design calls
for. Quarantine is checked on the parent session id, matching how
CursorRewriteGuard/WatchCommand.RunWatch resolve their own guard identity
for a child watcher process (sessionId = sessionIdOverride ?? key).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Task 13: end-to-end acceptance tests + docs (CLI half)
Adds CursorTailingWatcherTests.cs covering the plan's four acceptance
scenarios from the CLI side: force-quit mid-turn (a transcript's final
line, complete but not yet newline-terminated, is held by every live
drain and only consumed by the shutdown final drain); idle-ceiling exit
without Cursor synthesizing its own session-end (extracts the pure
CursorSuppressesEndPost(vendor, idleExit) helper out of RunWatch's
inline check so the suppression decision is independently testable,
contrasted against Codex which does NOT suppress); and reactivation via
BOTH a sessionStart resume and a non-sessionStart resume hook, driven
through the real CursorHookCommand.HandleCore dispatcher rather than
the bare ShouldSpawnWatcher predicate. "Sweep closes" is server-side
(already proven by Task 5's StaleActiveSessionReaperTests) and is out
of this repo's visibility — noted in the test file's doc comment rather
than re-derived.
Also documents the watcher-backed Cursor capture (hooks retained as
belt-and-braces, child-watcher acked-start gating, force-quit/idle-
ceiling/reactivation behavior, the runtime rewrite guard) and the
KCAP_CURSOR_IDLE_CEILING_MINUTES knob in README.md, plus
cursor-verify-appendonly as the phase-0 diagnostic.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Review fix: rewrite-guard shrink/full-prefix wiring, acked-byte checkpoint, heartbeat-aware idle ceiling, delivery-boundary re-checks
Addresses findings #2, #3, #6, and the watcher-side half of #8/#1 from
kcap-cli PR #324 review:
- #2: CursorRewriteGuard.VerifyFullPrefix existed but was never called from
the poll loop; wire it in on a periodic cadence (CursorFullPrefixVerifyEveryNPolls).
Also add VerifyNotShrunk — the guard's zone checks were previously gated
entirely behind "did the file grow", so a shrink or in-place same-length
rewrite slipped through undetected.
- #3: checkpoint the Cursor byte cursor using the SAME capped snapshot length
ReadNewCompleteLinesAsync sampled (NewTranscriptLines.SnapshotByteLength),
not a fresh FileInfo.Length re-sample racing a concurrent append. Advance
the checkpoint only as far as the server's acked LINE count actually
covers (ByteOffsetForAckedLines), not the full capped range, so a
partially-disposed D3 batch never has its unacked tail checkpointed as
delivered.
- #6: the Cursor idle clock (ShouldEndOnIdle) is now the later of transcript
activity and the hook heartbeat mtime (ResolveCursorIdleClock), and child
(subagent) watchers are idle-ceiling eligible without requiring
ThresholdReached, which they never set.
- #8 (watcher half): re-check the quarantine/barrier markers immediately
before SendTranscriptBatchAcked, not only at the top of the poll.
- #1 (backfill half): CursorTranscriptBackfill re-checks quarantine/barrier
immediately before the POST, closing the same race window on that path.
DrainNewLines is now internal (was private) so the guard wiring is directly
regression-testable without a live SignalR server — every path exercised
trips before ever touching the HubConnection argument.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Review fix: recovery spawn withheld on spool backlog; dropped subagent-start permanently gates child transcript delivery
Addresses findings #4 and #5 from kcap-cli PR #324 review:
- #4: capture whether the session still has spool backlog AFTER the
generic top-of-method drain attempt, and require no remaining backlog
before the recovery-spawn watcher call — a telemetry-only mapping
(SpoolOnFailure=false) previously reached the spawn regardless of
whether an earlier canonical event (e.g. sessionStart) was still stuck
undelivered.
- #5: add a durable per-child subagent-start-acknowledgement marker
(CursorMarkers.MarkSubagentStartAcked/HasSubagentStartAck), written the
moment a 2xx is observed (live POST or a later spool-drain delivery).
HandleSubagentChildEventAsync now gates ALL non-start hooks (content-less
backfill and the child's own subagent-stop) on this marker instead of on
"no spool backlog" — a subagent-start that hits a non-transient 4xx on
retry is permanently Dropped from the spool (HasBacklog goes false) even
though no AgentSubsession stream was ever opened server-side; without the
marker that emptied backlog let child transcript content flow ungated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Review fix: spool-replay enforces Cursor quarantine; historical import skips quarantined sessions (incl. correlated children)
Addresses the replay half of finding #1 and finding #7 from kcap-cli PR #324
review:
- #1 (replay half): LifecycleSpoolDrain's transcript poster now parses the
batch's vendor/session_id and drops (permanently discards) a Cursor batch
whose session is quarantined, and treats a pending side-effect barrier as
transient (retry later). This is the only delivery-time check the
shutdown-spool-replay path has — a batch queued before a runtime
rewrite-guard trip could otherwise still be replayed later.
- #7: CursorImportSource.ClassifyAsync now skips (ProbeError) any session
whose quarantine IDENTITY is marked — resolved via the already-computed
subagentLinks map so a correlated child is filtered under its PARENT's
quarantine marker (CursorRewriteGuard is always keyed on the family/parent
id for a spawned child watcher), not its own id. Previously `kcap import`
had no awareness of the marker at all and could feed the exact corrupted
line-number source the guard exists to shut off.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Review fix (r2): Cursor top-level watcher skips the below-threshold buffer
Cursor's own sessionStart hook posts (and spawns this very watcher) before any
transcript line is ever read, exactly like Antigravity's pre-spawn POST — so the
generic 10-line below-threshold buffer must not apply either. Before this fix a
top-level Cursor watcher re-added its still-unread lines to BufferedLines every
poll (the line cursor never advances while buffering) until they eventually
flushed as duplicates, and a watcher that force-quit before crossing the
artificial threshold skipped its final drain and shutdown spool, and was
permanently ineligible for the Cursor idle ceiling. Extracted into a pure,
testable SkipsThresholdBuffering predicate; child watchers are unaffected
(they never buffer regardless).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Review fix (r2): bind the Cursor rewrite guard to the bytes it actually sends
Three related TOCTOU-closing fixes to the D0 rewrite guard, all stemming from the
same theme: the guard's hash must be bound to the exact bytes actually
processed/sent, and its baseline must be real from the start.
1. Bind-to-sent-bytes (finding #1): ReadNewCompleteLinesAsync now optionally
(captureRawBytes: true, wired for vendor=="cursor") captures the raw byte
buffer of the SAME capped read that decodes the batch's lines
(NewTranscriptLines.SnapshotBytes). DrainNewLines's guard block now hashes
directly from that snapshot instead of reopening the file separately to
record the new-range/prior-zone hashes — closing the window where a rewrite
landing between the decode read and the old reopen produced a hash for bytes
the batch never actually came from. The post-ack checkpoint's trailing hash
is now derived from the same already-verified snapshot instead of reopening
the file after the RPC returns (a rewrite in flight during the ack could
otherwise be blessed as the new baseline).
2. Reconnect rewind atomicity (finding #2): a reconnect discovering the server
is behind the client now rewinds state.CursorByteOffset and the guard's
checkpoint ATOMICALLY with the line cursor, via the extracted
ApplyReconnectRewindAsync (testable without a live SignalR reconnect). The
true byte offset of the rewound line is resolved by scanning the transcript
(ResolveByteOffsetForLineAsync) rather than leaving the byte checkpoint at
the later, too-far-ahead offset (which left the replayed line gap's
new-range verification starting past the bytes it actually occupies).
3. Real full-prefix baseline (finding #3): the periodic full-prefix re-hash now
seeds on the REAL first poll (not just lazily on the guard's own first
VerifyFullPrefix call, which previously never happened before poll N) — a
same-length rewrite of an already-checkpointed middle region landing in
polls 1..N-1 now has a real baseline to be caught against at poll N, instead
of poll N seeding the already-rewritten file as if it were the original.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Review fix (r2): later nonterminal child hooks self-heal a dead watcher
Once a child's subagent-start is durably acked, every LATER NONTERMINAL Cursor
hook now also attempts to (re)spawn its child watcher, not just the child's own
sessionStart. Before this fix, only sessionStart ever called
MaybeSpawnChildWatcherAsync, so a child watcher that later exited (the
newly-enabled idle ceiling), crashed, or never actually spawned (e.g. its acked
sessionStart carried no transcript path) was never restarted. Retains the
terminal no-spawn rule for sessionEnd. EnsureWatcherRunning is idempotent
(PID+heartbeat check), so this is a cheap no-op once the watcher is alive.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Review fix (r2): historical import re-checks quarantine at delivery, resolves family via the live marker
1. Delivery-boundary quarantine check (finding #6): ImportSessionAsync now
re-checks CursorMarkers.IsQuarantined FRESH, before ANY lifecycle/transcript
delivery — previously it had no quarantine check at all, so a session
quarantined by the live watcher's runtime rewrite guard AFTER
ClassifyAsync (during repo probing, an interactive confirmation prompt, or
simply queueing behind other sessions in the same import run) would still
post session-start, every corrupted line, children, and session-end. A
second check right before the transcript boundary catches a guard trip
during the sessionStart POST itself: no transcript content is sent, but the
already-server-side-created session is still best-effort closed with
session-end so it doesn't hang open forever.
2. Family-identity fallback via the live marker (finding #7): subagentLinks is
only ever computed from the sessions ONE import batch discovered — a
`--session <child>` filter (or an inaccessible/omitted parent transcript)
excludes the parent entirely, so the in-batch correlator can't produce the
family link, and the CHILD's own (unquarantined) id was checked instead of
its family's. ResolveQuarantineIdentity now falls back to the persisted
CursorLiveSubagentLinker marker (written independently by the live hook
dispatcher), resolved once at classify time and stamped onto SourceMeta as
QuarantineIdentity so ImportSessionAsync's fresh re-check (above) uses the
same family identity.
Regression test note: several existing tests in CursorImportSourceTests share
hardcoded session ids across ClassifyAsync/ImportSessionAsync calls, backed by
CursorMarkers' real (non-injectable) on-disk quarantine marker; ImportSessionAsync
now also reading that marker widened a latent parallel-execution race, so the
class is marked [NotInParallel] (mirrors the existing MachineIdFileTests pattern).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Review fix (r3): serialize reconnect rewind with drain, seed initial-resume byte offset, bound rewrite-guard capture to the new range
Three P1 follow-on findings from the round-2 guard-byte-binding rework (db65d93):
1. WatchCommand.RunWatch's Reconnected handler and main polling loop both mutated
WatchState/CursorRewriteGuard once the hub reconnected, with no synchronization between
them — a reconnect rewind's three writes (byte offset, checkpoint reset, line cursor)
could interleave with a concurrently-running drain, recreating the byte/line-frontier
divergence the r2 fix was meant to remove. Serialized both under a single
SemaphoreSlim(1, 1) (cursorRewindGate, Cursor-only) via two new directly-testable helpers,
GatedApplyReconnectRewindAsync/GatedDrainNewLinesAsync.
2. The INITIAL WatcherConnect registration only ever assigned state.LinesProcessed on
resume; CursorByteOffset stayed at its default (0), so a watcher resuming at server line N
mapped acked-line counts relative to N but their bytes from 0 — a permanent, silent
line/byte-frontier misalignment. Extracted the reconnect-rewind's own byte-seeding logic
into a shared SeedCursorByteOffsetAsync helper, called from both the reconnect path and
the initial registration.
3. The Cursor watcher's captureRawBytes read materialized a buffer the size of the WHOLE
file on every poll, including idle one-second polls with nothing new — unbounded LOH churn
for large transcripts. ReadNewCompleteLinesAsync now accepts rawBytesReadFrom/
newRangeByteOffset so DrainNewLines can request a BOUNDED read (the guard's own small
trailing-tail zone plus whatever's actually new) on every poll except the rare periodic
full-prefix cadence, which still needs — and gets — the whole file. CursorRewriteGuard's
HashPriorZone gained an explicit snapshot-start-offset parameter so its window clips
correctly against a non-zero-based buffer.
Regression tests: gate-composition tests proving a drain/rewind cannot observe a half-applied
counterpart while the gate is held; SeedCursorByteOffsetAsync unit + composition tests proving
a full ack of M resumed lines checkpoints at N+M, not M; bounded-capture tests proving the
buffer is sized to the new range (not file length), an idle poll allocates ~0 bytes, and a
large-file rewrite is still caught via the bounded path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Review fix (r3): re-check quarantine during multi-batch import delivery
SessionImporter.SendTranscriptBatches streamed the mutable transcript file and posted one
request per 100 lines with no quarantine check at all inside the loop — the round-2 fix
only re-checked immediately before calling it. A quarantine written by the live watcher's
runtime rewrite guard AFTER the first transcript POST still let every remaining batch post.
SendTranscriptBatches now accepts an optional abortDelivery predicate, checked immediately
before every batch POST (including the first); a trip throws the new
TranscriptDeliveryAbortedException without posting the pending batch. CursorImportSource
threads its already-resolved quarantineIdentity into both the parent's own transcript send
and each subagent child's, so a quarantine trip aborts remaining batches for either — no
extra correlator work per batch. The parent's catch block reacts to the abort the same way
as the two existing quarantine boundary checks: best-effort session-end so the session
doesn't hang open "active" forever, then Failed so a re-run hits the pre-flight check and
cleanly Skips from then on.
Regression test: a 150-line transcript (two 100/50-line batches) with the quarantine marker
written the instant the first batch lands proves the second batch is never posted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Review fix (r4): keep byte/line frontier in lockstep across polls, cadence, and resume
Finding #1 - a poll that consumed only blank/whitespace lines advanced
state.LinesProcessed without advancing state.CursorByteOffset (or the
rewrite guard's checkpoint) to match. The next poll's bounded decoder then
re-read the same already-"processed" blank bytes but seeded its line
numbering from the already-advanced LinesProcessed, inflating the line
count on every idle poll and mis-mapping the next real batch's ack byte
frontier. DrainNewLines' "no content, no repo change" branch now maps the
same blank-line count to its true byte offset (ByteOffsetForAckedLines,
mirroring the ack path) and re-checkpoints the guard, so a truly idle
follow-up poll decodes nothing new.
Finding #4 - CursorGuardPollCount (the periodic full-prefix cadence
counter) incremented unconditionally before the guarded file was even
opened. A transient IOException on that read still consumed the cadence
slot, so the next successful poll silently skipped its due full-prefix
baseline. The cadence decision is now peeked without mutating state, and
only committed once the guarded read actually completes without throwing.
Finding #5 - ResolveByteOffsetForLineAsync silently clamped to EOF when
the server's acknowledged resume line exceeded the local transcript's
line count (a transcript truncated/replaced while the watcher was
offline), and SeedCursorByteOffsetAsync/ApplyReconnectRewindAsync treated
that clamp as a valid baseline — seeding the rewrite guard against the
wrong (truncated) file while the line cursor advanced past it. Both
methods now return a typed "could not resolve exactly" result; the seed
path quarantines the session instead of seeding a bogus baseline, and
both the initial WatcherConnect resume and reconnect-rewind call sites
exit (cts.Cancel()) rather than proceed on an unresolved rewind.
Regression tests (CursorGuardWiringTests, CursorReconnectRewindTests): a
blank-only poll advances both frontiers together and a subsequent
genuinely-idle poll re-decodes nothing; a guarded read that throws
IOException never consumes the full-prefix cadence; a resume/reconnect
frontier beyond the local transcript's line count quarantines instead of
seeding a clamped baseline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Review fix (r4): propagate delivery-abort quarantine through the historical import's close-and-fail path
Finding #2 - SendSubagentLifecycleAsync's catch-all swallowed the typed
TranscriptDeliveryAbortedException into a bare `false`, so a quarantine
tripped during a CHILD's own transcript delivery made ImportSessionAsync
return Failed WITHOUT ever posting the parent's best-effort session-end -
even though the child's subagent-start had already landed, leaving the
parent/subsession stuck Active forever (the quarantine marker makes the
next run Skip at preflight instead of repairing it). The child lifecycle
helper now (a) checks quarantine BEFORE posting a new child's
subagent-start, so a family found quarantined never starts another child,
and (b) rethrows TranscriptDeliveryAbortedException instead of catching
it, so the parent's child loop (now wrapped the same way the parent's own
transcript delivery already was) routes it through the shared
CloseAndFailAsync best-effort session-end + Failed contract.
Finding #3 - SendTranscriptBatches only re-checked abortDelivery BEFORE
each batch POST. A transcript that fits in a single (or final) batch -
including every transcript of <=100 lines - has no "next" batch to gate,
so a quarantine marker written while that one-and-only POST was in flight
was never observed anywhere; the method returned normally and the caller
proceeded into child lifecycle / normal completion. abortDelivery is now
also re-checked immediately AFTER every POST (mid-loop and the trailing
batch), closing that window.
Regression tests: a quarantine trip during a child's own transcript
delivery closes the parent via best-effort session-end + Failed; a later
child is never started once the family is found quarantined (even when
an earlier child's own delivery was clean); a quarantine marker appearing
during a single-batch (<=100 line) transcript's only POST is observed and
routed through close-and-fail, at both the SessionImporter.
SendTranscriptBatches level and the CursorImportSource.ImportSessionAsync
level.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Review fix (r4): lockstep byte frontier in the repo-only-failed drain branch too
The "repo-only batch failed" catch branch in DrainNewLines advanced
state.LinesProcessed past blank/whitespace-only lines without advancing
state.CursorByteOffset (or the rewrite guard's checkpoint) to match -
the same byte/line-frontier drift finding #1 fixed for the "no content,
no repo change" early return, on the other path that moves the line
cursor without an acked send. This branch is reached only when a repo
change was pending (repoToSend != null) and the repo-only RPC threw with
newLines.Count == 0 (any lines read were blank); left unfixed, the next
poll's bounded decoder re-read those same blank bytes under an inflated
line number.
Extracted the finding-#1 lockstep logic into a shared local function
(AdvanceCursorBlankByteFrontierInLockstep) so both call sites - the early
return and this catch branch - map the consumed blank-line count to its
true byte offset via ByteOffsetForAckedLines (fed from the already-captured
cursorGuardSnapshot) and re-checkpoint the guard byte-for-byte identically.
Regression test: a blank-only poll with a pending repo change whose
repo-only send fails advances byte AND line frontier together, and a
subsequent idle poll (repo change still pending, still failing) re-decodes
nothing / does not inflate the line number.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Review fix (r5): resolve a complete unterminated final line at EOF instead of quarantining
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Review fix (r6): rewind unterminated-final-line resume so an appended terminator keeps line numbering
The r5 fix seeded CursorByteOffset at EOF while leaving LinesProcessed at
the already-acked final record's own line count. When Cursor later appended
that record's terminating newline before its next record (Cursor's normal
write order), the bounded reader started reading exactly at that EOF and
misread the leading '\n' as closing a phantom empty line — because it seeds
its own line index from LinesProcessed, already past that record. Every
following line then landed one number too high, permanently: the server,
still waiting at the true frontier, saw a persistent gap while the watcher
kept resending from the stale offset.
ResolveByteOffsetForLineAsync now returns a (ByteOffset, LineNumber) pair;
the complete-unterminated-final-record case rewinds to the record's own
start paired with LineNumber - 1 instead of EOF paired with LineNumber
unchanged, so the record is re-read/re-sent next poll — harmless, since
Cursor's normalizer emits deterministic event ids and the server's
source-ack frontier dedupes a resend at/behind it. SeedCursorByteOffsetAsync
now assigns both halves of the pair together (and, for every vendor, owns
WatchState.LinesProcessed directly rather than leaving it to two separate
call sites), keeping the byte/line frontier in lockstep from one source of
truth.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Qodo fix: make CursorMarkers.Quarantine fail-open on write failure
Quarantine() did unguarded filesystem writes/moves with no exception handling,
but it's called from CursorRewriteGuard.Reject deep in the watcher's drain
loop, which has no broad exception handler above it (DrainNewLines only
catches IOException/OperationCanceledException). A non-IOException failure
(e.g. UnauthorizedAccessException) would escape and crash the watcher instead
of letting the caller's Verify* return value cleanly stop delivery and exit.
Wraps the write in a try/catch, logs to stderr, and never throws — matching
IsQuarantined/ReadMarker's existing fail-open contract. Also trims the
class-level doc comment per repo comment-verbosity guidelines.
Adds a regression test that occupies the marker's own file path with a
directory (so the final rename fails) and asserts Quarantine doesn't throw.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1382] Qodo fix: trim over-verbose Cursor watcher comments
CursorRewriteGuard's class doc and a few method docs, plus one
CursorHookCommand block, ran to multi-paragraph design-doc-style prose.
Condenses them to the essential invariant/precondition, keeping the subtle
correctness notes (TOCTOU binding, checkpoint-reset rationale) but dropping
narrative. Also strips AI-#### Linear identifiers from code comments per repo
guideline (CLAUDE.md: "DO NOT use Linear issue numbers in comments").
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
alexeyzimarev
pushed a commit
that referenced
this pull request
Jul 18, 2026
* Fix Kiro parent-PID watchdog name match (kiro-cli)
Adds a bounded `-cli` suffix tolerance to MatchesAgentName so the by-name
ancestry walk identifies the durable `kiro-cli` process for vendor `kiro`,
instead of falling back to the fragile getpgrp/getppid heuristic.
Refs AI-1359.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Freeze the watcher idle clock while disconnected
Subtracts SignalR-outage time (accrued since last activity) from the idle
measure so a transient disconnect can't false-idle-end a Codex/Antigravity
session, while repeated reconnects with no new lines still idle-end after
the connected budget.
Refs AI-1359.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add staged parent-dead / wedged-watcher recovery
On ParentAlreadyDead the watcher now periodically re-resolves + re-arms the
parent-exit watchdog (using the vendor process-name alias); it ends the
session only after a long, configurable ceiling with no transcript progress
and continued resolution failure, and any new progress resets the window.
This is the only end path for a wedged, alive-but-connected watcher that the
server stale sweep can't see. New env var KCAP_PARENT_DEAD_CEILING_MINUTES.
Refs AI-1359.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: AgentHookPoster.Spooled + PostOrSpoolAsync spool-on-lapse
Adds a Spooled outcome and PostOrSpoolAsync: on lapsed-auth or transient
(5xx/408/429/unreachable) failure the lifecycle payload is durably spooled
(HookSpool) for a later drain pass, so live capture can start regardless of
hook-POST delivery (spawn-before-post). Refs AI-1357.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: TranscriptSpool — bounded, no-drop, needs-import marker
AI-1357 Task 2: dedicated on-disk spool for undelivered transcript-tail
batches captured during a watcher outage. Unlike HookSpool (1 MB,
drop-oldest — fine for small lifecycle POSTs), this is bounded at 8 MB
per session with NO SILENT DROP: on cap exhaustion it stops appending
and writes a needs-import marker instead of truncating history, so the
session surfaces as requiring `kcap import` rather than silently losing
transcript content.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: TranscriptSpool no silent drop on I/O failure + Ignored result
Review fixes for AI-1357 Task 2:
- Append now marks needs-import (not phantom Appended) when the live
write throws — the silent-drop this class exists to prevent.
- MarkNeedsImport returns bool and logs to stderr on failure so callers
don't trust an unpersisted marker.
- Add AppendResult.Ignored for the malformed-session-id drop so it can't
be mistaken for a real append.
- Tests for the I/O-failure path and the malformed-id path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: LifecycleSpoolDrain — global cross-spool ordered drain pass
Adds a session-agnostic drain pass to run at the start of every kcap
invocation (and periodically by the daemon), enforcing per-session
ordering across the lifecycle (HookSpool) and transcript (TranscriptSpool)
spools: spooled session-start -> transcript tail (+ needs-import marker,
delivered even over cap) -> spooled session-end. Adds the route-filtered
HookSpool.DrainRoutesAsync/SessionIdsWithBacklog and
TranscriptSpool.SessionIdsWithBacklog helpers this needs; HookSpool's
existing route-agnostic DrainAllAsync is untouched (still used by
Claude/Cursor).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: spawn-before-post + Spooled for Kiro/OpenCode/Pi/Copilot hooks
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: throttle spool drain per-prompt; ShouldSpawnAfter drops AuthLapsed
AI-1357 review: (1) DrainSpoolsAsync now self-throttles via an on-disk
.last-drain stamp (30s) so Kiro agentSpawn / OpenCode idle re-fires can't
attempt a network drain every prompt during an outage; reaps moved inside.
(2) ShouldSpawnAfter spawns only on Posted/Spooled — AuthLapsed spools
nothing, so spawning would orphan a session with a dropped SessionStarted.
(3) Documented the fresh-client deviation (no reusable vendor client exists
at the pre-POST drain point).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: Codex spawn-before-post with stdout-first carve-out
Auth lapse now spools + spawns via PostOrSpoolAsync/ShouldSpawnAfter
(matching Kiro/OpenCode/Pi/Copilot) instead of dropping the session and
skipping the watcher. The global lifecycle/transcript spool drain runs
fire-and-forget AFTER Codex's blocking `{"continue":true}` stdout
handshake — never before, never gating it — so a large/unreachable spool
backlog can't stall the parent process. Extracted WriteSessionScopedOutput
and RunSessionStartHandshakeForTest as the seam CodexStdoutContractTests
uses to prove the ordering without a live server.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: Antigravity spawn-before-post + Gemini awaited EnsureWatcherRunning
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: Gemini session-start spawn-before-post + gated spool drain
Migrate Gemini's session-start off the POST-only PostAsync path to
AgentHookPoster.PostOrSpoolAsync + ShouldSpawnAfter, so an auth lapse or
transient outage spools the SessionStarted payload and STILL spawns the
watcher (was: returned without reaching EnsureWatcherRunning on anything
but Posted). Wire the throttled DrainSpoolsAsync gated to the two lifecycle
events (SessionStart/SessionEnd) so the per-turn Notification path adds no
network cost. Fix a dangling "PostHookAsync (below)" comment in
AntigravityHookCommand left by the Task 6 deletion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: shutdown final-line completion signal (newline/parseable-JSON)
The shutdown-during-outage final drain (idle-timeout / parent-exit) used
to disable the half-written-line holdback unconditionally, which could
consume a line the agent was still mid-write on, or drop a complete
newline-less final line. Add a pure IsFinalLineComplete signal (empty /
newline-terminated / last line parses as JSON — length-stability alone is
NOT proof, since a large write can pause mid-record past any bounded
window) plus a bounded (<=2s) WaitForFinalLineCompletionAsync wait in the
final-drain path. Complete -> send the newline-less final line as before;
incomplete -> keep the holdback on (never send-and-advance a truncated
line) and flag the session needs-import via TranscriptSpool so `kcap
import` can recover it later.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: close final-drain completeness TOCTOU; re-validate at consume time
Review found the completeness decision and the consuming read were two
separate reads: WaitForFinalLineCompletionAsync probed once, then
DrainNewLines consumed with holdback disabled — so a final line that
resumed growing in the gap was sent-and-advanced anyway, violating "never
consume a still-growing line."
Move the decision into the consuming read via an IncompleteFinalLinePolicy
(Hold / ConsumeIfComplete). Under ConsumeIfComplete the unterminated final
line is consumed ONLY IF the exact bytes read parse as a complete JSON
record; otherwise it is held and NewTranscriptLines.HeldIncompleteFinalLine
is set. The parse check runs on the same bytes being consumed, so there is
no TOCTOU with the bounded pre-wait (now purely advisory — gives the writer
time). RunWatch flags needs-import off the actual consume-time held result
via state.FinalDrainHeldIncompleteLine.
Also updates the stale ApplyPartialLineHoldback comment that claimed the
final drain opts out of holdback because "the file is static then" — the
exact wrong assumption this task fixes.
Tests: SplitNewCompleteLines + ReadNewCompleteLinesAsync gain parseable-
consume / unparseable-held cases, HeldIncompleteFinalLine parity, and a
grew-after-the-completeness-check TOCTOU guard asserting the resumed
partial is held, not sent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: spool undelivered transcript tail on shutdown-during-outage
At final drain during idle-timeout/parent-exit shutdown, DrainNewLines only
advances LinesProcessed past lines the hub confirmed sent. If the hub is
down (or still reconnecting) at that point, any transcript lines from
LinesProcessed to EOF were never delivered and the process exits right
after — silently dropping the tail.
Adds BuildTranscriptSpoolBatch (pure TranscriptBatch JSON builder) and
SpoolUndeliveredTranscriptTailAsync, which re-reads the undelivered tail
using the same ConsumeIfComplete completion decision as the final drain
and spools it into the dedicated TranscriptSpool (task 2) so the global
drain (task 3) replays it after recovery, without a manual `kcap import`.
Cap exhaustion still marks the session needs-import rather than dropping.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: spool tail unconditionally + redact secrets (task-8 review)
Two Critical review findings on the shutdown-during-outage tail spool:
1. Insufficient trigger → silent drop. Gating the spool on
hubConnection.State != Connected missed a HubException thrown by
SendTranscriptBatch2 while the connection stayed Connected (the generic
catch does not change connection state) — the tail was undelivered but
never spooled. Call SpoolUndeliveredTranscriptTailAsync unconditionally
on the shutdown path; it is already a no-op when position == EOF.
2. SECURITY: spooled tail skipped secret redaction. The live/inline drains
run lines through SecretRedactor.RedactLine, but the spooled tail was
written raw → secrets on disk and POSTed unredacted on replay. Redact
each spooled line exactly as the live drain does before writing.
Tests: undelivered_tail_spooled_even_though_connection_stayed_up (state-
agnostic spooling) and spooled_tail_is_secret_redacted (ghp_ token →
[REDACTED], raw secret absent from disk).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: watcher heartbeat + lock-guarded staleness reap+respawn
AI-1357 task 9. WatcherManager.IsWatcherAlive only checked the PID, so a
wedged (hung-but-alive) watcher was never restarted. The watcher now
touches a per-key heartbeat file every main-loop iteration — including
no-content drains and while disconnected/reconnecting — via the pure
WatcherHeartbeat helper (Core). IsWatcherAlive requires both a live PID
and a non-stale heartbeat (past a 30s startup grace, 20s threshold).
EnsureWatcherRunning reaps a wedged watcher (kill + respawn) under a
cross-platform spawn lock — same FileShare.None/flock primitive as
DaemonLock — so concurrent hooks racing the same key can't double-spawn.
The whole decide-and-spawn sequence is locked, not just the reap: killing
the old watcher deletes its pid file before the respawn's new one lands,
so guarding only the reap step would leave a window where a second hook
sees "no pid" and spawns unguarded (caught by the new concurrency test).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: keep heartbeat fresh during connect-retry + purge watcher sidecar files
AI-1357 task 9 review. Two Important issues.
1. Startup/reconnect outage looked "wedged" → false reap. The SignalR
connect-retry loop never touched the heartbeat, so a server outage
>=~50s at startup was indistinguishable from a wedged loop and the
hook probe would repeatedly reap+respawn a healthy-but-reconnecting
watcher. RunWatch now touches the heartbeat at the top of every
connect-retry iteration and waits via DelayWithHeartbeatAsync, which
chunks the backoff (grows to 30s > the 20s threshold) into <=5s slices
through the new pure HeartbeatSlices helper, touching before each — so
no wait window can ever cross the staleness threshold. The automatic-
reconnect path was already covered by the main loop's per-iteration
touch in the disconnected branch.
2. Sidecar file leak. Only .pid was deleted. KillWatcher now removes the
{key}.heartbeat and {key}.started markers, but deliberately NOT the
{key}.spawnlock: KillWatcher runs from inside WithSpawnLock on the reap
path, and unlinking a held lock file on POSIX lets a racing hook open a
fresh non-conflicting flock (the DaemonLock unlink-race). Spawn locks
are swept by the new WatcherManager.PurgeAuxiliaryFiles(), called by
kcap cleanup (holds no lock; also mops up orphans). CleanupCommand now
uses GetWatcherDir() so it honors KCAP_WATCHER_DIR.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: Kiro live usage-backfill synthetic lines (credits/context%)
AI-1357 task 10: the live kcap watch path never emitted Kiro's per-turn
credits/context% (the AI-1196 hedge only fires on import, which reads the
sibling {id}.json up front). A live drain has usually already sent the
turn's anchor AssistantMessage line by the time Kiro flushes that sidecar,
so inline enrichment can't reach it there.
Add a synthetic KiroUsageBackfilled line per turn anchor instead, mirroring
the Antigravity synthetic-USAGE-line pattern: WatchCommand.
BuildKiroUsageBackfillLine builds the JSONL; AppendKiroUsageBackfillLines
reads the sidecar via KiroUsage.AnchorMap and appends one line per anchor
not yet in the new WatchState.KiroUsageEmittedAnchors, staging them on
KiroUsagePendingAnchors for the caller to commit only after a successful
send (so a failed batch re-reads and re-stages instead of losing them).
Server-side event fold is a separate follow-up (task 13).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: Cursor live subagent split via hook/backfill path (AI-1151)
CursorSubagentCorrelator only ran on the historical import path, so a live
Cursor Task/Agent subagent was ingested as its own top-level session instead
of nesting under its parent. Cursor is not watcher-backed (CursorHookCommand
backfills each session's transcript over HTTP as hooks arrive), so the
correlation now runs inline in that per-hook dispatcher via a new thin
wrapper, CursorLiveSubagentLinker.
At a child's sessionStart, ResolveParent (reusing
CursorSubagentCorrelator.Correlate) checks the sibling transcripts under the
same agent-transcripts workspace dir; a match is persisted to a small
on-disk marker (the CLI is a fresh process per hook) and diverts subsequent
hooks for that session: subagent-start replaces the top-level sessionStart,
transcript backfill is routed under the parent with agent_id=child (mirrors
CursorImportSource.SendSubagentLifecycleAsync's watermark + POST shape), and
subagent-stop replaces sessionEnd. Mid-lifecycle hooks
(beforeSubmitPrompt/afterAgentThought/telemetry) are not forwarded for a
linked child, matching the import path (which has no side channel for them
either) instead of writing to a phantom AgentSession stream that never got a
SessionStarted.
Dashless ids are used throughout so a live-then-import of the same session
converges on the same AgentSubsession-{parent}-{child} stream rather than
duplicating it (ties to AI-1358 A1). Known eventual-consistency gap
(documented in code): if the parent's Task tool_use isn't yet flushed to
disk at the child's first hook, the child is temporarily ingested top-level
until a later `kcap import --cursor` converges it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: enforce start-before-stop spool ordering on Cursor live subagent path (AI-1151)
HandleSubagentChildEventAsync posted subagent-stop (and ran the sessionStart
backfill) with no spool.HasBacklog check, unlike the sibling top-level path.
Under a transient failure that left a subagent-start undelivered in the spool,
a later subagent-stop could be POSTed ahead of its own subagent-start, leaving
the AgentSubsession stream mis-ordered / never opened.
Mirror the top-level guard: after the HandleCore drain, if the child still has
spool backlog (drain hit a transient failure), spool the fresh lifecycle event
behind it and skip the agent-routed backfill so the next hook re-drains
start-first. Also gate the sessionStart backfill on the subagent-start POST
succeeding, so the transcript can't be routed to an AgentSubsession the
now-spooled start hasn't opened yet.
Adds a regression test: a child whose subagent-start is spooled (transient
failure) does not get subagent-stop delivered ahead of it, and a later
recovered drain delivers start before stop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: wire global spool-drain pass into hook entry + daemon-periodic
AI-1357 Task 12. Runs LifecycleSpoolDrain centrally in Program.cs's
`case "hook":` (before dispatch, non-Codex; throttled via the existing
AgentHookPoster.DrainSpoolsAsync) instead of per-vendor, and adds a 60s
daemon-periodic sweep (SpoolDrainLoop) for backlogs no later hook process
ever touches (Kiro/OpenCode watcher-owned session-end, GUI idle/parent-exit).
Moves HookSpool/TranscriptSpool/LifecycleSpoolDrain to Capacitor.Cli.Core so
the daemon can share them without referencing the CLI's exe project.
Resolves three ordering hazards surfaced by this wiring (each covered by a
new test): the ordered drain's withheld temp files now use a distinct
".ordered-*" namespace so Claude/Cursor's unrelated FIFO drain can never
cross-consume them (BLOCKER-1); the generic drain now fires the
generate_whats_done side effect on any vendor's session-end, not just
Claude's own poster (BLOCKER-2); and a session whose session-end was already
delivered is durably marked ended so a later straggler entry is dropped
instead of replayed out of order (BLOCKER-3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: Claude ordering guard must see the .ordered-* backlog namespace
AI-1357 Task 12 review fix. ClaudeHookCommand.CurrentSessionHasBacklog was a
stale private duplicate that only checked {sid}.jsonl + {sid}.*.draining, not
the .ordered-* namespace this task introduced. Since the centralized ordered
drain now runs on every non-Codex invocation (incl. --claude), a Claude
session-end withheld in .ordered-* pending the transcript tail was invisible
to the guard, so a later Claude subagent-stop for the same session posted
directly — ahead of the still-withheld session-end (the exact BLOCKER-1/3
cross-spool ordering violation).
Delegate CurrentSessionHasBacklog to the public HookSpool.HasBacklog (which
covers all three namespaces), matching CursorHookCommand. Add a test proving a
subagent-stop spools behind a session-end withheld in .ordered-*.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: AI-1357 CLI acceptance tests (spawn-before-post, drain order, heartbeat)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* AI-1358: add import origin marker constant (CLI)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* AI-1358: shared guarded discovery helper (A4)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* AI-1358: adopt guarded discovery in all import sources (A4)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* AI-1358: restore flat discovery scope for Kiro/Gemini (A4 fix)
GuardedDiscovery.EnumerateFiles gains a recursive flag (default true); the
two originally-TopDirectoryOnly call sites pass recursive:false so A4 adds
symlink/cycle/inaccessible/per-entry safety without widening discovery scope.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* AI-1358: OpenCode ended_at null/0 not 1970 (A3)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* AI-1358: per-vendor ended_at resolvers (A3)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* AI-1358: confirm/extend Codex ExtractLastTimestamp shape (A3, open Q2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* AI-1358: Claude/Codex resume end-only reassert + fail-closed tail (A2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* AI-1358: routed sources send historical-import origin marker (A1)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* AI-1358: Cursor import no stale PR + per-cwd repo cache (item 5)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* AI-1358: Antigravity import usage pass, USAGE-before-end, all classifications (item 7)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1154] Task A1 (CLI): shared Cursor prompt canonicalization + parity corpus
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1154] Task C2: suppressed Cursor re-import still records repository (D1, CLI)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1154] Task F1: widen Cursor correlator input to same-workspace discovery (D5)
CursorImportSource.ClassifyAsync built its subagent correlator input strictly
from the scope-filtered `sessions` slice, so a child whose parent falls
outside --session/--cwd/--since/scope (e.g. `--session <child>`) could never
be correlated -- the correlator never even saw the parent's Task prompt.
Widen ONLY the correlator's input to every session under the same
sanitized-workspace agent-transcripts/ dir (cheap local file reads via a new
DiscoverSameWorkspaceSessionPaths helper mirroring DiscoverAsync's walk minus
the filters). Repo detection continues to run only on the filtered slice.
StampSubagentMeta additionally carries ParentSessionId on a correlated child
so Task F2 can reconcile an orphaned child (parent not in this run's plan) to
a standalone import.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1154] Task F2: import an orphaned Cursor child standalone (D5)
CursorImportSource.ImportSessionAsync unconditionally skips (drops) any
session stamped IsSubagentChild, assuming its parent will import it inline.
That assumption breaks once F1 widened the correlator's input beyond the
scope-filtered slice: a child's parent can now be correlated even when the
parent itself isn't part of this run's plan (never classified, or classified
but excluded for another reason) -- silently dropping the child instead of
importing it, and risking a subagent-start against an un-planned/possibly
ended parent.
Add ImportCommand.ReconcileOrphanedCursorSubagentChildren, run right after
`routed` is built: for each routed classification still flagged
IsSubagentChild, clear IsSubagentChild/ParentSessionId when its
ParentSessionId isn't itself among `routed`'s session ids. The child then
falls through CursorImportSource's existing (now correctly conditional)
nested-child skip into the ordinary standalone session-start/transcript/
session-end path. The server-side CursorSubagentAdoptionSweep (AI-1156 D4)
adopts it under its real parent later, once the parent exists server-side.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1154] Review fix: prune out-of-plan Cursor subagent children before attach (P1)
CursorImportSource.ClassifyAsync widens its correlator input to same-workspace
discovery (D5/F1) so it can find parent<->child links even when the filtered
run only sees one side. That widened set was previously also used to decide
which children to ATTACH: a routed parent's SubagentChildren could list a
child that fell outside this run's routed/filtered plan (e.g. `--session
<parent>`, or a --since/scope filter excluding the child), so
ImportSessionAsync would send that child's subagent-start/transcript/stop
anyway -- silently widening the import plan past what the user asked for.
ReconcileOrphanedCursorSubagentChildren now prunes SubagentChildren down to
children that are actually part of `routed` for this run, in addition to its
existing orphaned-child-side reconciliation. A child left out of the plan
still imports standalone on its own run, or gets adopted later by the
server-side CursorSubagentAdoptionSweep.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1154] Review fix: don't double-count AlreadyLoaded repo-backfill replays (P2)
The routed-phase loop (Cursor) treats any Loaded/Resumed ImportOutcome as a
newly imported session, incrementing routedLoaded and joining
importedSessionIds. But an AlreadyLoaded classification's ImportSessionAsync
call exists purely to re-assert lifecycle hooks and backfill the repository
node (the C2 suppressed-repo-import contract) -- there's nothing past the
watermark to send, yet it still returns Resumed (no zero-line signal to
distinguish "nothing sent" from "brand new"). That folded one session into
both the classify-time AlreadyLoaded bucket and the routed Loaded counter, and
added it to importedSessionIds so a later --private pass would wrongly
re-private a session this run never actually (re)imported.
IsLifecycleOnlyRoutedReplay(status, outcome) gates both loop branches (TTY and
non-TTY): an AlreadyLoaded + Loaded/Resumed replay is now surfaced with its
own "Refreshed" message instead of counting toward Loaded or
importedSessionIds. A genuine New/Partial import, or an AlreadyLoaded replay
that fails, is unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1154] Review fix: thread a real sent-child-content signal out of ImportSessionAsync (P1)
IsLifecycleOnlyRoutedReplay inferred "no new work happened" purely from the
parent classification/outcome pair, which is wrong when an AlreadyLoaded
Cursor parent attaches a previously-unloaded nested subagent child inline —
that IS real new work, even though the parent's own transcript has nothing
past its watermark.
Add ImportSessionResult (ImportOutcome + SentChildContent, with an implicit
conversion from ImportOutcome so every non-Cursor source is unaffected) as
the new IImportSource.ImportSessionAsync return type. CursorImportSource
tracks whether SendSubagentLifecycleAsync actually POSTed new transcript
bytes for any correlated child and surfaces that via SentChildContent,
independent of its own sent/startLine outcome.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1154] Review fix: consume the sent-child signal, don't double-count AlreadyLoaded+Skipped children (P1+P2)
Two follow-on findings on IsLifecycleOnlyRoutedReplay:
- P1: the routed-phase loop now passes ImportSessionResult.SentChildContent
into IsLifecycleOnlyRoutedReplay. An AlreadyLoaded parent that attached a
brand-new nested child is no longer suppressed — it joins routedLoaded /
importedSessionIds so a --private run correctly re-privates it.
- P2: broaden IsLifecycleOnlyRoutedReplay to also recognize
AlreadyLoaded+Skipped (a correlated nested child whose own routed call
short-circuits to Skipped because its parent imports it inline). Previously
this rolled into routedExcluded on top of the classify-time AlreadyLoaded
bucket, double-counting the same child as both Already-loaded and Excluded.
A Failed outcome is untouched by this check and still surfaces as an error.
Extends ImportDoneBreakdownTests with regression coverage for both findings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1154] Review fix: fail-open child watermark probe must not assert new content (P2)
SendSubagentLifecycleAsync fails open when the child subsession watermark
probe throws (transient 5xx): startLine resets to 0 and the whole child
transcript is reposted. Those events are server-side idempotent duplicates
when the child was already complete, but SendTranscriptBatches returns the
count of lines POSTED, not "new" - so the SentContent signal was wrongly
true, letting an AlreadyLoaded parent be miscounted as newly Loaded and
re-privatized.
Track whether the probe itself succeeded (probeFailed) and require it be
false in addition to childSent > 0 before asserting SentContent. A genuine
resend - the watermark was actually known, whether it came back empty or
with a real value - still reports true.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1154] Review fix: scope the lifecycle-only-replay gate to Cursor (P2)
IsLifecycleOnlyRoutedReplay applied to every routed vendor, but
SentChildContent is populated only by Cursor. Antigravity's AlreadyLoaded
repair path deliberately routes through ImportChildrenAsync to attach a
previously-missing child - which can POST new transcript content - then
returns Skipped via the implicit ImportOutcome conversion (SentChildContent
defaults false). The shared gate then suppressed that real import as
lifecycle-only, wrongly excluding it from Done accounting.
Scope the gate to vendor == "cursor": the behavior it models (repo-backfill
AlreadyLoaded replays with no new content) is Cursor-specific, so this is
the minimal fix that can't suppress a real import on any other vendor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1154] Review fix: probe-failure resend defaults to privacy-safe (counts + privatizes) (P1)
A prior review fix (a12c254) forced SentContent=false whenever the child
subsession watermark probe threw, treating a fail-open full resend as proof
of "no new content". That's wrong: a failed probe is INDETERMINATE, not
proof of a duplicate. When the child is genuinely new and the watermark
endpoint merely 500s transiently, the fail-open resend really does post new
content, yet the caller was told nothing happened — so an AlreadyLoaded
parent was excluded from importedSessionIds and never privatized under
--private, leaking the newly-attached child's content on a public session.
Fix: drop the probeFailed suppression. SentContent is now
`stopOk && childSent > 0` regardless of whether the probe succeeded or
failed open — a probe failure is never treated as "definitely no new
content", only as "unknown", and posted content is conservatively counted
so the parent is privatized. Accepted trade-off (separately tracked): an
already-complete child that gets fail-open-resent will also report
SentContent=true, which can cosmetically double-count its AlreadyLoaded
parent across the Loaded/AlreadyLoaded buckets — privacy correctness wins
over count precision.
Inverts the round-3 regression test
(already_loaded_parent_with_failing_child_watermark_probe_does_not_report_sent_child_content)
to assert the privacy-safe behavior, renamed to
already_loaded_parent_with_failing_child_watermark_probe_conservatively_reports_sent_child_content_to_preserve_privacy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1154] Review fix (r6): privatize --private sessions independently of import lifecycle success (P1)
A Cursor session's routed import only joined importedSessionIds — the sole
input to the end-of-run --private PUT /visibility pass — when its own
ImportOutcome was Loaded/Resumed and wasn't a lifecycle-only replay. A
subagent-stop/session-end POST failing AFTER a child's transcript content had
already persisted made ImportSessionAsync return Failed (excluded from
privatization), and a later retry that read the session as
AlreadyLoaded-with-nothing-new was excluded too (lifecycle-only replay) —
either way a --private run's newly-attached content stayed public forever,
even across repeated re-runs.
Fix: a separate, outcome-independent tracker (privateScopeSessionIds) now
captures every Cursor routed classification touched under --private,
regardless of ImportOutcome/ClassificationStatus/SentChildContent, and is
unioned into the privatize set at the end of the run. importedSessionIds and
the Done-grid counting it feeds are untouched (the AI-1389 cosmetic
double-count concern stays deferred, as intended).
Also considered privatize-first (stamping default_visibility on Cursor's
session-start payload, as Antigravity/OpenCode/Pi already do) — server-side
CursorSessionStartHook has no DefaultVisibility field, so that mechanism
would be silently inert for Cursor until the server adds support. The union
fix instead reuses the already-trusted PUT /api/sessions/{id}/visibility path
end-of-run, so it works with the currently deployed server.
Regression tests (new CursorPrivatizeLifecycleFailureTests, integration):
verified red without the fix, green with it.
1. lifecycle POST fails after content persisted -> still privatized
2. AlreadyLoaded retry with no new content -> still privatized (self-heals)
3. non---private run -> never calls PUT visibility
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1154] Qodo fix: scope Cursor subagent correlation per workspace, thread cancellation into the same-workspace scan
Qodo finding #2 (Action required): ClassifyAsync unioned every same-workspace
transcript path across ALL workspaces touched by a classify call and ran
CursorSubagentCorrelator.Correlate over the combined set, so a child session
in workspace A could be linked to a parent in workspace B whenever their
canonical prompt hashes happened to match — corrupting nesting/attribution.
Same-workspace discovery is meant only to give a filtered/scoped import
visibility into a parent; it must also constrain correlation. Fixed by
building one path map per workspace (keyed by SanitizedDir, or an isolated
per-session bucket when a session carries none) and running Correlate()
independently per workspace, then merging the resulting subagentLinks.
Qodo finding #3 (Review recommended): DiscoverSameWorkspaceSessionPaths did a
synchronous Directory.EnumerateDirectories scan without consulting the
CancellationToken ClassifyAsync receives, so Ctrl-C could hang until a large/
slow-filesystem scan finished. Threaded the token through, calling
ct.ThrowIfCancellationRequested() once per directory iteration (letting
OperationCanceledException propagate past the existing hostile-subtree
catch-alls).
Adds regression tests for both: a cross-workspace prompt-collision case
asserting no cross-workspace link is made, and a pre-cancelled-token case
asserting ClassifyAsync throws promptly during the scan.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI-1154] Qodo fix: trim verbose Cursor subagent reconciliation doc comment
Qodo finding #1 (Review recommended): the doc comment above
ReconcileOrphanedCursorSubagentChildren carried narrative history (two
separate review-round call-outs, a <para> aside) well past the repo's
keep-comments-concise convention. Trimmed to the essential contract — what
counts as an orphan, why it must import standalone, and why SubagentChildren
is pruned — and dropped the embedded Linear identifiers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
realtonyyoung
added a commit
that referenced
this pull request
Jul 21, 2026
…(Qodo) - Security (Qodo #3): AcpReviewFlowMcp allowlist now resolves via KcapMcpRegistry.TryResolveReviewFlowAllowlist — the authoritative read-only reviewer policy (ReviewFlowAutoApprovableServers) the orchestrator already enforces for Codex — so a write server like kcap-memory can never reach an auto-approving reviewer. - Observability (Qodo #2): an unknown/flow-starting/non-auto-approvable entry now fails the launch fast with the offending name instead of being silently dropped. Build takes the validated canonical ids. - Maintainability (Qodo #1): trimmed verbose spec-prose comments to concise intent (rationale lives in the design spec). Tests updated: split recursion/dedup into a dedup test + a parameterized fail-fast test (kcap-flows/kcap-memory/kcap-workitems/unknown). 67 tests pass; AOT clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
realtonyyoung
added a commit
that referenced
this pull request
Jul 21, 2026
…P + owned-worktree-gated auto-approve (#337) * [AI-1407] ACP unattended reviewer foundation: flow-result MCP over ACP + owned-worktree-gated auto-approve Wires the vendor-neutral plumbing so a per-vendor child can flip SupportsUnattended and become an unattended review-flow reviewer over ACP. Flips nothing itself — every real vendor stays SupportsUnattended:false, so these paths are unreachable in production (Cursor pinned byte-for-byte). - AcpReviewFlowMcp.Build: the AcpMcpServerSpec analogue of ClaudeLauncher's PTY BuildReviewFlowMcpConfig — kcap-flow-result (KCAP_URL + KCAP_FLOW_AGENT_ID) plus the flow's MCP allowlist resolved via KcapMcpRegistry (flow-starting servers stripped, unknown skipped, deduped by canonical id to match ClaudeLauncher's JsonObject keying). - AcpHostedAgentRuntimeFactory: ValidateAndBuildReviewFlowMcp runs as the first statement of StartAsync, BEFORE the connectionSource spawns, and fails closed for a review-flow launch that isn't unattended-capable, isn't an owned worktree, has no ACP mcpServers support, or can't build a deliverable result channel (nonblank url/path/agent id). BuildProcessStartInfo gains a matching owned-worktree refusal as defense-in-depth. - AcpInteractionBridge: autoApproveUnattended selects the least-privilege allow option by Kind (nonblank + unique OptionId) without routing to a human, and declines elicitations; fails closed otherwise. Audit log pins agentId + kind + untrusted tool title, never a path. Owned-worktree is a launch precondition, NOT filesystem confinement — real containment is a per-vendor live-verification gate for each reviewer child. 62 new/covered unit tests; AOT-clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * AI-1407: enforce read-only reviewer allowlist policy + trim comments (Qodo) - Security (Qodo #3): AcpReviewFlowMcp allowlist now resolves via KcapMcpRegistry.TryResolveReviewFlowAllowlist — the authoritative read-only reviewer policy (ReviewFlowAutoApprovableServers) the orchestrator already enforces for Codex — so a write server like kcap-memory can never reach an auto-approving reviewer. - Observability (Qodo #2): an unknown/flow-starting/non-auto-approvable entry now fails the launch fast with the offending name instead of being silently dropped. Build takes the validated canonical ids. - Maintainability (Qodo #1): trimmed verbose spec-prose comments to concise intent (rationale lives in the design spec). Tests updated: split recursion/dedup into a dedup test + a parameterized fail-fast test (kcap-flows/kcap-memory/kcap-workitems/unknown). 67 tests pass; AOT clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * AI-1407: treat reserved kcap-flow-result allowlist id as a no-op (codex review) The result channel is always injected by AcpReviewFlowMcp.Build and is not a KcapMcpRegistry entry, but the server's DynamicFlowPolicy legitimately lists kcap-flow-result in McpAllowlist. Strip it (case-insensitive) before the strict read-only validation so it is a redundant no-op, not a fail-fast rejection — while still rejecting unknown/flow-starting/write-server entries. Single-sourced as AcpReviewFlowMcp.ResultChannelId. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * AI-1407: normalize reserved kcap-flow-result no-op in TryResolveReviewFlowAllowlist (codex review r2) Move the reserved result-channel normalization from a daemon-side pre-filter into KcapMcpRegistry.TryResolveReviewFlowAllowlist so the ACP reviewer path and the Codex orchestrator path share ONE consistent contract: kcap-flow-result (always launcher-injected, not a registry entry, legitimately listed by the server's dynamic-flow policy) is a satisfied no-op, never re-emitted, never a rejection. Single-sourced as KcapMcpRegistry.ReservedResultChannelId. Core test added; ACP end-to-end no-op test retained. 9+31+37+67 tests pass; AOT clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
realtonyyoung
added a commit
that referenced
this pull request
Jul 28, 2026
…e as a swap Codex review round 1 on this PR. Three findings, all real. P1 #1 -- the retry budget was right for registration and wrong on the launch path: three 10s waits plus backoff is ~30.75s of a thread-pool thread held before a launch can even be rejected, multiplied by concurrent launches. Registration keeps the three attempts (its result is cached for the daemon's lifetime, so a miss there is durable and destructive); the launch path now uses a single attempt. It does not need the retry: a miss there is no longer misclassified, produces a retryable rejection, and the caller can try again. P1 #2 -- the same bug I fixed at registration was still live at LAUNCH. With a version advertised at registration and the launch probe timing out, the swap arm fired and told the operator the CLI had changed and to restart -- when nothing had changed and restarting merely repeats the transient failure. A failed probe is now its own arm, classified BEFORE the swap comparison, with a retry remedy. Still fails closed; only the diagnosis and the remedy differ. P2 #3 -- the swap arm hid the out-of-range diagnosis. A CLI genuinely replaced with an out-of-range version was told only to restart the daemon, and restarting re-advertises the same out-of-range version, so the remedy could never work. The range is now checked before the swap arm, so the operator is told the thing they can act on. Final arm order: vendor -> connection -> policy -> probe-failed -> range -> swap. Mutation-verified rather than asserted: - remove the probe-failed arm -> 3 tests fail - move the swap arm before the range arm -> 1 test fails 12 tests, check-linear-ids clean.
realtonyyoung
added a commit
that referenced
this pull request
Jul 28, 2026
…ewer (#385) * fix: a transient version-probe timeout must not disable the reviewer Surfaced by three cross-driver conformance attempts that each failed differently. One defect explains all of them. ProbeCliVersion shelled out to `<cli> --version` with a 3s timeout, no retry, returning null on failure -- and that null was computed once at daemon registration and advertised as ExpectedCliVersion for the daemon's whole lifetime. `claude` is a Node CLI whose cold start routinely exceeds 3s on a loaded host. One transient failure then produced either: - the vendor dropping out of the unattended set entirely (reviewer_vendor_unavailable), because the server cannot range-check a null; - or EVERY launch being rejected, because the launch-time arm demanded exact equality between a freshly probed version and the advertised null. The second is worse than it sounds: it survives the remedy the error text gives. Restarting the daemon on a still-loaded host simply re-poisons the advertisement, which is exactly what happened between two of the attempts. Three changes: 1. The probe retries (3 attempts, 250ms/500ms backoff) with a 10s budget. A cold Node start under load is not an error condition. 2. The equality arm no longer treats "null advertised" as a mismatch. It exists to catch a CLI SWAP between advertisement and launch; null-vs-value is not a swap. A null or empty advertised version now falls through to the range check, which is the real gate -- proven by a test that an out-of-range CLI is still rejected when the advertisement is null. 3. The rejection NAMES the failing arm. Four distinguishable conditions collapsed into one message that reported the certification revision -- which matches on every one of these paths -- and told the operator to update a CLI that was correct and in range. This was already recommended when the same message was hit from a different arm (empty AllowedCliRanges), but that fix shipped server-side only and the daemon message never changed. Each arm now states the cause AND the remedy that applies to it, and a test asserts no rejection mentions the revision. The arm logic is extracted to an internal EvaluateReviewerCertification so it is testable at all -- it was previously a single inline boolean expression. 9 new tests, 29/29 in the neighbouring daemon suite, check-linear-ids clean. * fix: split the probe budget, and stop misreading a failed launch probe as a swap Codex review round 1 on this PR. Three findings, all real. P1 #1 -- the retry budget was right for registration and wrong on the launch path: three 10s waits plus backoff is ~30.75s of a thread-pool thread held before a launch can even be rejected, multiplied by concurrent launches. Registration keeps the three attempts (its result is cached for the daemon's lifetime, so a miss there is durable and destructive); the launch path now uses a single attempt. It does not need the retry: a miss there is no longer misclassified, produces a retryable rejection, and the caller can try again. P1 #2 -- the same bug I fixed at registration was still live at LAUNCH. With a version advertised at registration and the launch probe timing out, the swap arm fired and told the operator the CLI had changed and to restart -- when nothing had changed and restarting merely repeats the transient failure. A failed probe is now its own arm, classified BEFORE the swap comparison, with a retry remedy. Still fails closed; only the diagnosis and the remedy differ. P2 #3 -- the swap arm hid the out-of-range diagnosis. A CLI genuinely replaced with an out-of-range version was told only to restart the daemon, and restarting re-advertises the same out-of-range version, so the remedy could never work. The range is now checked before the swap arm, so the operator is told the thing they can act on. Final arm order: vendor -> connection -> policy -> probe-failed -> range -> swap. Mutation-verified rather than asserted: - remove the probe-failed arm -> 3 tests fail - move the swap arm before the range arm -> 1 test fails 12 tests, check-linear-ids clean. * fix: notify the caller before the self-heal, not after it Codex review round 2. My round-1 claim that the launch path was bounded to one 10s attempt was FALSE, and the reviewer was right to check the wiring rather than take it. The rejection branch called ComputeUnattendedVendorCapabilities BEFORE LaunchFailedAsync -- and that recompute re-runs the REGISTRATION probe, three 10s attempts plus 750ms of backoff. So a failed launch probe cost ~10s in the single-attempt probe, entered the new transient arm, and then blocked ~30.75s more before the caller heard anything. Splitting the probe budget did nothing while this sat in front of the notification. LaunchFailedAsync now goes first. The self-heal (recompute + re-advertise) still runs -- a certification mismatch usually does mean the advertisement is stale -- but off the response path and contained, so a throw there cannot surface as a second, different failure for a launch that has already been rejected. Not unit-tested, and saying so rather than implying otherwise: this is call-site wiring inside a large launch method, and the arm tests deliberately cover only EvaluateReviewerCertification. The reviewer made the same point. Verified by reading the ordering, not by a test. * fix: single-flight the background capability refresh Codex review round 3. My round-2 fire-and-forget REINTRODUCED the defect this PR removes, and the reviewer described the interleaving exactly: refresh A starts on a loaded host and spends ~30s timing out refresh B starts later, probes successfully, publishes, re-registers A completes LAST, overwrites the valid snapshot with its failed-probe null, and re-registers that poisoned advertisement Atomic reference assignment prevents a torn pointer, not stale completion order. I had reasoned about the former and asserted the latter was fine. SingleFlightRefresh serialises publication and coalesces: a request arriving mid-flight sets a rerun flag rather than starting its own pass, so a burst of rejections collapses to at most ONE extra pass, and that pass starts AFTER the request that asked for it -- so the last write is always the newest computation. The rerun flag is cleared BEFORE the work, not after, or a request arriving during a pass would be swallowed. Extracted as its own type for the same reason the certification arms were: the coordination is the risky part and it was untestable inline. 5 tests, including the reviewer's exact scenario (slow-failing overlapping fast-success) and a burst collapsing to one rerun. Mutation-verified: remove the gate so passes can run concurrently and ALL 5 fail. 12 certification-arm tests still green. * fix: Trigger must schedule, not run the delegate inline Codex review round 4. A DISCARDED task is not an asynchronous boundary -- `_ = RequestAsync(...)` still runs the method's synchronous prefix on the caller's stack, up to its first incomplete await. The refresh delegate computes capabilities SYNCHRONOUSLY (it shells out to probe CLI versions) before awaiting anything, and SemaphoreSlim.WaitAsync(0) completes synchronously when the gate is free -- so nothing yielded, and the launch path still ate the whole probe budget. My round-3 claim that the work was off the response path was wrong for the second time in this PR, in the same way: I asserted a boundary existed instead of checking where the first real await was. RequestAsync becomes a non-async void Trigger that wins the gate with the synchronous Wait(0) and hands the pass to Task.Run. The caller cannot inherit the work. That also removes the API asymmetry I raised last round rather than papering over it with a doc comment: there is no task to await, so a future caller cannot be misled by one whose meaning depends on whether they won the gate. An internal Current property exists purely so tests can await quiescence. New test: a delegate that BLOCKS SYNCHRONOUSLY must not delay Trigger. Mutation-verified -- replace Task.Run with an inline call and it fails. 6 single-flight tests, 12 certification-arm tests, check-linear-ids clean. * fix: serialize daemon registration so a stale snapshot cannot land last Codex review round 5. I argued this was a harmless duplicate registration and that reasoning was wrong -- checking the DTO settled it. DaemonConnectAsync reads _config.UnattendedVendorCapabilities INLINE at DTO construction, which happens AFTER an `await MergeRepoPathsAsync()` yield, and nothing serialized two concurrent registrations. So: heartbeat (slot displaced) constructs its DTO with the OLD capabilities the certification self-heal assigns the NEW capabilities and sends its own if the heartbeat's frame is processed last, the server ends up advertising the STALE set while the daemon's local config says otherwise That silently undoes the self-heal. It matters more now than it did before, because this change makes the self-heal the thing that restores a missing advertisement -- so its reliability is load-bearing rather than incidental. DaemonConnect construction AND invocation now run under a registration lock. Held across the invoke deliberately: releasing after construction would let a DTO built from fresher config overtake an in-flight older one, which is the same bug with a smaller window. The race predates this PR. I am fixing it here anyway because this PR is what makes the self-heal load-bearing, and because the fix is contained to one class rather than the cross-subsystem change I assumed it would be when I declined it last round. NOT unit-tested, stated plainly: exercising it needs a hub fake that can pause a registration mid-invoke, and there is no ServerConnection harness in this suite. Verified by reading the ordering. If a harness is wanted, it should be its own change rather than grown inside this one. 6 single-flight tests, 12 certification-arm tests, check-linear-ids clean. * fix: keep the launch-lane probe on its original short budget Qodo finding 4 caught a regression I introduced. The launch-time probe runs on the SEQUENCED COMMAND LANE -- a single serial consumer -- so every later launch and stop queues behind it. Reducing it to one attempt was right, but I also raised the per-attempt timeout from 3s to 10s for BOTH call sites, so the lane stall went from 3s to 10s. The change meant to shorten it tripled it. Registration keeps 3 x 10s (cached for the daemon's lifetime, so a miss there is durable). The launch path is back to a single 3s attempt: a miss there is now correctly classified as transient and retryable, so it never needed the bigger budget. Qodo's 61.5s figure assumed the capability recompute still ran inline on the launch path; it moved to a scheduled single-flight pass earlier in this PR, so the second probe no longer holds the lane at all. Findings 1-3 (comment length): trimmed the probe-budget, ExpectedCliVersion and test-class comments to the non-obvious invariant. Incident history belongs in the PR, which is where it now lives. 12 certification-arm tests, 6 single-flight tests, check-linear-ids clean.
realtonyyoung
added a commit
that referenced
this pull request
Jul 29, 2026
Qodo #1, partially taken. The comments explain why each assertion is shaped the way it is — which is load-bearing here, since three of this PR's review findings were tests that passed vacuously and the rationale is what stops the next reader simplifying them back. That stays, and it matches the surrounding code (SingleFlightRefresh, McpConfigShape, KcapMcpRegistry all document intent at length). What was genuinely historical rather than explanatory is gone: which review round found what, what my earlier attempts did, and past-tense accounts of defects that no longer exist. Rewritten as present-tense reasons. 42/42.
realtonyyoung
added a commit
that referenced
this pull request
Jul 29, 2026
…jection (#388) * test(mcp): pin the vendor-capable flows schema across every driver projection Reviewer choice is meant to be a property of the request, not of whichever harness is driving. Nothing enforced that: registration is FOUR mechanisms, not one. Six harnesses (Cursor, Copilot, Gemini, Kiro, OpenCode, Antigravity) converge on one JSON writer and differ only by a shape; Codex writes TOML through a separate engine with its own ownership ledger; Claude Code loads a hand-maintained static kcap/.mcp.json; and Pi gets a hard-coded server list inside an embedded TypeScript bridge. The existing per-harness tests each assert Contains("kcap-flows") in isolation — that a server by that name was written, not that it resolves to the same executable and therefore the same schema. A harness whose registration drifts to a different command leaves a caller believing it named a reviewer when it sent nothing. Adds a conformance suite covering: - vendor is an optional string on both START tools, and on neither of the six follow-up tools (the applied vendor is pinned at start; a vendor there would be ignored or an incoherent mid-run switch); - the vendor/model DESCRIPTIONS carry what the schema cannot — that omitting vendor takes the server default, that the token is canonical lowercase, that there is no silent fallback, and that model requires vendor. This is the only mechanism by which a driver LLM learns to pass the parameter, so a correct schema with a silent description produces exactly the failure the contract exists to prevent; - all nine driver projections resolve to the same `kcap mcp flows`, driven through the real writers rather than asserted against the descriptor; - Pi's bridge still lists flows in its literal — it discovers tools at runtime, so dropping it there is silent; - the two independent copies of the server list (KcapMcpServers vs KcapMcpRegistry) agree, and every canonical server resolves as an allowlist entry. Nothing kept these in sync. Also pins the hand-written harness table against VendorSelection.KnownVendorFlags (now internal), so a tenth installable target fails here instead of quietly being uncovered — no enumeration of supported harnesses exists in production code, the list is spread across four separate string arrays. Mutation-tested: dropping vendor from start_flow, drifting KcapMcpRegistry's args, removing flows from Pi's literal, adding a tenth vendor flag, and changing the canonical flows args each fail their assertion. The last one is instructive — all seven generated arms fail while the two static hand-maintained files pass, which is exactly the generated-vs-static drift this is for. Scratch dirs live under the assembly output, not the system temp root: on macOS /var is a symlink and CodexConfigToml's path guard rejects any symlinked component, so a temp-rooted Codex registration silently returns Failed. (That is also why the pre-existing CodexConfigTomlTests fail locally on macOS.) 26/26. Full Tests.Unit: 42 failures, all pre-existing on macOS and none in this suite. * fix(test): drive the real installers, not a reconstructed projection table Codex review round 1, and the P1 defeated the suite's central claim. The table hard-coded KcapMcpServers.ForCursor and the expected McpConfigShape, while the real choices are wired independently in SetupCommand and each PluginCommand installer. So changing a real arm to omit kcap-flows, use the wrong subset, or use the wrong shape left every projection test green — the test kept invoking its own correct reconstruction. The Codex arm had the same hole, calling RegisterKcapMcpServers directly rather than the install path. Each arm now runs PluginCommand.HandleAsync against a FakeUserHome, seeding the same installed-but-stale state its own PluginCommand*Tests use so `--if-installed` takes the refresh branch. Codex is flagged BareInstall — it installs unconditionally and needs a planted plugin root. Proof it now bites: dropping kcap-flows from the production ForCursor subset fails six arms, where before it failed none. Also from round 1: - argv comparisons were UNORDERED, so ["flows","mcp"] passed while launching nothing. Ordered in both the projection assertion and the two-list check. - the drift check only ran canonical -> registry, so a registry-only server stayed allowlistable-but-never-registered with everything green — one of the exact failure modes the suite claims to prevent. Now compares both name sets (KcapMcpRegistry.AllIds is new) and every server's args. - `model` was pinned by prose only, so retyping it to boolean or promoting it into Required — which would break every caller relying on the vendor's default model — still passed. Type and non-requiredness now pinned, mirroring vendor. The coverage tripwire also stops matching on a display name split on whitespace (which could match by accident) and matches on the flag each arm actually drives. Every fix mutation-tested: production dropping flows, a registry-only server, reversed argv order, and model promoted to Required each fail. 27/27. Full Tests.Unit unchanged at 42 pre-existing macOS failures, none mine. * fix: one definition of each harness's MCP projection, and real path containment Codex review round 2. P1 — the SetupCommand route was outside the gate. `kcap setup` builds its own six Register*Mcp delegates, duplicating the (subset, shape, marker) tuple that PluginCommand also spells out. Mutating SetupCommand.RegisterCopilotMcp to drop flows or use a divergent shape left every installer-driven arm green: a user could get a different tool surface depending on whether they ran `kcap plugin install` or `kcap setup`. Fixed structurally rather than by testing both routes. The tuple now lives once, in HarnessMcpProjections, and both call sites consume it — there is no longer a second definition to diverge. Dropping flows from the shared Copilot projection now fails 2 tests; giving it the wrong shape fails 1. P1 — path containment was broken in three of seven arms, and the test could therefore read and rewrite a developer's REAL harness config, or pass against a pre-existing entry. The Gemini arm cleared GEMINI_HOME, a name GeminiPaths does not read (it honours GEMINI_CLI_HOME); the Codex arm cleared nothing while CodexPaths still gives ambient CODEX_HOME precedence; OpenCode cleared OPENCODE_CONFIG_DIR but left its XDG_CONFIG_HOME fallback live. Every known override is now cleared for every arm — a per-arm list is exactly what was wrong, so there is no per-arm list. Codex also now passes --skip-codex-network-access; a schema test has no business rewriting profile network config. P2 — the status assertion was satisfied by the fallback. FormatStatusResponse catches formatter exceptions and returns the raw JSON body, so "contains claude" passed even if formatting failed entirely. Now asserts the rendered labels and that no raw JSON survives. Mutation-testing that fix found something else: the audit rendering is TRIPLICATED across FormatRoundResponse, FormatStatusResponse and FormatPolledRoundResult, and my first mutant hit the wrong copy. The polled path is the one an agent reads on nearly every flow, and it had no coverage at all — added, and both formatter mutants now fail. 34/34. Full Tests.Unit unchanged at 42 pre-existing macOS failures, none mine. * fix: route removal and ownership through the projection too Codex review round 3. I added HarnessMcpProjection.Unregister and then left it unused — the six PluginCommand remove paths still hard-coded shape and marker, and Kiro's "is the MCP half already installed?" probe constructed `new McpMarker("kiro")` directly. So the single-source claim was only half true: changing a projection made new installs write under one ownership tuple while uninstall looked under the old one (stranding owned entries kcap could no longer see) and Kiro's refresh read an existing install as absent. All six removals now go through the projection, and OwnsAnything moves the probe there for the same reason the marker name is derived rather than passed: a probe reading a different tuple than the writer is the same bug in a third place. `new McpMarker(` no longer appears in PluginCommand at all. Pinned by a per-harness register -> probe -> unregister round-trip asserting the config is left with no kcap entries. Mutation-tested by making Unregister use a different marker name: all six fail. 41/41. Full Tests.Unit failure set byte-identical to the 42-item pre-existing macOS baseline. * test(mcp): make deleting a bundled-config arm fail the coverage tripwire Qodo #3. The two bundled static configs were covered by an [Arguments] test the tripwire could not see, and both reduce to `--codex` / `--claude` there — so deleting the Codex-plugin arm left `--codex` green while one of two INDEPENDENT Codex registration mechanisms went untested. The bundled configs are now a list the coverage assertion can read, compared against what is actually shipped in kcap/. A third bundled config, or a deleted arm, fails. Mutation-tested by removing the .codex-mcp.json entry. 42/42. * docs(test): drop the review narrative from the conformance comments Qodo #1, partially taken. The comments explain why each assertion is shaped the way it is — which is load-bearing here, since three of this PR's review findings were tests that passed vacuously and the rationale is what stops the next reader simplifying them back. That stays, and it matches the surrounding code (SingleFlightRefresh, McpConfigShape, KcapMcpRegistry all document intent at length). What was genuinely historical rather than explanatory is gone: which review round found what, what my earlier attempts did, and past-tense accounts of defects that no longer exist. Rewritten as present-tense reasons. 42/42.
This was referenced Aug 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
kapacitor setupstep 4 now writes plugin registration directly into Claude Code'ssettings.jsoninstead of printing a/plugin installcommand for the user to copy-paste--plugin-scope user|project|skipfor--no-promptmodeTest plan
InstallPlugin(new file, preserve existing, update path, create dirs, malformed JSON)kapacitor setupwith option 1 (user-wide) — verify~/.claude/settings.jsonupdatedkapacitor setupwith option 2 (project) — verify.claude/settings.local.jsoncreatedkapacitor setupwith option 3 (skip) — verify fallback message shownkapacitor setup --no-prompt --plugin-scope user— verify non-interactive mode🤖 Generated with Claude Code