Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
19ea917
fix(sessions): follow /clear to the new transcript instead of listing…
abate Oct 5, 2026
b6b9294
fix(sessions): follow /clear only on proof of ownership, and keep cha…
abate Oct 8, 2026
d730f48
(sandbox): opt-in writable skills and agents
abate Oct 5, 2026
ce289fc
fix(sandbox): protect git and .claude inside opted-in skills and agents
abate Oct 7, 2026
fca4fc5
fix(restore): save the open sessions when the app closes
abate Oct 5, 2026
0d3e510
fix(sandbox): refuse an opted-in skills or agents that overlaps a rea…
abate Oct 8, 2026
7fdbaee
fix(restore): keep unrestored sessions on exit, and save after an abo…
abate Oct 8, 2026
93672b5
fix(sandbox): protect every path a repository is seen at, and compare…
abate Oct 9, 2026
8bf1c90
fix(restore): flush on a quit that joins a reload check, and keep the…
abate Oct 9, 2026
6923cd7
fix(sessions): hold header-only files, restore a cleared session, one…
abate Oct 9, 2026
81cf86b
test(sandbox): a repository below a symlinked Additional Directory is…
abate Oct 10, 2026
4fc768f
fix(sandbox): enter an Additional Directory that is a symbolic link w…
abate Oct 10, 2026
9f2cf00
test(sessions): a double click on a session row opens its terminal once
abate Oct 10, 2026
8da0598
fix(sessions): open a session once while its open is in flight
abate Oct 10, 2026
5680fee
test(sandbox): write below a symlinked Additional Directory from insi…
abate Oct 10, 2026
c638a66
test(sessions): a click is not merged into an automatic open, nor two…
abate Oct 10, 2026
efbbe71
fix(sessions): join only an equivalent open, and guard the resolved c…
abate Oct 10, 2026
3fa4e31
Merge #538 (sandbox: protect repositories below a symlinked Additiona…
devsuitup Oct 11, 2026
8a99223
Merge #539 (sessions: open a session once when its row is clicked twice)
devsuitup Oct 11, 2026
6057684
Merge #483 (sandbox: opt-in writable skills and agents)
devsuitup Oct 11, 2026
aa799f4
Merge #479 (restore: save the open sessions when the app closes)
devsuitup Oct 11, 2026
e1c796e
Merge #477 (sessions: follow /clear to the new transcript instead of …
devsuitup Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .ai/contexts/cli-session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,81 @@ The Agents view (`.ai/contexts/bg-agents.md`) reads the same
`jobId`, so `guardResume` can answer a `kind: 'bg'` session with an attach
instead of a resume confirmation.

## Owner of a /clear transcript

`/clear` does not continue the open transcript: the CLI opens a **new jsonl
under a new session id** in the same project folder, and writes into it only
the bookkeeping of the command (a `<local-command-caveat>` record, then the
`<command-name>/clear</command-name>` record). Nothing in that file names the
session it replaced — no `forkedFrom`, and the `sessionId` on every record is
the new one. `detectSessionTransitions()` (`session-transitions.js`) used to
match new files by those two fields only, so after a `/clear` the open
terminal stayed on the old row and the new conversation, once its first prompt
made it indexable, showed up as a second, unattached row.

The state file is what links the two: the CLI rewrites
`~/.claude/sessions/<pid>.json` with the new `sessionId` on `/clear` (checked
2026-10-05 on CLI 2.1.28x: live state files whose `sessionId` names a
transcript that starts with the `/clear` record). `clearOwner(newId, ptyPid)`
finds the live pid whose state file names `newId` and walks its parent chain
(`/proc/<pid>/stat`) up to the PTY's pid. Verdicts:

- `mine` — the CLI runs under this PTY: re-key, exactly like a fork
(`session-forked` to the renderer).
- `other` — another process (a CLI started outside Switchboard, another tab in
the same folder): not this session's file.
- `pending` — no live state file names `newId` yet (the jsonl can land before
the state file is rewritten): the file stays out of `knownJsonlFiles`, and
`scheduleRecheck` runs the folder's detection again a second later, while
the file is less than 60 s old. Nothing else would: the state file is not in
the watched folder, and the transcript gets no other write before the first
prompt. A new file whose first records are not written yet is rechecked the
same way.
- `unknown` — the pid is found but its parent cannot be read (no `/proc`:
macOS, Windows). Never matched, not even when this is the only Claude PTY in
the folder: a `claude` run outside the app in the same folder would be taken
over. The file is recorded and not rechecked, so on macOS and Windows a
`/clear` is followed only when the PTY's own pid is the CLI's.

