Skip to content

(sessions): follow /clear to the new transcript instead of listing it apart - #477

Merged
devsuitup merged 2 commits into
devsuitup:mainfrom
abate:fix/clear-rekey
Oct 11, 2026
Merged

devsuitup merged 2 commits into
devsuitup:mainfrom
abate:fix/clear-rekey

Conversation

@abate

@abate abate commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Now: the last review round only

#548 merged an older head of this PR (6923cd7), not the approved a468b5b. This branch is now main plus what 6923cd7 lacked:

  • After /clear the open entry gets a new session built as launchNewSession builds one, instead of inheriting the cleared conversation's title, star, bridge id and dates.
  • /reset, /new and /clear <name> are followed like /clear.
  • The trigger lock is keyed by the resolved session id again, so a chain and a new trigger share one queue across a PTY replacement or a /clear.
  • clearOwner reads the process table on Windows and never claims on macOS; held header-only files and fresh entries wait for indexing; owner and NO MATCH logs fire on change only.
  • applySessionForked is anonymous again; renderer tests capture the listener through a window.api.onSessionForked stub.
  • The changelog entry moves from v0.0.90 to Unreleased; .ai/contexts/trigger-watcher.md gets its carriage-return byte back.

Tests first: 6daa029 is red on all four test jobs, on assertions only (run 38144139365; 21 tests, two of them only on Windows). The test that captures the fork listener accepts main's named form so the red is an assertion, not a missing symbol; the fix commit drops that branch. The fix a88f03c is green on every job (run 38144648704).


Problem

/clear (and its aliases /reset, /new) makes the CLI open a new jsonl under a new session id. Nothing in it names the session it replaced (no forkedFrom; every record carries the new id), so detectSessionTransitions() never matched it: the open terminal stayed on the old row, and the new conversation appeared as a separate, unattached sidebar entry once its first prompt made it indexable.

Fix

  • Owner. The CLI rewrites ~/.claude/sessions/<pid>.json with the new sessionId on /clear. cliSessionState.clearOwner(newId, ptyPid) answers mine / other / pending / unknown. On Linux it walks the /proc parent chain of the pid that names newId up to the PTY. On Windows it uses (bg-agents): on Windows, Switchboard's own tabs show as External and count as live elsewhere #521's process table (anchor pid, creation times). On macOS the CLI writes no anchor, so the answer is other and the conversation is listed apart, as before. mine re-keys the session like a fork (session-forked, kind 'clear'). pending is rechecked once a second for up to 60 s. unknown is never matched.
  • Recognition. readNewSessionSignals() takes the first user record that is not bookkeeping. /clear, /reset and /new all count, with or without a name argument. A header-only file stays eligible until it shows its first turn.
  • Renderer. The open entry's session is replaced with a new "New session" row in the same project, built as launchNewSession builds one. It keeps none of the cleared conversation's title, star, bridge id or dates. That row is pending until its first prompt makes it indexable. The cleared conversation stays in the list as a past, resumable session.
  • Triggers. sessionTransitions.currentSessionId() resolves an old id to the current one, so a chain such as [/clear, reply] keeps reaching its terminal. An id held by a live session is never redirected. The per-session trigger lock queues every id that resolves to the same session.
  • Restore. A session with no transcript yet is saved as a fresh entry. On restore it is resumed if indexing found its transcript; otherwise it starts anew in that project, with the project defaults and no new worktree.

/clear writes no continued-in record. On CLI 2.1.296 the only writer is the background-session handoff, and none of 41 local /clear transcripts is the target of one. So #518's continuation restore does not redirect a click on the cleared conversation.

Docs: .ai/contexts/cli-session-state.md ("Owner of a /clear transcript"), .ai/contexts/trigger-watcher.md ("Re-keyed sessions", "Session serialization"), docs/session-restore.md ("A session with no transcript yet").

Tests

Tests-first:

  • Red: 037de9e, Test run 38069061167. All four test jobs fail with 37 failures, all of them new tests. The failures are assertions, apart from the new clearOwner / currentSessionId APIs being absent on main.
  • Green: fix a468b5b, Test run 38069643247.

The renderer tests capture the shipped onSessionForked listener through a window.api stub, with no named function extracted.

New tests:

  • session-transitions: owner verdicts; aliases and the named /clear; rechecks; aliasing; once-per-change logging.
  • cli-session-state and cli-session-process-table: Linux, Windows and macOS clearOwner.
  • trigger-clear-chain: a [/clear, reply] chain; a new-id trigger waits for the old-id chain; a PTY replaced under the same id keeps the queue.
  • trigger-context: re-keyed id resolution.
  • clear-pending-persist: the fresh restore, and the new row's identity.

🤖 Generated with Claude Code

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at 401e27e. Thanks for the PR. The idea is right: after /clear the new transcript should belong to the running session. The problems are in how the new transcript is attributed and how the session is re-keyed. The reviewer traced the paths below in the source. I re-read the code behind the first two; I have not run them.

