Re-pin: follow a /clear that happens inside a worktree - #57
Conversation
Every pin path asked `cwd === workdir` of a candidate conversation, and that quietly broke every agent that works in a git worktree. The PTY starts in the session's folder, the agent enters `.claude/worktrees/<name>`, and the conversation a later `/clear` starts records that deeper cwd. Equality rejected it from both directions — the SessionStart breadcrumb as 'cwd mismatch', the transcript scan by skipping the file — so the pin stayed on the abandoned conversation for the rest of the pane's life. Two things went wrong with that. The reader reads the pin, so it kept showing the pre-/clear conversation while the terminal showed the new one: one session, two different conversations side by side. And the next launch runs `--resume <pinned uuid>`, which would restore the old thread and discard everything since — the failure this watcher was written to prevent. Observed live: a session pinned edbfc11f… while claude was writing b1e23587… under `.claude/worktrees/session-sharing`. So ask for containment instead of equality, in one helper the three harnesses share (claude's scan and crumb, codex's rollout head, opencode's database row). Containment rather than a prefix test, so `/w/proj-a2` is not inside `/w/proj-a`. `folderIsShared` widens with it: now that the scan reaches below the folder, a live sibling of the same harness in a subdirectory — or in a parent, which is what a session on the workspaces root is — is exactly as ambiguous as one in the same directory, and gets the same refusal to guess. Nothing is lost where it fires; breadcrumbs are the mechanism and the scan is their backstop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The core case works: I built the shapes this PR is about against the real code and a 1.
|
Ran it live, PR code vs
|
main |
this branch | |
|---|---|---|
| pin before | c1b290cf |
ae774093 |
new conversation in <folder>/deep |
1e6cecae |
390e55b5 |
| pin after | c1b290cf — unmoved |
390e55b5 — followed |
| what the reader renders | ANSWER FROM c1b290cf in proj-a |
ANSWER FROM 390e55b5 in deep |
Server logs, same hook event, same pane shape:
main [claude] crumb-a34035: exact breadcrumb rejected (cwd mismatch)
fix [claude] re-pinning crumb-dad84a: ae774093… -> 390e55b5… (exact clear)
That second row is the reported symptom, reproduced and then fixed: on main the
reader keeps rendering the pre-/clear conversation while the pin is stale; on
this branch it moves to the conversation that actually exists.
2. The transcript-scan backstop — no crumb at all
main |
this branch | |
|---|---|---|
pin after a new conversation appears in <folder>/deep |
c8dbce3c — unmoved after 30s |
0963081e→ followed in 14s |
| log | (silent — the file was skipped) | re-pinning scan-4968ee: db0f2017… -> aaa572ed… (conversation was replaced (/clear)) |
14s is consistent with the cadence: first tick at 5s, then every REPIN_MS.
3. Guard: the widened folderIsShared still refuses to guess
The riskiest part of this change is that the scan now reaches below the folder,
so I checked the new ambiguity case on a live server: session on ov-parent, a
second live session on ov-parent/child, then an unattributable conversation
appearing in ov-parent/deep.
refused_to_guess: true
[claude] ov-parent-45ad3b: folder shared with another live session — following /clear only via breadcrumbs here
[claude] ov-child-5c4296: folder shared with another live session — following /clear only via breadcrumbs here
Pin unmoved, refusal logged for both, and breadcrumbs remain available there —
which is the intended trade. Non-overlapping folders still scan normally, which
is what §2 above demonstrates.
Still not verified
No browser was driven: "what the reader renders" above is the /api/trace/:id
payload the reader renders from, not a screenshot. And this ran against isolated
servers, not against the deployed one — the live Space still runs main, so the
pins currently stranded there (including the one that started this) stay stranded
until this lands and the panes are restarted.
The symptom
A session's reader and its terminal showed two different conversations. Same
pane, same agent: the terminal was mid-conversation while the reader kept
rendering a thread that had ended hours earlier.
Why
The reader reads
/api/trace/:id, which resolves tosession.sessionUuid— thepin.
runner.jskeeps that pin honest with two mechanisms (a SessionStartbreadcrumb from the pane, and a transcript scan as its backstop), and both
asked
cwd === workdirof a candidate conversation.That equality quietly broke every agent that works in a git worktree. The PTY
starts in the session's folder; the agent enters
.claude/worktrees/<name>; andthe conversation a later
/clearstarts records that deeper cwd. So thebreadcrumb was rejected as
cwd mismatchand the scan skipped the file, and thepin stayed on the abandoned conversation for the rest of the pane's life.
Observed live on this Space: a session pinned
edbfc11f…while Claude waswriting
b1e23587…under.claude/worktrees/session-sharing./tmp/am-repinshowed a crumb written and consumed with the pin unmoved.
The wrong reader is the visible half. The other half is worse: the next launch
runs
--resume <pinned uuid>, so a restart would restore the pre-/clearthread and discard everything since — precisely the failure this watcher was
written to prevent (see the comment above
scheduleClaudeCapture).The change
One helper, containment instead of equality:
Containment rather than a prefix test, so
/w/proj-a2is not inside/w/proj-a.Applied at all four places that asked the same question, because the other three
harnesses carried the identical one-liner and Codex agents here work in worktrees
too:
claudeCandidate)head.cwd !== workdirbreadcrumbVerdict)crumb.payload.cwd !== facts.workdirtryCaptureCodexId)mp.cwd !== workdirapplyBreadcrumb)row.directory !== workdirfolderIsSharedwidens with it. Now that the scan reaches below the folder, alive sibling of the same harness in a subdirectory — or in a parent of it,
which is what a session on the workspaces root is — is exactly as unattributable
as one in the same directory, and gets the same refusal to guess. That costs
nothing where it fires: breadcrumbs are the mechanism, and they carry their own
pane/run/pid attribution; the scan is only their backstop.
Attribution is not loosened anywhere else. A crumb still has to match
AM_ID,AM_RUN_ID, the harness, an unclaimed conversation id, and a pid that is thepane root or its direct child — so a nested
claude -pin a subdirectory stillcannot speak for the pane.
What I verified
A control first, because a test that passes both ways proves nothing. With
the fix reverted, against the same fixtures the new tests use:
With it applied, the scan claims the worktree conversation and the crumb
re-pins. Both cases, plus eight containment unit cases (the folder itself, a
worktree below it, a plain subdirectory, a prefix neighbour
proj-a2, theparent, elsewhere, and null inputs), are now in
server/test/repin.test.mjs.Full server suite green, exit 0:
spawn-group28,repin67,opencode-resume32, plusterminal-modes,trace-tail,migration(whichboots a real server) and
resize.That run needs
@coder/libghostty-vt-node, which a fresh worktree does not have— from the worktree alone you get 65/67 (the two live-pane checks skip) and a
spurious
migration.test.mjsfailure that is only the missing dep. I ran thesuite from a clone with a real
node_modulessymlinked in to confirm it passeswith deps present.
What I did NOT verify: I did not drive a browser, and I did not watch this
fix repair a live pin end to end. The re-pin needs a conversation born inside a
launch window, so proving it live means restarting a pane on this build and
/clear-ing inside a worktree — the deployed server is stillmain. Theevidence here is the control-vs-fix pair on real transcript fixtures plus the
live diagnosis that produced the same
cwd mismatchverdict.Not in scope:
share.js:217has the same equality in its cwd fallback.It only runs when the pin matches nothing on disk, and a correct pin makes it
moot, so widening the share path's guesswork belongs in its own change.
🤖 Generated with Claude Code