Fix #1891: Forking an ignored session (claude --fork-session, codex fork) records the whole parent conversation under the new id; a SessionStart hook can close it on both clients by philcunliffe · Pull Request #1897 · hyparam/hypaware · GitHub
Skip to content

Fix #1891: Forking an ignored session (claude --fork-session, codex fork) records the whole parent conversation under the new id; a SessionStart hook can close it on both clients - #1897

Open
philcunliffe wants to merge 2 commits into
masterfrom
fix/issue-1891

Conversation

@philcunliffe

Copy link
Copy Markdown
Contributor

Feature or issue

hyp session ignore persists exactly one key per opt-out, the raw session id, and every recorder drops on that id alone. claude --resume <id> --fork-session (and /branch) mints a new id over a copy of the whole parent transcript, and the fork's first request replays the full parent history as context. So ignoring a conversation and then forking it recorded all of it under the fork's id, live and through backfill. Issue #1891 verified the mechanism locally: forked transcripts keep every copied line's uuid unchanged and rewrite only sessionId (9 transcript pairs in ~/.claude/projects share their leading uuids line for line, one of them a three-way chain). The product knew and pushed it onto the user (hypaware-reference/SKILL.md, LLP 0403 line 20); nothing enforced it.

Solution

  • Store (src/core/control/session_ignore_store.js). hyp session ignore now also stores a fork fingerprint beside the id marker under session-ignores/: the leading (at most 8) transcript line uuids, read from the transcript_path the session-context channel already records, and nothing else. The loader learns the .fingerprint.json suffix while still rejecting any other filename as corruption; fingerprint bytes count toward the existing 4 MiB ceiling, a write that would overshoot it is refused (overshooting would make the next load refuse the whole store and stop capture everywhere), and delete removes marker and fingerprint together. No existing drop path changed.
  • Claude lane (claude/src/hook_command.js). The managed hook, on SessionStart with source: "fork" (repeated idempotently on UserPromptSubmit), matches any one uuid of the new transcript against any stored fingerprint and, on a hit, adds the new id by running hyp session ignore in this same install: the same persistent marker plus POST to every live control route (LLP 0256 #cli-posts-to-both), nothing reimplemented. Any-match covers a fork of a fork with no record per generation. The ordinary path costs one stat plus one readdir that stops at its first entry. The receipt now states whether fork protection was armed or is UNCONFIRMED for this id, in prose and as fork_protection in --json, because silence would read as armed.
  • Proof. New tests fail at the merge base and pass here. test/plugins/claude-fork-guard.test.js drives the real verb against a real control route over a real SessionIgnoreSet, then the real hook against a forked transcript; before the fix, "the live recorder drops the fork" is expected: true, actual: false, and unignore removes the fingerprint is expected: 1, actual: 0. The false-positive direction (a fork of an un-ignored session must not be ignored) has its own case and passes both before and after, so it is a guard against silently destroying capture rather than a symptom of the fix. npm test: 6924 tests, 3 failures, identical to the merge base's hyparquet-floor-pin trio from stale local node_modules; npm run typecheck: the one pre-existing src/core/otlp/server.js(85,51) error, unchanged; smokes claude_telemetry_session_ignore, gateway_claude_capture, claude_telemetry_capture, hypignore_capture_drop all green.
  • Not covered, deliberately. The issue's Codex lane is not implemented and Forking an ignored session (claude --fork-session, codex fork) records the whole parent conversation under the new id; a SessionStart hook can close it on both clients #1891's acceptance is therefore only partly met: no SessionStart hook is installed into Codex's hooks.json, hyp leave reverses nothing new, and no hook-trust requirement is surfaced. That lane needs a second managed-file perimeter on a client whose attach probe is single-file today, and its match key has a container-versus-thread grain (session_meta.forked_from_id vs the id the recorder stamps) that no fixture on this machine can settle; it is left to its own change rather than landed unproven. It is named as open in LLP 0419 #scope and the skills still tell users a codex fork needs its own exclusion. Also out of scope per the issue: purging content recorded before the ignore, and retroactively excluding forks made before the parent was ignored.
  • Docs. New LLP 0419 (decision) records the design; LLP 0403's fork sentence and header gain the Extended-by: forward-ref (editorial only, nothing it settled was edited). Both hypaware-reference and both hypaware-privacy skill copies and docs/PRIVACY.md are updated, the outdated codex/src/index.js "Codex has no SessionStart context-injection hook" comment is corrected, and a claude_fork_session_optout acceptance procedure is added for the two upstream facts no fixture can settle.

Code: +622 / -15 lines

Fixes #1891

`hyp session ignore` holds one key per opt-out, the raw session id, and
`claude --fork-session` mints a new one over a copy of the whole parent
conversation. An opted-out conversation was therefore recorded in full
under the fork's id, live and through backfill, and the product pushed
the problem onto the user.

Claude exposes no parent id, so the parent is identified by what the copy
keeps: each copied line's `uuid` is carried over unchanged while
`sessionId` is rewritten. `hyp session ignore` now stores the leading
uuids of the session's transcript as a fork fingerprint beside the id
marker, and the managed hook matches any one of them on SessionStart
(`source: "fork"`) and UserPromptSubmit, adding the fork's id through the
ordinary `hyp session ignore` path.

LLP 0419 records the decision; LLP 0403's fork sentence gains the
forward-ref. The Codex lane of the issue is not implemented and is named
as open in LLP 0419 #scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

… key

Review of 3dec943 (LLP 0419).

- A fingerprint is now written only once the id marker is on disk. The
  Cursor and OpenCode listeners answer the control route over a
  process-lifetime `Set`, so a confirmed 200 from one of those wrote a
  fingerprint with no marker beside it: an exclusion no shipped verb can
  remove, and a fork guard that re-spawns `hyp session ignore` on every
  prompt of that session without ever settling.
- `FINGERPRINT_TOKEN` is a canonical uuid rather than any 8-128 character
  token. The any-match rule reads one shared value as proof of a copied
  conversation, which holds only while the values are random; a short or
  constant token in that set would match every fingerprint at once and
  ignore every session on the machine.
- One unreadable fingerprint no longer aborts the whole match walk, so a
  concurrent unignore cannot cost the fingerprints after it their turn.
- The spawned opt-out passes the id after `--`, so a client-supplied id
  opening with a dash is not read as an unknown flag on every prompt.
- LLP 0419 records the marker precondition, the uuid bound, and the limit
  a reviewer found: unignoring one member of a fork family leaves the
  siblings' fingerprints matching, so the guard re-adds the id. That
  residue is over-exclusion, never a recorded conversation the user opted
  out of, and what `unignore` should mean for a conversation filed under
  several ids is left to its own change. The acceptance procedure's step
  12 is corrected to unignore the whole chain.
- 0419 #verification names `claude_fork_session_optout`, the procedure
  this change adds, rather than only `claude_otel_shape_check`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Verdict: approve with fixes landed. Full PR review of 3dec9434 against base master, comparison base eee50a73a4129f80acbb30b857f9a771de91af0f (merge base). 9 findings: 5 fixed and pushed, 4 deferred. New head dd047dd5a5d5974f010e577da269850cceb9b89d.

The mechanism is sound and the fail-closed direction is right throughout. Nothing found could cause the defect this PR closes (an opted-out conversation recorded under a fork's id). Two of the fixes close paths that could have destroyed capture for a session nobody opted out of, which review priority 1 names as worse than the bug.

Baseline confirmed at the merge base myself: hyparquet-floor-pin 3 failures and src/core/otlp/server.js(85,51) typecheck error are pre-existing and unchanged. At the new head: 6925 tests, same 3 failures, same 1 typecheck error, +1 test.


1. A fingerprint could be written with no marker beside it, looping the guard forever - high, fixed

session_command.js:445 wrote a fingerprint on any confirmed 200. But the Cursor listener answers this route over a plain Set (cursor/src/index.js:36 -> cursor/src/listener.js:157, advertised at :330), as does OpenCode, so ignored: true can come back with no marker on disk. Then in hook_command.js:254 hasSessionIgnoreMarker is false, :255 a fingerprint exists, :257 it matches (a fingerprint matches its own session), :259 spawns node bin/hypaware.js session ignore - and the next UserPromptSubmit does it again, one 20s-budget subprocess per prompt, indefinitely. The orphan is also unremovable: unignore against a plain-Set recorder deletes nothing from disk.

Fixed: the marker, not the reply, arms the fingerprint (session_command.js:564). fork_protection: armed now means a durable exclusion exists. New test a recorder that persists nothing arms nothing, and the receipt says so (claude-fork-guard.test.js:200) drives the plain-Set shape.

2. FINGERPRINT_TOKEN did not enforce the property any-match rests on - medium, fixed

session_ignore_store.js:46 was /^[A-Za-z0-9._:-]{8,128}$/, while LLP 0419 #any-match justifies the whole rule with "transcript uuids are random v4, so one shared value is not a coincidence". Nothing checked that, on the write side or the match side. A single non-random uuid value from a future client build (msg_..., a constant, a short token) would match every stored fingerprint at once and silently ignore every session on the machine.

Not reachable today: 20 real transcripts sampled, every head line type either carries a distinct v4 uuid or no uuid at all (queue-operation, mode, permission-mode, file-history-snapshot, last-prompt carry none and are correctly skipped), 0 collisions across files. But the code accepted far more than the argument allows. Fixed to a canonical uuid, any version digit, so a move to v7 keeps protection (session_ignore_store.js:53).

3. One unreadable fingerprint aborted the whole match walk - medium, fixed

sessionForkFingerprintMatches's readFileSync was unguarded while the lstatSync two lines above was not. An EACCES/ENOENT from a concurrent unignore propagated to walkExclusionDir's catch { return undefined } and reported "no match" for every fingerprint after that entry. Fail-open is the right direction, but the blast radius should be one file. Fixed at session_ignore_store.js:342.

4. The spawned opt-out passed the id positionally - low, fixed

hook_command.js:311 was ['session', 'ignore', sessionId, '--json']; parseArgv refuses an argument starting with -, so a client-supplied id opening with a dash would fail the spawn on every prompt for the life of the session. Fixed to ['session', 'ignore', '--json', '--', sessionId] (:315) - the verb already supports --.

5. LLP 0419 #verification named the wrong acceptance gate - low, fixed

It pointed at claude_otel_shape_check as the release gate for fork drift while this same PR adds claude_fork_session_optout for exactly that. Both are now named (0419:194).


6. Unignoring one member of a fork family does not release it - high, deferred, needs a decision

Each auto-ignored fork gets a fingerprint of its own on the way through (LLP 0419 states this), and every fingerprint in a chain holds the same leading uuids. delete removes only the pair for the id it is given, so:

  • hyp session unignore $B in the fork -> B's marker goes, but B's head still matches A's fingerprint -> the guard re-adds B on its next prompt.
  • hyp session unignore $A in the parent -> A's head still matches B's fingerprint -> A is silently re-ignored.

Both print a success receipt saying the id is out of the drop set. The hook's stderr notice is invisible in practice (the hook exits 0, so Claude does not surface it). Verified empirically against the real modules. The existing test misses it because it unignores A before any fork was auto-ignored, so only one fingerprint ever exists.

Deferred rather than fixed because every candidate fix trades one silent wrong for another: removing matching siblings' fingerprints would lose fork protection for a parent that is still ignored, and suppressing the fork's own fingerprint fixes only one of the two directions. What unignore means for a conversation filed under several ids is a question LLP 0403 did not settle either. The residue is over-exclusion, never a recorded conversation the user opted out of, so it does not block this change.

Landed instead: LLP 0419 #scope records the limit verbatim (0419:163), and docs/ACCEPTANCE.md step 12 is corrected - as written it told a human to unignore only $A and expect a fresh fork to be recorded, which would have failed the run and been read as a regression.

7. A fork taken with no live recorder is not covered, and says so invisibly - medium, deferred

ignoreSessionViaCli runs the real verb, which returns SESSION_EXIT_UNKNOWN when no recorder advertises the route (session_command.js:396). Fork while the daemon is down and no marker is written; the warning goes to stderr on an exit-0 hook, so nobody sees it, and a later transcript backfill imports the fork in full. Inherited from hyp session ignore itself (a hand-typed opt-out with the daemon down behaves the same way), so not a regression, and fixing it means changing what the verb guarantees. The code comment's "loudly enough to find" overstates what a user actually sees.

8. Raising FORK_FINGERPRINT_UUIDS later would brick capture on a rollback - medium, deferred

parseFingerprint treats length > FORK_FINGERPRINT_UUIDS as damage, and load() turns damage into LOAD_ERROR, which disables capture everywhere. A well-formed-but-longer list is version skew, not damage. Speculative until someone changes the constant, and that release would be a durable-store change gated accordingly. Same class: an older build meeting any .fingerprint.json at all refuses the store, so a downgrade across this release needs the directory cleared.

9. Reuse and duplication - low, deferred

transcript_fingerprint.js:26,47 re-declare the 512 KiB context tail and re-derive the plugins/@hypaware/claude/session-context.jsonl path, both of which exist in the plugin as SESSION_CONTEXT_READ_TAIL_BYTES / defaultSessionContextFile. The file's own header explains why it cannot import the plugin, so the copies are deliberate, but they can drift with nothing to catch it. Related: the hook appends to the --state-file baked into settings.json at attach time while the verb recomputes the root from the current env, so a moved HYP_HOME yields unconfirmed while looking healthy.


Verified clean

  • False positives. Any-match is safe given uuid randomness, which finding 2 now enforces. The un-ignored-fork test is not vacuous: it runs the full path (fingerprint present, guard live, transcript read, matcher consulted) and stops only at the match. Subagent transcripts go to subagents/agent-*.jsonl, never named in transcript_path, and subagents emit neither hook event. Across the real session-context channel: 0 transcripts shared by more than one session id, 0 sessions with more than one transcript path.
  • False negatives. The UserPromptSubmit backstop is genuine and covers a copy landing after SessionStart; the guard is idempotent and short-circuits on one stat once ignored (asserted by harness.calls being empty on the repeat). Empty, truncated, and >64 KiB-head transcripts all yield zero uuids, which never matches and is reported as UNCONFIRMED rather than silently assumed armed.
  • Privacy of the mechanism. A fingerprint is uuids only; nothing logs, stores, or emits transcript content, paths, or prompts. The filename is the sha256 of the id. No transcript path on record -> marker written as today, receipt says UNCONFIRMED in prose and as fork_protection in --json.
  • Store. Unknown filenames are still corruption (session_ignore_store.js:100), and the tmp-file regex genuinely matches what atomicWriteFileSync produces for both names (executed against real tmpPathFor output), so a crash mid-write cannot brick the store. Ceiling accounting is real and conservative in the safe direction; delete clamps at 0 for the cross-process case.
  • The rewritten test asserts the new truth, not a weaker one: it pins the UNCONFIRMED branch positively and adds assert.doesNotMatch on the removed blanket claim. The armed branch is asserted separately in claude-fork-guard.test.js.
  • LLP discipline. git diff origin/master...HEAD -- llp/0403*.md | grep -c '^-[^-]' = 0; 0403 gained only the Extended-by: header and an italic forward-ref. All four new @refs resolve to real 0419 anchors. node scripts/llp-numbers.js check: 1 number minted, no collision.
  • Acceptance procedure. Runnable as written, and it names the two upstream facts no fixture can settle (copied lines keeping uuid, the hook still seeing source: "fork") plus the silent failure mode. Step 11 is the false-positive direction; step 12 corrected per finding 6.
  • Codex lane. Deliberately absent and honestly recorded in the PR body, LLP 0419 #scope, docs/PRIVACY.md, and all four skill copies. Nothing half-built left in the codex plugin: the only change there is a comment correction.
  • Style / deps. No semicolons in code, no em dashes anywhere including LLP and docs prose, no NUL bytes, JSDoc only, no @typedef, no inline import('...') types, no new runtime dependencies, no invented config keys.

CPU and memory pass

Live capture is untouched: the drop stays an O(1) Set lookup over id markers and a fingerprint never enters that set. Store loads are per refresh, not per message. The hook's ordinary path is one stat plus a readdir that stops at its first entry, both far below the two git subprocesses the same hook already spawns per event. walkExclusionDir streams rather than materialising a listing, and every file handle closes in a finally.

Two costs worth naming, neither a blocker:

  • Once any fingerprint exists, every UserPromptSubmit of every Claude session - not just forks - pays a 64 KiB head read plus a split('\n') over the whole buffer (transcript_fingerprint.js:114), materialising every line before the loop breaks at 8 uuids, plus a walk that reads every stored fingerprint. LLP 0419 acknowledges the walk and bounds it by the store's 10,000-id ceiling; it does not acknowledge that the read and split happen for non-forks too. An incremental indexOf('\n') scan would remove the split allocation for nothing. Deferred.
  • writeSessionForkFingerprint lstats every entry per opt-out. Person-initiated, and the walk is what buys the difference between "this fork is not covered" and the ceiling overshoot that would refuse the whole store.

No unbounded growth, busy loop, or retained allocation. Finding 1's per-prompt subprocess loop was the one genuine unbounded-work path and it is fixed.


Fixed and pushed as dd047dd5, verified in the committed tree against 3dec9434: session_command.js:564 (marker guard) and :17 (import), session_ignore_store.js:53 (uuid token) and :342 (guarded read), hook_command.js:315 (--), 0419:78,163,194, ACCEPTANCE.md:2191, claude-fork-guard.test.js:200, session-ignore-fork-fingerprint.test.js.

@philcunliffe philcunliffe added the neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human label Sep 18, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human

Projects

None yet

2 participants