Blocking

  1. An unrelated /clear can be attached to our terminal (session-transitions.js, the signals.cleared branch). matched is true when owner === 'unknown' && isSoleClaudeIn(folder). On Windows and macOS the ownership of a transcript cannot be established, so owner is unknown. If one managed session runs in a folder and the user runs claude outside the app in the same folder and clears it, that other session's new transcript is taken over by our terminal.
  2. Re-keying removes the id that in-flight triggers use (same block: activeSessions.delete(sessionId) then set(newId, ...)). /clear is one of the two commands the trigger watcher may send, and a chain resolves its target by session id at each step. After the /clear step the old id no longer exists, so the following steps abort even though the terminal is alive.
  3. A pending owner has no retry (same block). A file in the pending state is only rechecked when something else triggers a scan, and a partially written header can stay excluded from detection for good.
  4. Quit after /clear, before the first prompt (public/app.js). The saved new id is not in the index yet, so restore cannot find it.

Non-blocking

  • test/session-transitions.test.js (the awaiting-fork case) passes even though an older snapshot matcher causes a transition, so it does not pin what it says.
  • CHANGELOG.md: the entry needs the PR number as its suffix, (#477).
  • #482's archive state and the injected pending row may disagree.

The branch is also two commits behind main, with three overlapping files.

Six existing suites pass against the unchanged sources (81 tests, Node 24). Not verified: a real restart with SQLite, Node 20/22 with c8.

@jbr-sekoia

Copy link
Copy Markdown
Collaborator

Tested on Linux (Ubuntu, GNOME on Wayland), PR head 401e27e.

Setup. An isolated instance from the PR head, with a temp HOME, its own SWITCHBOARD_DATA_DIR, and CLAUDE*/GIT_* unset. The real claude is 2.1.292. The PR's test files pass: test/session-transitions.test.js and test/cli-session-state.test.js, 51/51.

/clear in a managed session (the Linux /proc path): works

  • A prompt, then /clear: the state file is rewritten to the new id, and the log shows owner=mine matched=true, then <old> → <new> (clear).
  • The active row becomes the new session, still holding its running PTY, and the cleared conversation stays as a past row.
  • The next prompt goes into the new transcript, and the row updates in place.
  • Cosmetic: the re-keyed row shows the old row's age ("2m ago") instead of "just now".

Blocking 1 (an unrelated /clear attached to our terminal): does not reproduce on Linux

  • Two managed sessions in the same folder, /clear in B. A logs owner=other matched=false, and only B is re-keyed.
  • claude in a plain shell outside Switchboard, in the same folder, then /clear. Both managed sessions log owner=other matched=false. Neither is re-keyed, and the external transcript is listed as its own row.
  • Same, with only one live managed session in the folder. It still logs owner=other matched=false: on Linux, /proc gives a definite owner, so the sole-PTY fallback is never reached.
  • Not covered. The owner=unknown path (macOS/Windows), where the review places the defect, cannot be exercised here.

Blocking 2 (a trigger chain with /clear aborts): reproduces

  • The chain was [{"command":"/clear"},{"command":"reply ok"}].
  • The re-key happens, and 70 ms later the watcher logs Session exited during chain step 0 submit verify.
  • The result is {"ok":false,"error":"session exited during wait","partial":true,"steps_completed":0}. Step 1 is never sent, although the terminal is alive with its PTY.

Blocking 3 (pending owner with no retry): not observed. In every live /clear, the state file was already rewritten when the new transcript was detected, so the verdict was never pending. That timing was not forced.

Blocking 4 (quit after /clear, before the first prompt): reproduces

  • /clear, no prompt, then quit and relaunch.
  • openWorkingSet held the new id. On relaunch the toast says "Not restored: is not in the index", and no row exists for it.

@devsuitup

Copy link
Copy Markdown
Owner

Blocking 1 of my review (an unrelated /clear attached to our terminal), reproduced on Windows at 401e27e. It complements the Linux test above, which could not exercise the owner=unknown path.

Driven in memory with the shipped session-transitions.js and cli-session-state.js and the fixture helpers of the existing tests, on Node 24:

  • One managed session A in the folder, an unrelated CLI B runs /clear, ancestry unknown: owner=unknown matched=true. The keys go from ["A"] to ["B"] and session-forked A B is sent: A is re-keyed to B's transcript.
  • Two managed sessions in the folder: owner=unknown matched=false, nothing re-keyed (the refusal works).
  • Control, B's descriptor is a descendant of A (injected ancestry): owner=mine matched=true, A is re-keyed. This is the legitimate case.
  • The real default parent-pid reader on this host: it returns null for a real child process and for an unrelated one, so on Windows ancestry is never established and owner=unknown is the normal path, not a corner case. A direct match on the PTY pid still gives mine.

So on Windows (and macOS, which shares the null reader), the single-managed-session fallback accepts any external /clear in the same folder once its transcript and live descriptor are detected. It needs a managed session and an independent CLI in the same project at the same time. The fix asked for stands: require positive ownership evidence, or leave the transcript unattached when ancestry cannot be established.

Not run: a real CLI or Electron, Node 20/22 with c8.

@abate

abate commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks to both of you, and thanks @devsuitup for the Windows reproduction of point 1. Rebased on current main (eaf59ff); the conflicts with #374/#487 in cli-session-state.js, its test and its doc were additive. The fixes are in 4a670ad, on top of the rebased original commit.

Blocking

  1. An unrelated /clear attached to our terminal.
    • The sole-PTY fallback is gone. unknown is never matched: the file is recorded and not retried. A /clear is followed only on positive evidence, an ancestry walk to the PTY or the PTY's own pid being the CLI's (owner === 'mine').
    • As your reproduction shows, the parent reader returns null on macOS and Windows, so there /clear is followed only when the PTY runs the CLI directly. Otherwise the new conversation is listed apart, as before this PR. cli-session-state.md says so.
    • clearOwner also compares ids case-insensitively now, like the rest of the module since (agents): follow-ups from the #374 review #487.
    • Test: with unknown and a single Claude PTY in the folder, nothing is re-keyed, no session-forked is sent, and the file is recorded.
  2. Re-keying breaks trigger chains.
    • session-transitions.js keeps oldId → newId on each re-key, fork or /clear, and exports currentSessionId, which follows the hops. createTriggerContext takes resolveSessionId, which main sets to currentSessionId, and resolves every lookup through it: getPtyForSession, isSessionBusy, getComposerState, getTranscriptTurn and getCliStatus. The old id reaches the same terminal, and the CLI status is read under the id the CLI now writes.
    • Test: two /clears in a row; currentSessionId('old-id') is the newest id, getPtyForSession('old-id') returns the live PTY, and getCliStatus is asked for the newest id. Described in trigger-watcher.md, "Re-keyed sessions".
  3. A pending owner has no retry.
    • A pending verdict on a fresh file schedules one recheck of the folder a second later (scheduleRecheck, at most one per folder at a time), until the 60 s window ends. The same applies to a new file whose first records are not written yet, the partial-header case, while it is under 60 s old. Older ones wait for the next change in the folder, as before.
    • Tests: a pending owner is matched by the scheduled recheck alone; a partial file is rechecked and then matched; an old partial file schedules nothing.
  4. Quit after /clear, before the first prompt.
    • A session with no transcript yet, still a pending row, is now left out of the saved set: nothing exists to resume, and the next start no longer reports it as "not in the index". When loadProjects finds its transcript and drops the pending row of an open session, the set is saved again with it.
    • This also covers a session started with + and never prompted, which had the same problem. docs/session-restore.md has a short section on it.
    • Tests in test/clear-pending-persist.test.js, through the shipped persistWorkingSet and loadProjects.

Non-blocking

Other changes

  • Two existing harnesses that load persistWorkingSet declare pendingSessions: restore-live-elsewhere and restore-pending-persist.
  • cli-session-state.md covers the unknown rule, the recheck, the alias and the snapshot matcher.

Each fix was removed in turn, and each removal fails a test: the unknown fallback, the alias record, the PTY and status resolution, the recheck, the partial-file recheck and its age limit, the snapshot exclusion, the persist exclusion and the persist on becoming real.

On the full suite, the only failures are the CLI-state canary, test-pr and sandbox-wrapper tests, which fail the same way on main in my sandboxed environment. Not run: a live /clear in the app, and Windows or macOS.

@abate
abate requested a review from devsuitup October 8, 2026 09:55

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at 4a670ad, rebased on current main. Thanks: the unknown-owner fallback no longer attaches a stranger's transcript, the missing-id lookup and the awaiting-fork assertion are fixed, the pending-owner retry works, the changelog suffix is there and the docs are updated. 176 tests in 12 related suites pass locally on Node 24, and the reviewer reproduced the findings below with the shipped modules. Four points block; three of them were already reproduced on Linux in the maintainer's test of 401e27e.

Blocking

  1. A header-only transcript is marked known before /clear appears, so it never transitions (session-transitions.js ~495, the final knownJsonlFiles update). A flush that reads only the initial mode/caveat records sees cleared === false; the file is not put in emptyFiles, so it joins knownJsonlFiles and the /clear record appended later never reconsiders it. Keep a new file with a header and no command record eligible (as the pending-owner case does) until it shows a first user turn or goes stale. Test: write the header, flush, append the /clear record, flush again, and expect the transition.
  2. Quitting after /clear and before the first prompt loses the tab on restore (public/app.js ~185). persistWorkingSet saves the new id; the new transcript is bookkeeping-only, so it is never indexed and the restore planner reports "not in the index". Reproduced on Linux: "Not restored: is not in the index". Persist enough to restore the cleared session independently of indexability (or keep the old id as the restorable target until a prompt exists). Test: clear, persist, restart the planner with that set, and expect the session restored.
  3. An awaiting fork accepts another session's snapshot prefix (session-transitions.js ~450). A session waiting for its fork matches any new transcript that has only snapshots and no /clear marker, before any ownership evidence arrives. Require the forkFrom or ownership evidence before matching, or wait for the marker.
  4. The old and the new id take different trigger locks (trigger-context.js ~44, trigger-watcher.js ~1843). sessionLocks is keyed by the raw session id, but both ids reach the same PTY after a re-key, so triggers aimed at the old and the new id can interleave their writes. Key the lock by the PTY (or resolve the id through rekeyed before locking). The chain that aborts at step 0 on Linux ("session exited during chain step 0 submit verify") needs the in-flight target followed through the transition as well; please cover it with a test that runs [/clear, reply] through the shipped watcher.

Non-blocking

  • Pending-row injection in the renderer bypasses the backend archive filtering (public/app.js ~1171): an archived cleared session can reappear.
  • An original trigger id stops resolving after 33 clears (session-transitions.js ~34, the bounded map).
  • Explanatory comments at session-transitions.js ~31 and ~449 go beyond the one-line pointer allowed in code.
  • test/clear-pending-persist.test.js (~43) supplies the pending rows by hand, so the new renderer callback branch is not pinned.

Not run: a live /clear chain, a SQLite restart, macOS, Node 20/22 with c8, lint, fresh CI. CI on this head is not approved yet.

@abate

abate commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks. Rebased on current main (75ab0ad). The conflicts were in main.js and trigger-context.js, with #454's getLiveDescriptor and the compaction cursor of getTranscriptTurn; both sides are kept, and getLiveDescriptor resolves the re-keyed id too. The fixes are in 6923cd7, on top of the rebased commits.

Blocking

  1. A header-only transcript became known before its /clear record.
    • readNewSessionSignals now reports hasTurn, which is true once a user record that is not bookkeeping, or an assistant record, is read.
    • A new file without one stays out of knownJsonlFiles until it shows one or is an hour old, the same limit as a file with no parseable record. While it is under 60 s old, scheduleRecheck runs the folder again a second later. The session's own transcript is never held.
    • Tests: header, flush, then the /clear record and the scheduled recheck give the transition. A header-only file older than the limit, and the session's own file, are recorded with no timer.
  2. Quitting after /clear, before the first prompt, lost the tab.
    • A local session with no transcript yet is now saved as { sessionId, projectPath, active, fresh: true } and no longer dropped. This also covers a session started with + and never prompted.
    • The planner counts a fresh entry as available without the index, so it is never reported as "not in the index". runRestore starts a new session in that project for it, with the project's current defaults for a new session minus worktree, since the saved path already is the folder to start in. If the fresh entry was the active one, its new session is shown.
    • The entries held during a restore keep the fresh mark. Once the first prompt makes the session real, it is saved as an ordinary entry.
    • Restoring the old id was the other option. I did not take it, because it would bring back the conversation the user had just cleared.
    • A pending session on a remote host is still left out, because restore has no remote launch for it.
    • Tests in test/clear-pending-persist.test.js: /clear through the shipped applySessionForked, then persist. The saved set is fed to a new planner with an empty index and indexingDone, and runRestore starts the session. The tests also cover the fresh mark kept while held, the remote exclusion, and the first prompt.
  3. An awaiting fork accepted any snapshot-only file.
    • A file with snapshots and no turn is taken as the fork only when clearOwner (which answers for any session id) says the CLI under this PTY writes it.
    • A pending owner is rechecked like a /clear. With other or unknown, the file stays eligible, and its first turn decides through forkedFrom or parentSessionId, as for any fork. On macOS and Windows a fork is therefore matched at its first prompt, unless the PTY's own pid is the CLI's.
    • Tests: a stranger's snapshot-only file is not taken, and its first turn records it. An owned one is taken, a pending one is matched by the recheck, and an unknown one is matched by its first turn.
  4. Old and new ids took different trigger locks.
    • acquireSessionLock keys the queue by the PTY that getPtyForSession returns. That lookup already resolves the id, so both ids share one queue. When there is no PTY, the session id is the key, as before.
    • test/trigger-clear-chain.test.js runs [/clear, reply] through the shipped watcher, with the re-key done by session-transitions.js while step 0 is verified. It ends ok with /clear then reply written to the same PTY. Without resolveSessionId, it fails with the result you saw: "session exited during wait" at step 0.
    • A second test drops a trigger for new-id while a [/clear, first, second] chain on old-id runs. The PTY receives /clear, first, second, other, and keying by id interleaves them.
    • One existing test (startup scan respects MAX_INFLIGHT) gave twelve sessions a single shared fake PTY; each now has its own.

Non-blocking

  • Archive: the row added after /clear is marked cleared. It is not re-added to the default list when the backend filtered its folder out as archived, but it still appears with the archived folders. A row from + is unchanged. Test included.
  • The 33-clear bound is gone. Each re-key moves every older id that pointed at the old one to the new one, so any id the session ever had resolves in one lookup. Test: 40 clears.
  • Comments: what remains in session-transitions.js are one-line pointers; the explanations are in cli-session-state.md.
  • The renderer branch: the session-forked callback is now the named function applySessionForked, and the tests go through it rather than hand-built rows. test/session-activity.test.js reads that function for its source check.

Docs:

  • cli-session-state.md: the file with no turn yet, the snapshot-only fork rule, the alias collapse and the archived row.
  • docs/session-restore.md: fresh entries.
  • session-cache.md: fresh entries in the planner.
  • trigger-watcher.md: the lock key and the chain test.
  • CHANGELOG: the entry now mentions the restore. It still ends with (#477).

Each fix was removed in turn, and each removal fails a test. That covers the no-turn hold, its own-file exclusion and age limit, the owner check on snapshot-only files, both recheck conditions, the alias collapse, the PTY lock key and the getLiveDescriptor resolution. On the restore side, it covers the fresh mark when saving and when held, the remote exclusion, the planner rule, the fresh branch of runRestore, the worktree removal, the active mapping, the archived-folder skip and its mark, the row added after /clear, and the persist when the session becomes real.

Full suite on Node 22: 4872 pass, 17 fail. The failures are the CLI-state canary, four sandbox-wrapper tests (they need #483's environment fix) and twelve test-pr isolated tests, none of them in files this PR touches; they fail in my sandboxed environment on main too. Lint: 0 errors.

Not run: a live /clear or fork in the app, a real restart, macOS or Windows, Node 20 with c8.

@abate
abate requested a review from devsuitup October 9, 2026 14:43

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at 6923cd7, rebased on current main (75ab0ad). Thanks for the thorough follow-up. All four blocking points of the last review are addressed and each fix is pinned: removing each one locally (twelve mutations) makes at least one new test fail. The nine touched test files pass (270 tests, 0 failures) and CI on this head is green on Node 20/22 for Linux and Windows. Three points still block, one of them new.

Previous findings

# Finding Status Where
1 Header-only transcript marked known before /clear Resolved session-transitions.js:361 (hasTurn), :470-473
2 Quit after /clear before the first prompt Resolved; side effects under Minor public/app.js:185-191, :226-246; public/restore-plan.js:40
3 Awaiting fork accepts a stranger's snapshot-only file Resolved session-transitions.js:453-458
4 Old and new ids take different trigger locks / [/clear, reply] chain Resolved trigger-watcher.js:1927-1928, trigger-context.js:42; test/trigger-clear-chain.test.js
– Archive / injected row Resolved public/app.js:1195-1196
– 33-clear bound Resolved, see Blocking 1 session-transitions.js:36-38
– Comments, renderer callback Resolved public/app.js:512

The merge with #454 keeps both sides: getLiveDescriptor is wired at main.js:3426, the compaction cursor of getTranscriptTurn is intact at trigger-context.js:86-93.

Blocking

1. A trigger aimed at a cleared conversation's id goes to the wrong terminal once that conversation is resumed (session-transitions.js:30-38, trigger-context.js:38). rekeyed keeps old-id → new-id for the app's life and resolve() applies it even when activeSessions holds a live session under old-id itself. The changelog says the cleared conversation stays in the list as a past session; clicking it opens a new terminal keyed by old-id (main.js:2747, :2897). Every trigger lookup then follows the alias: getPtyForSession, isSessionBusy, getComposerState, getCliStatus, getTranscriptTurn, getLiveDescriptor resolve to new-id, and acquireSessionLock locks the wrong PTY. Reproduced with the shipped session-transitions.js and trigger-context.js: after /clear from A, a second PTY B registered under old-id, a trigger to old-id returned PTY A. The same happens after a fork re-key when the fork's source is reopened. Resolve through the alias only when the raw id has no live entry (activeSessions.has(id) ? id : currentSessionId(id)), drop aliases whose source becomes active again, prune those whose target exited, and add a test for this sequence.

2. The changelog entry is in the released v0.0.90 section (CHANGELOG.md:28). It sits between ## v0.0.90 (line 10) and ## v0.0.89 (line 31); please move it under ## Unreleased. The CI check only looks for the file in the diff.

3. No CI run shows the new tests failing before the fix. For bug fixes we ask for a run that is red on the regression tests alone, then the fix that turns it green. Every commit here (19ea917, b6b9294, 6923cd7) carries tests and fix together, and the only runs (401e27e, 4a670ad, 6923cd7) are green. Could you push the new and changed tests first on top of main, let CI run red, then the fix commits? A force-push of the reordered branch is fine.

Major

4. On Windows and macOS, /clear is never followed, but the changelog says it is (cli-session-state.js:453-459, main.js:2863). Managed sessions start claude through a shell, so pty.pid is the shell's pid, and defaultReadParentPid returns null outside Linux (cli-session-state.js:68); clearOwner therefore returns unknown at depth 0 for every /clear there and nothing is re-keyed. Either scope the entry ("On Linux, …") or establish ancestry on Windows and macOS (ps -o ppid=, a process snapshot), with a test for the non-Linux path.

Minor

  1. public/app.js:233, public/restore-plan.js:40: a fresh entry wins over the index at restore, so a session saved as fresh whose transcript exists by restart starts empty instead of resuming. applySessionForked (public/app.js:534) also marks any fork without a pending entry as cleared. Resume when sessionMap.has(item.sessionId), and wait for indexingDone before treating a fresh entry as unindexable.
  2. public/app.js:236-239 (inferred, not reproduced): a pending + session launched with forkFrom or a worktree default is saved as fresh and comes back as a plain session in the main checkout. Keep forkFrom and the worktree choice in the entry, or do not mark such sessions fresh.
  3. trigger-context.js:91: readPaths is keyed by the raw trigger id, so after a mid-chain re-key the old file's cache entry is never forgotten (one small leak per /clear sent by a chain). Key it by file path, or forget the previous path first.

Suggestion

The hold at session-transitions.js:470 also applies, for up to an hour, to header-only files of unrelated CLIs in the same folder (up to 512 KB re-read per flush per session), and an awaited fork logs "NO MATCH" at info level on every flush; logging once per file would keep main.log readable.

Not verified: a live /clear in the app, a real restart, macOS; finding 6 is inferred from the code.

Changes requested: 3 blocking, 1 major, 3 minor, 1 suggestion.

@devsuitup

Copy link
Copy Markdown
Owner

Heads-up for coordination, not a review finding: #518 (a backgrounded session restored from its pre-fork transcript) is the same class as this PR — the PTY's conversation id changes while it lives — but it is not caught by the /clear detection here (a bg continuation starts with bridge_status, and an in-CLI /resume creates no file). Our plan is to build the live re-key for those cases on top of your clearOwner ownership walk and rekeyed map once this lands, rather than a parallel path, re-keying from the CLI descriptor's sessionId. That may make the 60 s pending recheck partly redundant later; nothing for you to change now. Details in #518.

@abate

abate commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Rebased on current main (a02f96c, v0.0.92). The branch is now two commits: eceb9aa, the new and changed tests alone, then 6c1fa8c, the fix.

Blocking

  1. Alias hijack.
    • currentSessionId returns the raw id when a live session holds it, and drops that alias. So a cleared conversation reopened in its own terminal owns its id again.
    • An alias whose target is gone or exited is dropped too.
    • Every trigger lookup and the per-terminal lock go through the same function.
    • Test: after /clear from A (old → new), a second PTY B is registered under old-id. A trigger to old-id reaches B, new-id still reaches A, and the alias is gone once B leaves. A second test covers an exited target.
  2. Changelog: the entry is under ## Unreleased → ### Fixed.
  3. Tests first: the two runs above. The red run also fails the two tool-bar fork tests. That is because their listener now loads the named applySessionForked, which main does not have; it is not a regression check.

Major
4. Windows and macOS. #521's process table, merged since your review, gives clearOwner ancestry on Windows. When the table is enabled, clearOwner gives the same answer as conversationOwns:

  • mine only when the descriptor's pid is the writer the table names (pidDomain: 'win32:anchor', procStart) and its chain reaches the PTY's pid;
  • pending while the table does not list the pid yet, so the recheck asks again.

The macOS CLI writes no anchor, so there a /clear is other and the new conversation is still listed apart. The changelog now says "On Linux and Windows" and names macOS. Re-keying macOS from the descriptor's sessionId is left for the #518 follow-up.

Tests (with an injected table) passed on windows-2022 in CI:

  • the table not read yet, then mine;
  • a stranger's CLI is other;
  • macOS is other.

Not run: a live /clear on Windows or macOS.

Minor
5. Fresh entries at restore.

  • The planner takes a fresh entry as a candidate only once it is indexed or indexing is done. Before that, it waits like any other entry.
  • runRestore resumes a fresh entry whose transcript is in the index, and starts anew only one that still is not.
  • session-forked now carries the kind ('clear' or 'fork'), and the renderer adds the "New session" pending row only for a /clear.
  • Tests for each.
  1. Fork and worktree launches: a + session started with forkFrom or a new worktree is marked restorable: false and left out of the saved set while it has no transcript, like a remote one: starting it anew would not give back the same thing. Test through the shipped launchNewSession. Documented in docs/session-restore.md.
  2. readPaths: when a lookup finds the transcript under a new id, getTranscriptTurn forgets the file it read under the old one. Test through the shipped context.

Suggestion

  • "NO MATCH" is logged once per file.
  • The signals of a new file are cached by path, size and mtime, so a held header-only file is read again only when it changes. Tested by counting opens, including a write that keeps the mtime.

Mutation checks: each of the 18 changes, removed one at a time, turns at least one test red. This includes the size half of the cache key and both halves of the alias pruning. I also removed an untested sweep in recordRekey; the pruning in currentSessionId covers it.

Full suite (Node 22, this sandboxed Linux session): the only failures are the CLI-state canary, the test-pr isolation tests and four sandbox-wrapper tests that need #483, all failing here on main too. Lint has 0 errors.

Not run: a live /clear in the app, a real restart, Windows or macOS outside CI's unit tests.

@abate
abate requested a review from devsuitup October 10, 2026 09:31

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 6c1fa8c — request changes (1 major, 3 minor, 3 suggestions). The /clear logic holds: re-key, aliases, the [/clear, reply] chain, the Windows owner check and the fresh restore; no conflict with #518/#524 and nothing merged is reverted. Base a02f96c, mergeable.

Previous findings

# Status
B1 alias sends a trigger to the wrong terminal closed — session-transitions.js:37-55 currentSessionId keeps the raw id when a live session holds it and drops aliases whose target exited; trigger-context.js:38 uses it everywhere; both tests pass
B2 changelog under v0.0.90 closed — Unreleased / Fixed
B3 no red CI before the fix closed, with a caveat (minor 1): run 38040849047 on eceb9aa red on the 4 test jobs (37 failures, all new/changed tests), run 38041240410 on 6c1fa8c green, none of the new tests skipped
Mj4 Windows/macOS closed for Windows (cli-session-state.js:597-601 uses #521's process table, Windows tests green on windows-2022); macOS named in the changelog
m5, m6, m7, suggestion closed (restore-plan.js:40, app.js:227/:583/:187, trigger-context.js:91-92, signals cache, once-per-file NO MATCH log)

Major — the "New session" row after /clear inherits the cleared conversation's identity (public/app.js:586)
The pending row is { ...entry.session, sessionId: newId, summary: 'New session', firstPrompt: '', name: null, messageCount: 0 }, so it keeps aiTitle, starred, modified, created and bridgeSessionId. Sidebar (sidebar.js:1664), grid and terminal header display name || aiTitle || summary, so the new row shows the old title (jsdom probe of the shipped function: "Refactor the payment flow", not "New session"), two rows carry the same title, and the new one is pinned and tied to the old claude.ai session. Build the row like launchNewSession does, and add a test on the displayed name.

Minor

  1. test/clear-pending-persist.test.js:63: setup() loads applySessionForked in every test; main has no function of that name (its listener is anonymous), so all 10 restore tests went red on "must define a top-level function applySessionForked" — 7 never call it. Same for 2 tool-bar tests and 1 session-activity test. The red therefore shows a missing symbol, not the restore bug. Replayed locally against main with only the anonymous callback renamed, 8 of 10 fail on assertions, so the tests do catch it. Load only what each test needs and capture the callback through a window.api.onSessionForked stub.
  2. trigger-watcher.js:1926-1929 (plausible): the trigger lock is now keyed by the PTY object; if the PTY is replaced under the same id mid-chain, the running chain writes to the new PTY while a new trigger takes a different lock and does not wait (on main both waited on the id). Key it by the resolved id.
  3. .ai/contexts/trigger-watcher.md:995-996: the raw carriage-return byte between backticks became a line feed, splitting the line and naming the wrong character (od -c on both versions).

Suggestions

  • session-transitions.js:489, :499: two info logs fire every second for up to 60 s per recheck.
  • Plausible: /reset and /new may not be recognised as /clear.
  • Plausible, unsettled: if /clear also writes a continued-in record into the cleared transcript, #518's restore would redirect a click on the cleared conversation to the new one.
  • The PR description still describes the 401e27e design.

Local (Node 22): touched suites plus the #518/#524 ones, 330/330. The one trigger-watcher.test.js failure (i488-fix2 … 650 ms) is an existing timing flake — 2 of 8 on both head and main. Not run: a live /clear, a real restart with SQLite, macOS, a /clear in an attached background session.

@abate

abate commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed at a468b5b, tests first: 037de9e is red in Test run 38069061167 (all four test jobs, 37 failures, all of them new tests) and a468b5b is green in Test run 38069643247.

Major, the new row inherits the cleared identity. The open entry's session is now replaced with a new session built as launchNewSession builds one: "New session", not starred, no aiTitle, bridgeSessionId or old dates, same project (public/app.js). A new test in clear-pending-persist checks the pending row, sessionMap and the open entry. For each it asserts that name || aiTitle || summary is "New session", the row is not starred, has no bridge id and has new dates. Restoring the old spread fails it.

Minor 1, missing-symbol red. applySessionForked is gone: the listener is anonymous again, as on main. clear-pending-persist captures it through a window.api.onSessionForked stub, and only in the tests that need it. tool-bar.test.js and session-activity.test.js are back to main's version. The branch history was rebuilt so the tests come first; replayed on main, the renderer tests fail 8/11 on AssertionError. Elsewhere, the only non-assertion failures are the new clearOwner / currentSessionId APIs, which main does not have.

Minor 2, lock keyed by PTY. The lock is keyed by id again. A trigger now waits for every queued id that resolves (ctx.resolveSessionId) to the same session, so the old and the new id share one queue. A PTY replaced under the same id keeps its queue too, with a new test in trigger-clear-chain. Keying by id alone fails "a trigger for the new id waits for a chain still running under the old id". trigger-watcher.test.js is back to main's version.

Minor 3, CR byte. Restored in .ai/contexts/trigger-watcher.md; the line matches main again.

Suggestions

  • Logs. The owner lines for /clear and snapshot-only forks are logged when they change, not on every recheck. Test: three pending rechecks and a final match log two lines.
  • Aliases. In CLI 2.1.296, /reset and /new are /clear's aliases (name:"clear", aliases:["reset","new"], argumentHint:"[name]"). Both are recognised now, and so is /clear <name>; before, any argument defeated the match. Three tests cover them, and all three fail without the fix.
  • continued-in. /clear writes none. In CLI 2.1.296 the only writer of continued-in is the background-session handoff ([bg] continued-in record not written). Of 41 local /clear transcripts, none is the target of a continued-in record. Noted in cli-session-state.md.
  • PR description. Rewritten for the current design.

Local (Node 22): full suite 5120/5137. The 17 failures are the canary and sandbox/test-pr tests that fail in this sandboxed session, the same set as on main. trigger-watcher.test.js passed 3/3 runs.

@abate
abate requested a review from devsuitup October 10, 2026 17:04

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at a468b5b — approve (2 nits). All eight points of the 6c1fa8c review are closed.

Previous point Status
Major: the row after /clear keeps the old title/star/bridge closed — public/app.js:586-588 builds a fresh session like launchNewSession (summary 'New session', starred: 0, new dates, no aiTitle/bridgeSessionId), shared by the pending row and sessionMap; test the row after /clear is a new session, not a copy of the cleared conversation checks name‖aiTitle‖summary on the pending row, sessionMap and openSessions
Red from a missing applySessionForked closed — listener anonymous again; tool-bar / session-activity tests slice the shipped listener
Trigger lock keyed by the PTY object closed — trigger-watcher.js:1925-1938 keys by id and waits on every queued id ctx.resolveSessionId maps to the same target; new test a trigger waits for a chain whose session got a new PTY under the same id
CR byte turned into LF in trigger-watcher.md closed (od -c, identical to main)
Per-second info logs closed — logOwnerOnce, with a test
/reset, /new closed — CLEAR_COMMANDS, three tests, changelog updated
continued-in interplay with #518 documented (CLI 2.1.296 writes none on /clear); not re-checked here
PR description updated

Red-first: run 38069061167 on 037de9e red on the 4 test jobs, 37 failures all in tests #477 adds or changes — 31 assertions, 1 TypeError that is the behaviour under test (re-keyed id not resolved), 5 missing symbols that are the new public APIs (clearOwner, currentSessionId). Run 38069643247 on a468b5b green, builds green. Locally (Node 22) touched + bridge/continuation/restore suites: 426/428, 2 skipped; the i488-fix2 … 650 ms trigger-watcher failure is the known flake also seen on main.

No change to session-cache.js, db.js or session-continuations.js; the new row no longer carries bridgeSessionId, so it cannot join the old conversation's #524 bridge group.

Nits

  1. session-transitions.js:36: the /** The id a session is keyed under now… */ JSDoc now sits above CLEAR_COMMANDS/logOwnerOnce instead of currentSessionId.
  2. app.js:587 (plausible): the new row drops remoteAlias and type, read at app.js:182/187 and terminal-manager.js:324-390. Safe today since clearOwner never returns mine for a remote session; worth keeping in mind if remote /clear following is added.

Not run: a live /clear, a real restart with SQLite, macOS, a remote or bg-attached session.

abate pushed a commit to abate/switchboard that referenced this pull request Oct 11, 2026
abate added a commit to abate/switchboard that referenced this pull request Oct 11, 2026
…by id, and owner checks

Tests for the last review round of devsuitup#477, which devsuitup#548 did not merge: the row
after /clear is a new session rather than a copy of the cleared one; /reset,
/new and /clear with a name are followed; a trigger waits for a chain whose
session got a new PTY under the same id; owner logs fire on change only;
clearOwner reads the process table on Windows and never claims on macOS;
held header-only files and fresh entries wait for indexing.
abate added 2 commits October 11, 2026 15:35
…by id, and owner checks

Tests for the last review round of devsuitup#477, which devsuitup#548 did not merge: the row
after /clear is a new session rather than a copy of the cleared one; /reset,
/new and /clear with a name are followed; a trigger waits for a chain whose
session got a new PTY under the same id; owner logs fire on change only;
clearOwner reads the process table on Windows and never claims on macOS;
held header-only files and fresh entries wait for indexing.
…ne trigger queue per session

After /clear the open entry gets a new session built as launchNewSession
builds one, instead of inheriting the cleared conversation's title, star,
bridge id and dates. /reset, /new and /clear with a name are recognised.
The trigger lock is keyed by the resolved session id again, so a chain
and a new trigger share one queue across a PTY replacement or a /clear.
Owner logs fire only when the owner changes, the fork listener is
anonymous again (tests capture it through the onSessionForked stub), and
the changelog entry moves to Unreleased.
@devsuitup

Copy link
Copy Markdown
Owner

Notes from reviewing the earlier pair 4568996/3288ba3, ahead of the rebased fix:

  • Equivalence (verified). Apart from the CHANGELOG, the non-test code of 3288ba3 is equivalent to the reviewed a468b5b applied on 8b12045.
  • Windows harness. At the tests commit, 4 Windows tests (531, 532, 537, 538) fail with TypeError: forkedListener is not a function. That is a harness failure, not an assertion. The harness slices applySessionForked out of app.js with ';\n', which never matches the CRLF checkout on Windows. On Linux, 531 and 537 pass at the tests commit, so they do not pin the fix.
  • Nit (verified). Two JSDoc blocks in session-transitions.js sit above the wrong declarations: "The id a session is keyed under now…" is above const CLEAR_COMMANDS, and "Read first few lines…" is above const signalsCache.
  • Minor (not reproduced). On Windows, clearOwner returns 'other' for good when leafIsTheDescriptorWriter fails, for example with a CLI that does not write pidDomain: 'win32:anchor'. The new conversation is then listed apart, and nothing in the log says why.

@abate

abate commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator Author

#548 merged 6923cd7, an older head of this PR, not the approved a468b5b. The branch is now main plus that missing last round only: new-session row after /clear, /reset and /new, trigger lock by resolved id, owner checks, anonymous fork listener, changelog moved to Unreleased. Tests 6daa029 red on assertions (run 38144139365), fix a88f03c green (run 38144648704). The body lists it.

@devsuitup

Copy link
Copy Markdown
Owner

Review of a88f03c: approved.

  • Same code as reviewed. The fix's changed lines are identical to 3288ba3's, and 3288ba3 is equivalent to the reviewed a468b5b applied on 8b12045, CHANGELOG aside. The tests commit is identical too.
  • Red, then green. The tests commit 6daa029 is red. The fix a88f03c is green on all 12 checks.
  • Not blocking, follow-ups if you want them (see my earlier comment): the two misplaced JSDoc blocks in session-transitions.js, and clearOwner staying 'other' on Windows with no log line.

Merging into v0.0.93 at a88f03c.

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved at a88f03c.

@devsuitup
devsuitup merged commit 2bae3ce into devsuitup:main Oct 11, 2026
12 checks passed
abate pushed a commit to abate/switchboard that referenced this pull request Oct 11, 2026
… open sessions when the app closes, last review round)

Merged onto main after devsuitup#477: the only conflict was the CHANGELOG, where both PRs moved their stale v0.0.90 entry to Unreleased; both lines are kept under Unreleased and neither under v0.0.90.
@abate
abate deleted the fix/clear-rekey branch October 15, 2026 21:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants