Add Cursor editor and CLI capture with native session recovery - #1640
Add Cursor editor and CLI capture with native session recovery#1640philcunliffe wants to merge 5 commits into
Conversation
…N, list the new gate runClaudeSessionContextHook put the CURSOR_VERSION skip above its --help branch, so `hyp claude-hook session-context --help` printed nothing in a Cursor environment. LLP 0399#coexistence says help and other commands retain normal dispatch, and bin/hypaware.js's own skip deliberately spares --help; classify_hook.js already orders it that way. Move the guard below the help branch and cover it in the inherited-hooks test. AGENTS.md gains cursor_editor_cli_capture in the written acceptance procedure list and cursor_capture in the release smoke battery, the way launchd_supervisor_env and the GitHub flows were listed when they landed. docs/ACCEPTANCE.md step 8 gains the direction its fixture cannot settle: the skip keys on CURSOR_VERSION alone, so a Cursor shell that exports it would silently disable Claude Code's own session-context and spool-sweep lane. Only a real client can say whether Cursor exports it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t check listener.js computed a `lifecycle` flag after the guard above it had already returned for every hook that is not beforeReadFile, so the flag was always false, its counter increment was dead, and the `lifecycle_only` skip reason it advertised could never be produced. Collapse to the one reachable outcome. docs/ACCEPTANCE.md step 2 now names every human prompt in the comparison against the real client, and says a missing one points at the typed user-message suppression field. That field decides whether a prompt row is written at all, and no fixture sets it, so only a real session can check it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review round 1 — PR #1640, head
|
| Structure | Cap | Enforced at |
|---|---|---|
| In-flight request bodies | 4 concurrent, 1 MiB each | listener.js:127, :139 |
| Pending recovery queue | 64 entries, 16 per pass | listener.js:108, :53 |
| Live root fingerprints | 64, FIFO eviction | listener.js:86 |
| Sweep root fingerprints | 1000, FIFO eviction, pruned against present sessions | recovery.js:112, :76 |
| Blob reads per graph | 4096 | native.js:249 |
| Bytes per graph | 32 MiB cumulative, 1 MiB per record | native.js:254, :185 |
| Wire fields per record | 16384 | native.js:203 |
| Session discovery | 1000 editor headers, 1000 CLI dirs; reaching the cap is reported, not silently truncated | native.js:82, :97 |
| Identity strings | 256 chars | native.js:323, :354, projector.js:40 |
| Hook subprocess | 1 MiB stdin, 2 attempts, 2.5 s deadline | hook.mjs:5, :6, :24 |
| Writer identity maps | rotated every 1024 callbacks; a fresh per-pass writer for recovery | listener.js:250, :73 |
| Usage-policy memo | rotated every 1024 callbacks | listener.js:160 |
Repeated work, called out as the rule requires:
listener.js:63constructscursorAdmission(and a freshcreateUsagePolicyResolver) inside the per-item loop, discarding the resolver's TTL memo between items. Construction is allocation-only (matcher.js:122-131, no I/O), so the real cost is re-walking ancestors withexistsSyncper item. Bounded at 16 items per ~1 s pass, and the freshness is privacy-favorable — a.hypignorewritten mid-batch is honored. Accepted as deliberate.listener.js:242re-runs the fullbeforeReadFileadmission (2realpathcalls) inside the serialized write after already running it at:220— 4realpathper file observation. Deliberate and correct; the comment at:182says so andcursor-listener.test.js:153proves a session ignored while queued cannot slip through. Accepted.- Per completed turn the live lane re-reads and re-projects the whole conversation graph, so cost over a session is roughly quadratic in turn count. Mitigated three ways, each verified: an in-progress graph fails fast after reading only the root (
native.js:264,:278); the root fingerprint short-circuits before any graph walk (native.js:275-276); the live pass is debounced at 1 s and rearms only after the previous pass settles (listener.js:48,:101). Consistent with how the Claude transcript sweep already behaves. Findings 6 and 9 are the residual cases where the fingerprint can never advance.
Latency, not leakage: readCursorSession is fully synchronous (node:sqlite DatabaseSync) and can decode up to 32 MiB plus per-blob JSON.parse and sha256 on the daemon's event loop. That is the mechanism behind finding 5, and it is the one number the acceptance run most needs. node:sqlite is not a new dependency or a new engine constraint — hermes already uses it under the same node_engine: ">=22.12" (hermes/src/state_db.js:110).
Shutdown is clean: stop() clears the timer, drains pending, closes all connections and awaits the serial chain (listener.js:293-302); activeRecoveries entries are released in a finally on both lanes (listener.js:97, recovery.js:119); SQLite handles close in a finally (native.js:380). The bounded-queue test asserts pending_recovery returns to 0 after cleanup (cursor-listener.test.js:329).
Verdict of the pass: no CPU or memory concern that should block this PR. The one item worth a number before release is finding 5's head-of-line blocking.
Convention and privacy compliance
- No new runtime dependencies.
npm pack --dry-runconfirms all ten plugin files ship,hook.mjsincluded; it imports onlynode:cryptoandnode:http. - Style: no semicolons, no em dashes (U+2014), no NUL bytes anywhere in the added lines — grepped the whole diff.
- Types: JSDoc only, no
@typedef, no inlineimport('...');types.d.tsinterfaces imported via@importwith repo-root-anchored.jsspecifiers (listener.js:19,native.js:10,recovery.js:10). - LLP: every
@refresolves. LLP 0399's anchors#capture #identity #native-format #recovery #file-content #attachment #coexistence #resourcesall exist; the one cross-doc ref,LLP 0306#endpoint-free-clients, resolves tollp/0306-*.md:63.node scripts/llp-numbers.js check→ "1 LLP number minted against refs/remotes/origin/master, no collision". - Capture conventions: two lanes present (live hook receiver + bounded recovery, plus manual
hyp backfill cursor);entrypointdistinguisheseditorfromclioff the native store, asserted atcursor_capture.js:132;backfill.window_daysbounding is inherited correctly from the kernel (backfill_sweep.js:460,@ref LLP 0359#sweep-context) rather than reimplemented, andsweepregistration follows theclaudeadapter'sonJoin !== falsepattern exactly. - Privacy: no credentials, prompts or reasoning in telemetry —
CursorReadErrorcarries fixed codes only (native.js:20-24), storage errors are never logged (listener.js:270), and both are pinned by tests asserting'PRIVATE'never appears in status or errors (cursor-listener.test.js:62,cursor-native.test.js:106). Local-only withholding is proven against the real export seam, not a stub (cursor-listener.test.js:227-260). Detach removes only marker-owned entries and preserves a user-edited command verbatim (cursor-attach.test.js:41-45). native.js:330'snative_external_user_textrefusal — declining rather than substituting empty text for a real prompt — is the right instinct, and the direct counterpart to finding 7, where the same care is missing.
Check results
| Check | Head 0c53b2b4 |
After fixes | origin/master baseline |
|---|---|---|---|
npm test |
6569 pass / 3 fail / 1 skip | 6569 pass / 3 fail / 1 skip | 6537 pass / 3 fail / 1 skip |
npm run typecheck |
exit 0 | exit 0 | — |
npm run smoke -- cursor_capture |
ok | ok | n/a |
node scripts/llp-numbers.js check |
no collision | no collision | — |
npm pack --dry-run |
all 10 plugin files present | unchanged | — |
The 3 failures are identical in all three columns — the known environmental test/core/hyparquet-floor-pin.test.js cases (installed node_modules behind package.json in this container):
not ok - icebird uses the root parquet pins directly or through overrides
not ok - every read-path dependency that carries hyparquet is held at the floor
not ok - the read path resolves the one root hyparquet, not a nested copy
Confirmed pre-existing by actually running a clean origin/master worktree, not assumed from the label. No other failure, and none introduced by this PR or by my fixes. The branch adds 32 passing tests over master; this round added an assertion inside an existing test rather than a new one, so the count is unchanged.
Disposition summary
| # | Finding | Severity | Disposition |
|---|---|---|---|
| 1 | --help suppressed under CURSOR_VERSION |
medium | fix (landed, verified) |
| 2 | Unreachable lifecycle_only branch |
low | fix (landed, verified) |
| 3 | New gate/smoke missing from AGENTS.md |
low | fix (landed, verified) |
| 4 | CURSOR_VERSION blast radius unestablished |
medium | defer + acceptance clause landed |
| 5 | Hook write blocks behind recovery, trips the 800 ms timeout | medium | defer (noise, not loss; acceptance step 9) |
| 6 | Unknown tool field discards the whole conversation | medium | defer (fail-closed by design, observable) |
| 7 | Undocumented, untested user-prompt suppression field | medium | defer + acceptance clause landed |
| 8 | File-scope guard keys on the literal name Read |
medium | defer (additive over repo baseline) |
| 9 | Non-converging session re-reads every sweep | low | defer (bounded; acceptance step 5) |
| 10 | Picker probes a file Cursor does not create | low | defer (detection only seeds a checkbox) |
| 11 | Four unexercised fixture files | low | defer (retained evidence, documented) |
| 12 | unknown_entrypoints always equals callbacks |
low | defer (accurate but redundant) |
| 13 | Per-callback stderr noise when the daemon is down | low | defer (real trade-off) |
| 14 | previous_message_id: [] on native rows |
— | reject (LLP 0399#identity settles it) |
| 15 | is_error !== null changes the shared projector |
— | reject (deliberate; pinned by a matrix test) |
| 16 | CURSOR_HOME bypass |
— | reject (LLP 0399#attachment states it; test pins it) |
No blockers. The two things most worth a real-client number before this ships are finding 7 (does any human prompt go missing?) and finding 5 (how long does a recovery pass actually hold the chain?). Both now have a written acceptance step pointing at them.
…in place `native.js` suppressed a recovered human prompt whenever typed user field 5 held the varint 1, with no fixture reaching the branch (the native fixture writes fields 1/2/25 only) and no note anywhere saying what the field is. Its failure shape is the worst in the adapter: every human prompt vanishes while assistant and tool rows still land, silently. The field's real meaning is only settleable against a real Cursor store, and `docs/ACCEPTANCE.md` step 2 already asks for that. What a fixture can settle is the predicate itself, so pin both directions: field 5 = 1 drops the prompt and nothing else, and any other value keeps it. Both assertions fail if the suppression is dropped or widened to any present field 5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…or in --client `cursorAdmission().filter` assigned `allowed` per block with no break, so a second file block could reinstate a message the first one refused: a Read resolving outside the workspace sets `allowed = false`, and a following missing-file Read inside it assigns `allowed = true` again, persisting the first block's out-of-workspace contents. Unreachable today because the native reader emits one content block per tool message, but this is the function standing between a foreign directory and persisted file text, so it should fail closed structurally rather than by the producer's current shape. Every rejection now breaks, and a test pins it with two blocks that are each admissible or refusable on their own. It fails if the breaks are removed. `hyp setup --help` also still enumerated the four pre-Cursor `--client` choices while `INIT_CLIENT_CHOICES` carries five, so `--client cursor` worked but was undiscoverable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review round 2 (final) - PR #1640, head
|
| Mutation | Result |
|---|---|
if (true) messages.push (suppression dropped) |
not ok 5 - 10 pass / 1 fail |
if (!user.has(5)) messages.push (suppression widened to any field 5) |
not ok 5 - 10 pass / 1 fail |
| unmutated | 11 pass / 0 fail |
One piece of reassurance for whoever runs the gate: docs/cursor-capture-evidence.md:35-37 records that the candidate reader recovered 5 of 5 editor user turns and 1 of 1 in each CLI session, so in all three inspected stores the predicate never fired. That lowers the odds that field 5 is a commonly-set flag; it does not settle it, which is why step 2 stays.
2. One refused file block could be reinstated by the block after it - medium - fix (landed)
Found by the code-review pass; I verified it and it is real. recovery.js:24-44, the function standing between a foreign or ignored directory and persisted file text, assigned allowed per block with no break:
if (!isEqualOrDescendant(target, root) || [...].some(...)) allowed = false // no break
} catch {
allowed = block.is_error === true && isEqualOrDescendant(full, cwd) && ... // plain assignment
}So a tool message whose first Read resolves outside the workspace (allowed = false) and whose second Read is a missing-file error inside it (allowed = true) is written whole, including the first block's out-of-workspace contents.
Unreachable today, and I checked why rather than assuming: native.js:368-372 emits exactly one content block per tool message, so no current producer can build the two-block shape. That is the producer's shape protecting the filter, not the filter protecting anything. A privacy gate should fail closed structurally, so every rejection now breaks, and a test pins it with two blocks that are each individually admissible or refusable:
$ git show HEAD:hypaware-core/plugins-workspace/cursor/src/recovery.js | sed -n '32,42p'
const full = path.resolve(cwd, file)
try {
const [root, target] = await Promise.all([realpath(cwd), realpath(full)])
// Every rejection breaks: a later block must never be able to
// reinstate a message an earlier one refused.
if (!isEqualOrDescendant(target, root) || [...].some(...)) { allowed = false; break }
} catch {
...
allowed = block.is_error === true && ...
if (!allowed) break
}
Non-vacuous: restoring only recovery.js to its 9bbee912 blob and rerunning gives not ok 9 - one refused file block refuses the whole message, whatever block follows it (11 pass / 1 fail); with the fix, 12 pass / 0 fail.
3. hyp setup --help omits cursor from --client - low - fix (landed)
Also from the skill pass, also verified. init.js:190 added cursor to INIT_CLIENT_CHOICES, but core_commands.js:358-359 still enumerated the four pre-Cursor choices, and that list is meant to be exhaustive, so --client cursor worked but was undiscoverable. One-line correction; no test pins the string (grepped).
Deferred
4. A hook write queues behind a synchronous graph decode - medium - defer (re-examined, still deferrable)
Both passes raised this; it is round 1's finding 5. listener.js:241 chains the beforeReadFile write on the same serial promise the recovery pass takes at listener.js:52, a pass runs up to 16 sessions (listener.js:53) of fully synchronous readCursorSession, and hook.mjs:32 destroys the request at 800 ms, so the user gets HypAware Cursor capture unavailable; this callback was not confirmed. on stderr for a callback whose row later lands.
I re-examined whether it is safe to leave, and it is, on four grounds that are firmer than round 1 stated:
- Decoupling the chains would not fix it.
readCursorSessionis synchronous end to end (node:sqliteDatabaseSync, plus per-blobsha256andJSON.parse, up to 32 MiB and 4,096 blobs per graph,native.js:249-255). While it runs, the daemon's event loop is blocked outright: the hook request is not merely queued behindserial, it is not read at all. A separate write chain would leave the stall in place while looking like a repair. The real fix moves the decode off the loop (worker thread, or chunking) and teachesstop()to drain both chains: a concurrency redesign, not a review repair, and nothing here can size it. - It is a decided, documented position. LLP 0399#resources says it outright: "Native parsing is synchronous and may pause the daemon within the per-graph limits ... Process startup, large-session latency, catalog-cap coverage and sustained heap behavior remain acceptance measurements."
docs/ACCEPTANCE.mdstep 9 asks for hook latency and queue saturation with numbers. - No data is lost, and that is pinned by a test rather than by argument.
hook.mjs:21allocatesdelivery_idonce before the retry loop, so the post-timeout retry is the same delivery, andcursor-listener.test.js:57-75proves concurrent redelivery plus a source restart still yields exactly one row. - It is bounded, at 16 x 32 MiB per pass, and the live pass is debounced 1 s and rearms only after the previous one settles (
listener.js:48,:101).
One correction to the skill's version of this, which claimed a full re-parse "roughly once per second for the whole duration" of a live session: the pending map is keyed by [session, cwd] (listener.js:107), so a burst of callbacks in one turn coalesces into one entry, and a mid-turn read aborts after reading only the root (native.js:264 for the editor, :278 for the CLI) without touching the graph. The accurate cost is one full re-read per completed turn, so roughly quadratic in turn count over a session, not per callback.
Residual risk, stated plainly because it is what I would want measured first: the stall is the whole daemon's event loop, not just this plugin's, so a pathological pass also delays the OTEL listener and the gateway. That is exactly what step 9 is for, and it is the top follow-up on this branch.
5. The CURSOR_VERSION skip also disables Claude's body-spool sweep - medium - defer (round 1 finding 4, sharpened)
The skill raised this twice (as hook_command.js:71 and as bin/hypaware.js:23); both are one finding, and it is round 1's finding 4 with a consequence round 1 named but did not weigh: runClaudeSessionContextHook's own contract says the sweep runs "LAST, and on EVERY invocation ... which is the half of LLP 0253's bound the daemon cannot deliver on its own" (hook_command.js:49-51, :83-87). The skip returns before it.
Two corrections, both load-bearing:
- The skill's proposed repair does not work. Moving the handler guard below the sweep changes nothing in production, because
bin/hypaware.js:23-27exits 0 forclaude-hook session-contextbefore dispatch ever reaches the handler. Any real repair has to change the binary guard, which is precisely the part that cannot be narrowed without evidence. - "The spool grows without bound, silently" is overstated. LLP 0253#byte-cap puts the cap on the daemon ("The cap is enforced by the daemon, not by hoping the reader keeps up"), and LLP 0263 extends it to the client hook specifically "so the bound holds while the daemon is down". So the exposure is the narrower one: a stopped daemon, plus Claude Code running from a Cursor-launched shell that exports
CURSOR_VERSION, leaves the down-daemon half of the bound unenforced.
Still deferred, for round 1's reason plus one of my own: I went looking for a discriminator that is not invented and there is none. grep -rn "CURSOR_VERSION" research/ returns nothing, and the tree carries no Claude runtime env marker to pair it with (HYP_CLAUDE_CODE_VERSION is a HypAware attach-floor override, not a signal Claude sets). Picking one would be exactly what CLAUDE.md's do-not-invent rule forbids. docs/ACCEPTANCE.md step 8 already asks the operator to print the hook process environment from both a Cursor terminal and a plain one and to confirm Claude still records context and still sweeps its body spool in both, which is the only thing that can settle it.
6. openDb collapses a missing node:sqlite builtin into a read failure - low - defer
native.js:39-50 catches everything into one code, native_database_unavailable, so an absent builtin, a corrupt store, SQLITE_BUSY and a permissions error are indistinguishable in cursor.recovery.incomplete. hermes/src/state_db.js:106-119 shows the in-tree pattern: a distinct sqlite_unavailable naming the Node floor.
Deferred, and the skill's framing of the cause is rejected: it argued the manifest's node_engine: ">=22.12" is wrong because node:sqlite unflags later. That floor is not this PR's invention. It is the repo's settled, deliberate value: root package.json engines.node is ">=22.12", hermes and github already declare exactly ">=22.12" for the same builtin, and llp/0123 T1 records the repo-wide bump to >=22.12 as the node:sqlite decision (@ref LLP 0125). Cursor matching that is consistency, not a new hazard. If the floor is wrong it is wrong repo-wide and belongs to LLP 0125, not to this branch. The remaining half, diagnostic granularity, is a real but low-value nicety that would cost a cross-plugin import at the last review round.
7. Live recovery contention is counted as a read failure and surfaces as lastError - low - defer
listener.js:60 throws native_recovery_busy when the scheduled sweep already holds the session, and it lands in the same catch as a genuine read failure (listener.js:92-96): native_failures++, and lastError set, which status() exposes (listener.js:284). So ordinary overlap between the live lane and the default 5-minute sweep reads as an error in hyp status. The backfill lane handles the mirror case correctly, yielding a distinct cursor.recovery.busy event (recovery.js:86-89), so the asymmetry is demonstrable rather than a matter of taste.
Deferred on concrete counter-evidence: the current behavior is deliberately pinned by an existing test - cursor-listener.test.js:303 waits for native_failures >= 1 as the assertion that a busy session did not read - so changing it edits a decided behavior rather than repairing a slip. It is also self-clearing: listener.js:90 clears lastError on the next success, pinned at cursor-listener.test.js:291.
8. counts.lifecycle_events counts every callback that is not beforeReadFile - low - defer
listener.js:229 increments it for beforeSubmitPrompt, afterAgentResponse, postToolUse and postToolUseFailure as well as the five genuine lifecycle hooks, so the hyp status --json field means "callbacks that scheduled native recovery", not what it is named. Pre-existing at 0c53b2b4 (both the live branch and the dead one incremented it), so round 1's fix 2 neither caused nor worsened it.
9. unknown_entrypoints always equals callbacks - low - defer
Both passes found this; it is round 1's finding 12. listener.js:162 increments unconditionally. It is accurate (hooks genuinely never carry an entrypoint; only native recovery resolves one), so it is redundant rather than wrong. Same class as finding 8: renaming or dropping a field in a shipped hyp status --json shape is a design call, and both are accurate-but-misnamed.
Rejected
10. hook_command.js:71's env guard is unreachable through the shipped binary - reject
Worth stating because after round 1 moved it, it can look like dead code: bin/hypaware.js:23-27 exits 0 for claude-hook session-context whenever CURSOR_VERSION is set and --help/-h is absent, so the handler-level if (ctx.env.CURSOR_VERSION) return 0 is not reachable through hyp or hypaware for a non-help invocation. Not a defect: it is defense in depth for direct and library invocation, it is what round 1's regression assertion exercises (cursor-attach.test.js:71), and classify_hook.js:59 carries the identical pair.
11. The node_engine: ">=22.12" floor is not this PR's to answer - reject
Covered in finding 6 above: root engines, two shipped plugins and llp/0123 T1 all set the same floor for the same builtin.
Round 1's open findings, re-affirmed
I re-checked each of the ten deferrals and three rejections against this head. All still hold.
| # | Round 1 finding | Round 2 |
|---|---|---|
| 4 | CURSOR_VERSION blast radius unestablished |
defer - see finding 5; sharpened with the LLP 0253/0263 scoping and a failed search for a non-invented discriminator |
| 5 | Head-of-line blocking | defer - see finding 4; re-argued, and the skill's per-callback framing corrected |
| 6 | Unknown tool field discards the whole conversation | defer - native.js:358 unchanged; still fail-closed by design and observable via cursor.recovery.incomplete |
| 7 | Undocumented prompt-suppression field | partially fixed - see finding 1; the coverage half is closed, the semantics half stays with acceptance step 2 |
| 8 | File-scope guard keys on the literal Read |
defer - recovery.js:28 unchanged; still additive over the repo baseline (finding 2 hardened the block loop around it, not the name match) |
| 9 | Non-converging session re-reads every sweep | defer - bounded; acceptance step 5 catches it |
| 10 | Picker probes .cursor/hooks.json |
defer - detection only seeds a checkbox; init.js:177 carries cursor in INIT_SOURCE_CHOICES and cursor-picker.test.js:14 pins it |
| 11 | Four unexercised fixture files | defer - retained evidence, zero runtime cost |
| 12 | unknown_entrypoints always equals callbacks |
defer - see finding 9 |
| 13 | Per-callback stderr noise when the daemon is down | defer - real trade-off |
| 14 | previous_message_id: [] |
reject - LLP 0399#identity settles it verbatim |
| 15 | is_error !== null in the shared projector |
reject - re-verified: no other in-tree producer emits is_error: null, and ai-gateway-message-projector.test.js:681 pins all four cases |
| 16 | CURSOR_HOME bypass |
reject - LLP 0399#attachment states it; cursor-attach.test.js:20-23 pins it with all three env vars set wrong at once |
One thing neither round 1 nor the skill checked, and the riskiest shared-file edit in the diff: stripManagedHooks at client_detach_disk.js:635 now runs if (isManagedHandler(group, command) && groupMatcherEquals(group, matcher)) continue for every client's disk detach, not just Cursor. It cannot reach a Claude-shaped group: isManagedHandler requires group.type === 'command' and group.command === command, and a Claude group is { matcher, hooks: [...] } with neither. The only shape it newly removes is a bare command handler whose command exactly matches one the marker recorded as owned, which is Cursor's shape and is by definition marker-owned. cursor-attach.test.js:41-45 proves a user-edited command in that position survives detach verbatim.
Explicit CPU and memory pass
Required by CLAUDE.md. I walked the changed code and affected paths independently of round 1: the hook receiver, the live recovery queue, the synchronous native reader, the scheduled sweep, the shared message_projector and stripManagedHooks edits, and my own three changes.
No unbounded growth, and no new concern. Every retained structure is capped in code, not merely documented: request bodies (4 x 1 MiB, listener.js:127/:139), pending recovery (64 queued, 16 per pass, listener.js:108/:53), live root fingerprints (64 FIFO, listener.js:86), sweep fingerprints (1000 FIFO, pruned against present sessions, recovery.js:112/:76), blob reads (4096, native.js:249), graph bytes (32 MiB cumulative, 1 MiB per record, native.js:254/:185), wire fields (16384, native.js:203), discovery (1000 editor headers + 1000 CLI dirs, and hitting the cap is reported, native.js:83/:97), identity strings (256 chars), hook subprocess (1 MiB stdin, 2 attempts, 2.5 s deadline, hook.mjs:5/:6/:24), and both the exchange writer and the usage-policy memo rotate every 1024 callbacks (listener.js:250, :160). Shutdown releases everything: listener.js:293-302 clears the timer, drains pending, closes connections and awaits the chain; activeRecoveries is released in a finally on both lanes (listener.js:97, recovery.js:119); the SQLite handle closes in a finally (native.js:380).
Repeated work, called out as the rule requires. Per-item cursorAdmission construction inside the recovery loop (listener.js:63) re-walks ancestors with existsSync rather than reusing the resolver's TTL memo: construction is allocation-only, it is bounded at 16 items per pass, and the freshness is privacy-favorable, so accepted as deliberate. The double beforeReadFile admission (listener.js:220 and :242, four realpath per observation) is deliberate and proven necessary by cursor-listener.test.js:153. Per completed turn the live lane re-reads the whole conversation graph, so cost over a long session is roughly quadratic in turn count; mitigated by the in-progress fast path (native.js:264, :278), the root-fingerprint short circuit before any graph walk (native.js:275-276), and 1 s debouncing that rearms only after the previous pass settles.
The one number worth measuring is latency, not leakage, and it is finding 4: synchronous decoding on the daemon's event loop, bounded at 32 MiB per graph and 16 graphs per pass. node:sqlite is stdlib, not a new dependency, and hermes/src/state_db.js:110 already uses it under the same declared floor.
My own changes cost nothing at runtime: two comments, one earlier break in a loop that already ran to completion, one help string, and two unit tests that read temp fixtures. No added allocation, no hot-path change; finding 2's fix strictly reduces work by exiting the block loop on the first rejection.
Verdict of the pass: no CPU or memory concern that should hold this PR.
Convention and privacy compliance
- No new runtime dependencies.
npm pack --dry-runships all tenplugins-workspace/cursorfiles includinghook.mjs, which imports onlynode:cryptoandnode:http. - Style: re-grepped the whole
origin/master...HEADdiff - no semicolons in added JS, no U+2014 anywhere, and no file in the changed set contains a NUL byte. - Types: JSDoc only, no
@typedef, no inlineimport('...');types.d.tsinterfaces imported through@importwith repo-root-anchored.jsspecifiers. - LLP: every
@refin the diff resolves. LLP 0399's#capture #identity #native-format #recovery #file-content #attachment #coexistence #resourcesall exist as{#anchor}headings, and the one cross-doc ref resolves tollp/0306-*.md:63 ### Endpoint-free clients {#endpoint-free-clients}.node scripts/llp-numbers.js check-> "1 LLP number minted against refs/remotes/origin/master, no collision". My changes add no@refand need no LLP: two tests, two comments and a help string, whichCLAUDE.mdnames explicitly as changes that get none. The one doc edit (docs/ACCEPTANCE.mdstep 2) lands in the same commit as the test that made it necessary. - Capture conventions: two lanes (live hook receiver plus bounded recovery, and manual
hyp backfill cursor);entrypointdistinguisheseditorfromclioff the native store (cursor_capture.js:132);backfill.window_days/sweep_cronbounding is inherited from the kernel viareadBackfillPolicyandresolveWindowrather than reimplemented (LLP 0359); detach removes only marker-owned entries and preserves a user-edited command verbatim. - Privacy:
CursorReadErrorcarries fixed codes only (native.js:20-24), storage errors are never logged (listener.js:270), and both are pinned by tests asserting'PRIVATE'never appears in status or errors. Finding 2's fix strengthens the file-content gate. My new tests assert counts, prompt text and admission outcomes only; they add no payload to any log or fixture.
Check results
All run in a clean worktree, with node_modules symlinked from the main checkout.
| Check | 9bbee912 |
After fixes | origin/master baseline |
|---|---|---|---|
npm test |
6569 pass / 3 fail / 1 skip | 6571 pass / 3 fail / 1 skip | 6537 pass / 3 fail / 1 skip |
npm run typecheck |
exit 0 | exit 0 | - |
npm run smoke -- cursor_capture |
ok | ok | n/a |
node scripts/llp-numbers.js check |
no collision | no collision | - |
npm pack --dry-run |
all 10 plugin files present | unchanged | - |
The 3 failures are the known environmental test/core/hyparquet-floor-pin.test.js cases. I did not take that on trust: I built a separate origin/master worktree and ran the file there, and the same three fail with the same names (# pass 6 / # fail 3):
not ok 3 - icebird uses the root parquet pins directly or through overrides
not ok 6 - every read-path dependency that carries hyparquet is held at the floor
not ok 9 - the read path resolves the one root hyparquet, not a nested copy
No other failure, and none introduced by this PR or by my fixes. The pass count rises by exactly 2, the two tests this round added.
Disposition summary
| # | Finding | Severity | Disposition |
|---|---|---|---|
| 1 | Prompt-suppression predicate unexercised (round 1 called it unfixturable) | medium | fix (landed, mutation-proved) |
| 2 | A refused file block could be reinstated by the next block | medium | fix (landed, mutation-proved) |
| 3 | hyp setup --help omits cursor from --client |
low | fix (landed) |
| 4 | Hook write blocks behind a synchronous graph decode | medium | defer (decoupling would not fix it; acceptance step 9) |
| 5 | CURSOR_VERSION skip also disables Claude's down-daemon spool sweep |
medium | defer (no non-invented discriminator; acceptance step 8) |
| 6 | openDb collapses a missing builtin into a read failure |
low | defer |
| 7 | Recovery contention counted as a failure and shown as lastError |
low | defer (pinned by an existing test) |
| 8 | lifecycle_events counts every non-beforeReadFile callback |
low | defer (status-field naming) |
| 9 | unknown_entrypoints always equals callbacks |
low | defer (accurate but redundant) |
| 10 | hook_command.js:71 unreachable through the binary |
- | reject (defense in depth; pinned by the round-1 assertion) |
| 11 | node_engine: ">=22.12" is wrong for node:sqlite |
- | reject (repo-wide settled floor: root engines, hermes, github, LLP 0123 T1) |
No blockers. Two things still need a real Cursor before this ships, and both now have a written acceptance step pointing straight at them: what typed user field 5 actually means (step 2, with the predicate now pinned and labelled, and no prompt lost in any of the three inspected stores), and how long a recovery pass actually holds the daemon's event loop (step 9).
Finding disposition — PR #1640, head
|
| # | Finding | Disposition | Evidence / issue |
|---|---|---|---|
| 1 | --help suppressed under CURSOR_VERSION |
resolved (round 1 fix holds) | hook_command.js:62-65 help branch first, skip at :72; round 2 proved the regression assertion non-vacuous by restoring the pre-fix blob |
| 2 | Unreachable lifecycle_only branch |
resolved (round 1 fix holds) | listener.js:233-234 collapsed; lifecycle_events still counted at :229; no reader lost a value |
| 3 | Gate/smoke missing from AGENTS.md |
resolved (round 1 fix holds) | AGENTS.md:190 procedure entry, :318 smoke entry, both verified at head |
| 4 | CURSOR_VERSION blast radius unestablished |
defer (preference) | No observed misbehavior; no non-invented discriminator exists; ACCEPTANCE step 8 is the settling instrument → #1653 |
| 5 | Hook write blocks behind synchronous graph decode | defer (defect, neutral:fix) |
Sync readCursorSession stalls the whole daemon event loop; hook destroyed at 800 ms prints a misleading unavailable notice for a row that lands; no data loss (delivery_id pre-allocated, pinned by cursor-listener.test.js:57-75); real fix is a worker-thread redesign sized by ACCEPTANCE step 9 numbers → #1654 |
| 6 | Unknown tool field discards the whole conversation | defer (preference) | Deliberate fail-closed per LLP 0399, observable via native_tool_shape; softening changes a Draft LLP's decision → #1655 |
| 7 | Prompt-suppression field undocumented and unexercised | resolved (round 2 fix 1) | Test at cursor-native.test.js:83 (mutation-proved), comment at native.js:331-334, ACCEPTANCE step 2 corrected; semantics half stays with the shipped acceptance step |
| 8 | File-scope guard keys on the literal Read |
defer (preference) | LLP 0399#file-content decides the scope ("Other tool results use the existing workspace policy contract"); additive over the repo baseline; gate now fails closed within its scope → #1656 |
| 9 | Non-converging session re-reads every sweep | defer (preference) | Bounded per pass; the alternative silently loses data; ACCEPTANCE step 5 surfaces it → #1657 |
| 10 | Picker probes .cursor/hooks.json |
defer (preference) | Detection only seeds a checkbox; needs real-install evidence → #1658 |
| 11 | Four unexercised fixture files | defer (preference) | Deliberately retained sanitized evidence per docs/cursor-capture-evidence.md; zero runtime cost → #1659 |
| 12 | unknown_entrypoints always equals callbacks |
defer (preference) | Accurate but redundant (listener.js:162); status-shape design call → #1660 |
| 13 | Daemon-down stderr noise per callback | defer (preference) | Silent hooks would be worse; rate-limit pending acceptance evidence → #1661 |
| 14 | previous_message_id: [] on native rows |
reject | LLP 0399#identity verbatim at head: "No parent chain is invented from callback arrival or typed completion order" |
| 15 | is_error !== null shared-projector change |
reject | Deliberate, LLP-documented, required by native.js unknown outcomes; four-case matrix pinned at ai-gateway-message-projector.test.js:681-690; no other in-tree producer emits is_error: null |
| 16 | CURSOR_HOME bypass |
reject | LLP 0399#attachment states it; client_settings_path.js:114-117 @ref honest; cursor-attach.test.js:20 pins all three env overrides wrong at once |
Round 2 findings (ordinals 17-27)
| # | Finding | Disposition | Evidence / issue |
|---|---|---|---|
| 17 | Suppression predicate unexercised (fix 1) | resolved | Landed and verified at head; mutation-proved both directions |
| 18 | Refused file block reinstated by the next block (fix 2) | resolved | recovery.js:33-43 breaks on every rejection, including the catch path; pinned by cursor-native.test.js:147; the privacy gate fails closed structurally |
| 19 | hyp setup --help omits cursor (fix 3) |
resolved | core_commands.js:359 lists cursor, opencode (repeatable) |
| 20 | Hook write blocks behind graph decode | duplicate of ordinal 5 | Round 2's own text: "it is round 1's finding 5"; consolidated into #1654 |
| 21 | CURSOR_VERSION skip disables the down-daemon spool sweep |
duplicate of ordinal 4 | Round 2's own text: "both are one finding, and it is round 1's finding 4"; the LLP 0253/0263 scoping is carried into #1653 |
| 22 | openDb collapses every failure into one code |
defer (preference) | Correct fail-closed; granularity nicety mirroring hermes → #1662 |
| 23 | Recovery contention counted as failure / lastError |
defer (preference) | Deliberately pinned by an existing test; self-clearing; asymmetry with recovery.js:89-91 noted → #1663 |
| 24 | lifecycle_events misnamed |
defer (preference) | Pre-existing at 0c53b2b4; naming-only status-shape change → #1664 |
| 25 | unknown_entrypoints always equals callbacks |
duplicate of ordinal 12 | Consolidated into #1660 |
| 26 | Handler-level env guard unreachable through the binary | reject | Defense in depth for direct/library invocation; exercised by round 1's regression assertion; identical pair in classify_hook.js |
| 27 | node_engine: ">=22.12" wrong for node:sqlite |
reject | Repo-settled floor: root package.json:70-72, hermes, github, LLP 0123 T1 |
Duplicates are recorded as reject in the machine-readable disposition solely so one finding mints one issue; the table above is the authoritative human account.
Deferral integrity
Every deferral has a real issue keyed to this head. Only #1654 carries neutral:fix (the one deferral with an observed incorrect behavior, an expected behavior, and a testable acceptance condition); the eleven preferences stay backlog until a human delegates them. The two things that still need a real Cursor before release remain exactly where round 2 left them: what typed user field 5 means (ACCEPTANCE cursor step 2) and how long a recovery pass holds the daemon's event loop (step 9, now also tracked by #1654).
Ship risk:
|
Cursor's native hooks omit intermediate assistant replies and reduce some tool results to summaries, even for a 32-byte file. This adds a bundled Cursor adapter that recovers conversation messages and richer tool results from the editor and CLI's saved session databases.
Native message identities are authoritative. Hooks trigger bounded recovery and retain the additional
beforeReadFileobservations. Replaying live or historical records converges without duplicate conversation rows, and native results correctly preserve CLI Shell rejections that hooks reported as ordinary completions.Changes
hyp backfill cursor, and scheduled recovery using the existing backfill controls.Validation
npm test: 6,570 passed, 3 skipped.npm run typecheckandnpm run smoke -- cursor_capture: passed. The smoke checks actual storage/query behavior and capture, recovery and privacy telemetry.Limits and release acceptance
The adapter reads a private, version-specific Cursor format. Normalized token accounting, complete subagent graphs and revision history remain unsupported. Live editor hook delivery, interactive CLI completion, additional interruption/resume/regeneration scenarios, and installed-client acceptance remain documented release checks in
docs/ACCEPTANCE.md. Editor evidence above verifies saved-session recovery, not live hook delivery.CPU and memory
No runtime dependencies added. Queues, record sizes, graph reads and fingerprint caches are bounded; unchanged roots skip graph decoding and storage dedupe. The CPU/memory pass found no unbounded new queue or lifetime content retention. Synchronous large-graph parsing and shared waiting-spool scans remain latency concerns for the release acceptance measurements.