Re-keying keeps `oldId → newId` (`currentSessionId`), so a trigger chain that
sent `/clear` reaches the same terminal on its next step (see
trigger-watcher.md, "Re-keyed sessions"). On each re-key every older id that
pointed at `oldId` is moved to `newId`, so any id the session ever had resolves
in one lookup, however many times it was cleared.

**A file with no turn yet.** The CLI writes a `/clear` transcript in several
appends: a mode record and a `file-history-snapshot` can land, and be read,
before the `/clear` record. A new file whose first real turn
(`readNewSessionSignals().hasTurn`) is not written yet stays out of
`knownJsonlFiles`, whatever its other signals, 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 this way.

**Snapshot-only fork file.** A session started with `--fork-session` waits
for its fork's transcript, which holds only snapshots until the first prompt.
Such a file is taken as the fork only when `clearOwner` (it answers for any
session id, not only a `/clear`) says the CLI under this PTY writes it. A
`pending` owner is rechecked like a `/clear`; `other` or `unknown` leave the
file eligible, and its first turn decides through `forkedFrom` or
`parentSessionId`, as for any fork. Before, any snapshot-only file in the
folder was taken, including another session's `/clear` or new session.

`readNewSessionSignals()` recognises the file by its first user record that is
not local-command bookkeeping, through `classifyUserText()` — the caveat record
comes first and must be skipped, not taken as the file's first turn.

The renderer re-keys as for a fork, and when the old id was not a pending
(transcript-less) row it adds one for the new id titled "New session": the
`/clear` transcript is not indexed until its first prompt (see
session-cache.md), so without it the open terminal would have no row at all
until then. That row is marked `cleared`, and is not re-added to the default
list when the backend filtered its folder out as archived. Like any local
pending row, it is saved in the working set as a fresh entry
(docs/session-restore.md, "A session with no transcript yet"). The old
conversation stays in the list as an ordinary, resumable session — it is a
real transcript on disk.

## Canary tests

`test/canary-*.test.js` is a convention this module introduces. A canary
Expand Down
2 changes: 1 addition & 1 deletion .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ A kept row is recorded with its folder. On PTY exit, `main.js` calls `releaseLiv
- **Open question #3 (existing databases)**: repaired on the next index pass, not left alone. `bridgeSessionId` and `mergedIntoSessionId` are added purely via the schema-reconciliation block (not a numbered migration — deliberately, to avoid coupling `migrations.length` to unrelated migration-ordering tests; see `db-schema-reconcile.test.js`'s "foreign higher-version" precedent for why reconciliation is the version-independent mechanism). Their absence sets `mustReindex = true`, which wipes `session_cache` + `cache_meta` + the `initial_scan_complete` marker, forcing every folder through the now-merging indexer on the next scan — the same repair path already used when `fileMtime` (v7) or the fork subagent columns (v4) were introduced.
- **"Open on claude.ai" (issue #213).** `buildProjectsFromCache` passes `bridgeSessionId` to the renderer (`null` when absent). `bridgeSessionUrl()` in `public/bridge-url.js` turns it into `https://claude.ai/code/session_<suffix>`: the transcript record carries `cse_<suffix>`, the CLI descriptor and the web URL carry `session_<suffix>`, and the suffix is the same (measured on local transcripts that mention both forms). Any other shape, or an id with a character outside `[A-Za-z0-9]`, gives no URL and the row shows no button. The button (`.session-bridge-btn` in `buildSessionItem`) opens the URL through `window.api.openExternal`, whose main-side handler already refuses anything but `http(s)`.

- **Working-set restore retries until indexing is done, not once.** `populateCacheViaWorker` streams `sessionMap` one folder at a time on a cold start, so a saved working-set id can be missing for many ticks before it's genuinely indexed. `createRestorePlanner()` (`public/restore-plan.js`) is ticked from every `projects-changed` handler and from `updateIndexingBanner` on `payload.done`; it keeps returning `'wait'` until every saved id is indexed or indexing is over (then the rest is presumed deleted), restoring incrementally in `auto` mode and asking once (`askOnce: true`) in `ask` mode instead of re-prompting per tick. The end of indexing reaches the renderer on its own `indexing-finished` channel, sent at the end of **every** `populateCacheViaWorker` run, warm start included: `indexing-progress` is first-run only, so a warm start never told the planner that indexing was over and a saved session missing from the index left the "Finishing indexing" toast up for good. When the planner gives up (indexing over, or the tick cap) it returns the saved entries it never found as `unavailable`, and `tickRestorePlanner` names them in a "Not restored" notice. The end is also pullable (`get-indexing-state`, read once when the planner starts) because the startup scan can finish before the renderer listens, and `markRestoreIndexingDone` reloads the projects before the final tick because the last folders may not have reached `sessionMap` yet. See `test/session-restore-cold-cache.test.js`, `test/restore-unavailable.test.js`. A persist during that window keeps the entries not resolved yet (`pendingRestoreEntries`: the planner's pending ones, the ones awaiting the restore toast, and `restoreInFlight`, those handed to `runRestore` while their live-elsewhere check and open are awaited, even across two overlapping restores), otherwise any click or close would erase them from `openWorkingSet` before they are reached; see `test/restore-pending-persist.test.js`.
- **Working-set restore retries until indexing is done, not once.** `populateCacheViaWorker` streams `sessionMap` one folder at a time on a cold start, so a saved working-set id can be missing for many ticks before it's genuinely indexed. `createRestorePlanner()` (`public/restore-plan.js`) is ticked from every `projects-changed` handler and from `updateIndexingBanner` on `payload.done`; it keeps returning `'wait'` until every saved id is indexed or indexing is over (then the rest is presumed deleted), restoring incrementally in `auto` mode and asking once (`askOnce: true`) in `ask` mode instead of re-prompting per tick. The end of indexing reaches the renderer on its own `indexing-finished` channel, sent at the end of **every** `populateCacheViaWorker` run, warm start included: `indexing-progress` is first-run only, so a warm start never told the planner that indexing was over and a saved session missing from the index left the "Finishing indexing" toast up for good. When the planner gives up (indexing over, or the tick cap) it returns the saved entries it never found as `unavailable`, and `tickRestorePlanner` names them in a "Not restored" notice. The end is also pullable (`get-indexing-state`, read once when the planner starts) because the startup scan can finish before the renderer listens, and `markRestoreIndexingDone` reloads the projects before the final tick because the last folders may not have reached `sessionMap` yet. See `test/session-restore-cold-cache.test.js`, `test/restore-unavailable.test.js`. A persist during that window keeps the entries not resolved yet (`pendingRestoreEntries`: the planner's pending ones, the ones awaiting the restore toast, and `restoreInFlight`, those handed to `runRestore` while their live-elsewhere check and open are awaited, even across two overlapping restores), otherwise any click or close would erase them from `openWorkingSet` before they are reached; see `test/restore-pending-persist.test.js`. A saved entry marked `fresh` (a local session with no transcript yet, see docs/session-restore.md) counts as indexed for the planner, and `runRestore` starts a new session in its project for it; see `test/clear-pending-persist.test.js`.

- **Neither the working-set restore nor the reload path resumes a session that is live in another process.** `runRestore` and the post-`loadProjects` re-open of `sessionStorage.activeSessionId` call `openSession(..., { automatic: true })`, which skips the session without a prompt when `guardResume` reports it live elsewhere; the skipped entry is not activated, stays in the persisted working set at its saved position, and is reported by a one-line notice. See `.ai/contexts/cli-session-state.md` ("Live elsewhere").

Expand Down
28 changes: 28 additions & 0 deletions .ai/contexts/session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,34 @@ the terminal header's stop button, and the grid card's stop button all funnel
through it) — it now asks `resolveSessionStop` which IPC to call instead of
always calling `stopSession`.

### Opening a session once

`openSession` keeps the open of each session id in flight in `openingSessions`
(id → `{ promise, byUser }`), set before its first `await` and removed when the
open settles. A second call for the same id while the first is still running
(a double click on a sidebar row, a repeated key, a restore reaching a session
the user just clicked) gets the same promise back instead of starting a second
`open-terminal`, so callers that read the result (`runRestore`) see the real
outcome. Without it, both calls passed the `openSessions.has` check before the
first `await`, and since `open-terminal` awaits the MCP server before it
records the PTY, the second request could start a second `claude --resume` on
the same transcript. The body is `openSessionNow`.

Only an equivalent open is joined. A user open (`automatic: false`) that finds
an automatic one in flight does not take its result: an automatic open of a
session live elsewhere refuses without asking, and the click must still ask.
It sets the click's flags (`continuationRetryCancelled`,
`sessionOpenedOutsideRestore`) at once, waits for the automatic open to
settle, then runs as usual, so it shows the terminal the automatic open made
or asks when that open refused. An automatic open joins any open in flight.

The key is the id the caller passed, which for a click is the id before
continuation resolution, while the restore passes the resolved one. So once
`resolveResumeSession` returns another id, the open waits for any open of
that id in flight and registers itself under it too; a click resolving to a
continuation the restore is opening then shows that terminal instead of
starting a second one.

### Reopening a plain terminal

A session's `type` is what tells `open-terminal` which of its two branches to
Expand Down
26 changes: 23 additions & 3 deletions .ai/contexts/trigger-watcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,22 @@ user typed and has not submitted (`composer-state.js`, fed from
`null` for an unknown or exited session, and **a `null` — or an absent `getComposerState` — means busy, never
free**.

### Re-keyed sessions

A fork or a `/clear` re-keys the open session in `activeSessions` under its new
id (`session-transitions.js`; see cli-session-state.md, "Owner of a /clear
transcript"), while a chain keeps the id it was given, and `/clear` is one of
the commands a chain may send. `createTriggerContext` takes `resolveSessionId`
(`sessionTransitions.currentSessionId` in main), and every lookup it makes
(`getPtyForSession`, `isSessionBusy`, `getComposerState`, `getTranscriptTurn`,
`getCliStatus`) goes through it, so the old id keeps reaching the same terminal
and the CLI status is read under the id the CLI now writes, as is
`getLiveDescriptor`. Results still carry the id the trigger named.
`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; without the resolution that chain ends with "session exited during
wait" at step 0.

### Session handle (2026-09-08, issue #220)

`trigger-watcher.js` never touches `session.pty` or a raw pid. It writes and
Expand Down Expand Up @@ -332,8 +348,11 @@ in-flight write. Two commands landing in one composer half-typed into each
other is a worse failure than anything `submitted` reports: corrupted input,
not just a wrong report field.

`start()` keeps `sessionLocks`, a `Map<sessionId, Promise>` used as a FIFO
mutex queue (`acquireSessionLock`). `processTriggerFile` awaits it right after
`start()` keeps `sessionLocks`, a `Map` used as a FIFO mutex queue
(`acquireSessionLock`), keyed by the PTY that `getPtyForSession` returns, or by
the session id when there is none. A fork or `/clear` gives one terminal a new
id; keyed by the PTY, triggers naming the old and the new id share one queue
(see "Re-keyed sessions"). `processTriggerFile` awaits it right after
`sessionId` is known to be a non-empty string — before any wait, any PTY
lookup, any write — and releases it in the function's own `finally`, so every
exit path (success, validation refusal, thrown exception) releases exactly
Expand Down Expand Up @@ -969,7 +988,8 @@ session, which has no local descriptor). No new watcher: it reuses the cache
the CLI reacted. `idle` alone covers a turn too fast for a poll to see
`busy`; `waiting` is a permission dialog our Enter opened. A spinner on the
level probe, or a status that began earlier, proves nothing. Otherwise the
existing recovery applies (one bare ``, only into a free composer, same
existing recovery applies (one bare `
`, only into a free composer, same
window), and the reaction is looked for again. Still nothing:
`confirmed: false`.
- **The recovery Enter is never written while the descriptor reads `waiting`
Expand Down
Loading
Loading