Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
46 changes: 35 additions & 11 deletions .ai/contexts/cli-session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,17 +444,29 @@ finds the live pid whose state file names `newId` and walks its parent chain
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.
- `unknown` — the pid is found but its parent cannot be read. 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.

On Windows and macOS there is no `/proc`; `clearOwner` uses the process table
of "Own descendants outside Linux" instead and 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 with creation times that only go back in time. A pid the table
does not list yet is `pending` (the table refreshes in the background, and the
recheck asks again). The CLI on macOS writes no anchor, so there a `/clear` is
`other` and never followed: the new conversation is listed apart, as before;
the follow-up for #518 may re-key it from the descriptor's `sessionId`.

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.
in one lookup, however many times it was cleared. An id that a live session
holds itself is never redirected: reopening the cleared conversation starts a
terminal under the old id, and from then on that id is its own (the alias is
dropped). An alias whose target is gone or exited is dropped too.

**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,
Expand All @@ -463,7 +475,9 @@ before the `/clear` record. A new file whose first real turn
`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.
session's own transcript is never held this way. A held file is read again
only when its size or mtime changes (the signals are cached by path), and the
"NO MATCH" line of a session awaiting a fork is logged once per file.

**Snapshot-only fork file.** A session started with `--fork-session` waits
for its fork's transcript, which holds only snapshots until the first prompt.
Expand All @@ -476,10 +490,20 @@ 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
comes first and must be skipped, not taken as the file's first turn. The
command may be `/clear`, or its aliases `/reset` and `/new` (CLI 2.1.296:
`name:"clear"`, `aliases:["reset","new"]`), with or without a name argument.
The owner and match lines are logged when they change, not on every recheck.
`/clear` writes no `continued-in` record (checked on CLI 2.1.296: the only
writer is the background-session handoff; 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 to the new one.

`session-forked` carries the kind of re-key, `'clear'` or `'fork'`. The
renderer re-keys as for a fork, and for a `/clear` whose old id was not a
pending (transcript-less) row it replaces the open entry's session with a new
one for the new id, built as `launchNewSession` builds one (titled "New
session", not starred, no title, bridge or dates of the old conversation): 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
Expand Down
19 changes: 11 additions & 8 deletions .ai/contexts/trigger-watcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ the commands a chain may send. `createTriggerContext` takes `resolveSessionId`
(`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.
`getLiveDescriptor`. Results still carry the id the trigger named. An id a
live session holds itself resolves to that session, so a trigger aimed at a
cleared conversation that was reopened reaches its own terminal. When a lookup
finds the transcript under a new id, the turn reader forgets the file it read
under the old one.
`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
Expand Down Expand Up @@ -348,11 +352,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` 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
`start()` keeps `sessionLocks`, a `Map<sessionId, Promise>` used as a FIFO
mutex queue (`acquireSessionLock`). A fork or `/clear` gives one terminal a new
id, so a trigger waits for every queued id that resolves (`ctx.resolveSessionId`,
see "Re-keyed sessions") to the same session as its own: the old and the new id
share one queue, and a PTY replaced under the same id keeps it. `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 @@ -988,8 +992,7 @@ 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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc
- A double click on a session in the sidebar opens it once, instead of sometimes starting two terminals that resume the same conversation. (#539)
- Closing the window or quitting no longer leaves Switchboard running when its window has stopped responding: the window closes as before, and once the system reports it not responding, about 5 seconds after the close, or once you close it again, it is closed anyway. A window still busy at that point is closed too, and its last unsaved changes to the list of open sessions are lost; one that recovers sooner closes normally. (#540)
- Archiving or deleting a session no longer fails with "cannot tell whether a background job is running this session" while a background job that has not received its first prompt yet is live. (#553)
- On Linux and Windows, `/clear` (or `/reset`, `/new`) in a session keeps the open terminal on its sidebar row, which now follows the new conversation, instead of listing that conversation as a separate session. The cleared conversation stays in the list as a past session, and a session quit after `/clear` before its first prompt comes back on restore as a new session in the same project. On macOS the new conversation is still listed apart. (#477)

## v0.0.92 — 2026-10-10

Expand Down Expand Up @@ -49,7 +50,6 @@ What changes for you in each release of Switchboard. How to write an entry: [doc
- A session that moves into a worktree while it runs stays in the sidebar, under its project, instead of disappearing while its terminal keeps running. A running session also stays listed when its transcript leaves its folder for any other reason. (#485)
- Worktree folders from another host, or whose repository is not listed, show in the sidebar instead of being nested under the wrong folder or not shown at all. The worktrees of a repository hidden with Hide Project stay hidden. (#473)
- Quitting or closing the window saves the open sessions right away, so the next start restores the sessions that were open, not an empty or out-of-date list. (#479)
- `/clear` in a session keeps the open terminal on its sidebar row, which now follows the new conversation, instead of listing that conversation as a separate session. The cleared conversation stays in the list as a past session, and a session quit after `/clear` before its first prompt comes back on restore as a new session in the same project. (#477)
- Remote triggers refuse commands containing invisible format characters, default-ignorable characters or braille blanks, including joined emoji, emoji with variation selectors (such as hearts), soft hyphens and right-to-left marks. Fullwidth slash, exclamation and number-sign prefixes are refused too. (#440)

## v0.0.89 — 2026-10-04
Expand Down
18 changes: 15 additions & 3 deletions cli-session-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -577,11 +577,11 @@ async function findLiveProcess(sessionId, { exclude = () => false } = {}) {
// see .ai/contexts/cli-session-state.md ("Owner of a /clear transcript")
function clearOwner(sessionId, ptyPid) {
if (typeof sessionId !== 'string' || !sessionId || !Number.isInteger(ptyPid)) return 'pending';
startProcessTableRefresh();
let names;
try { names = fs.readdirSync(dir); } catch { return 'pending'; }
const key = sessionId.toLowerCase();
let pid = null;
let found = null;
for (const name of names) {
if (!STATE_FILE_RE.test(name)) continue;
let raw;
Expand All @@ -590,11 +590,23 @@ function clearOwner(sessionId, ptyPid) {
if (!Number.isInteger(raw.pid) || raw.pid <= 0) continue;
if (!isProcessAlive(raw.pid)) continue;
pid = raw.pid;
found = raw;
break;
}
if (pid == null) return 'pending';
if (chainReaches(pid, (current) => current === ptyPid, false)) return 'mine';
return readParentPid(pid) == null ? 'unknown' : 'other';
if (processTableEnabled) {
startProcessTableRefresh();
if (!tableEntry(pid)) return 'pending';
return conversationOwns(found, new Set([ptyPid])) ? 'mine' : 'other';
}
let current = pid;
for (let depth = 0; depth < 64 && current && current > 1; depth++) {
if (current === ptyPid) return 'mine';
const parent = readParentPid(current);
if (parent == null) return depth === 0 ? 'unknown' : 'other';
current = parent;
}
return 'other';
}

function ownPidTest(ptyPids) {
Expand Down
16 changes: 10 additions & 6 deletions docs/session-restore.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,16 @@ session's id and project, and which one was active — in its global settings
A session started with **+**, or one just cleared with `/clear`, has no
conversation until its first prompt: the CLI has nothing to resume, and the
index does not list it. It is saved as a *fresh* entry (`fresh: true`, with
its project). On the next start a fresh entry needs no index: restore starts
a new session in that project, with the project's current defaults for a new
session, except that it never creates a worktree, since the saved project
path already is the folder to start in. Once its first prompt makes it a real
session, the set is saved again with it as an ordinary entry. A session
without a transcript on a remote host is still left out.
its project). Restore waits for indexing to finish before it treats a fresh
entry as having no transcript: one whose first prompt landed before the quit
is indexed by then and is resumed like any other session. A fresh entry that
is still not in the index then is started anew: a new session in that project,
with the project's current defaults for a new session, except that it never
creates a worktree, since the saved project path already is the folder to
start in. Once its first prompt makes it a real session, the set is saved
again with it as an ordinary entry. A session without a transcript is still
left out when it runs on a remote host, or was started as a fork or in a new
worktree: starting it anew would not give back the same thing.

## Closing the app

Expand Down
2 changes: 1 addition & 1 deletion preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.on('cli-busy-state', (_event, sessionId, busy) => callback(sessionId, busy));
},
onSessionForked: (callback) => {
ipcRenderer.on('session-forked', (_event, oldId, newId) => callback(oldId, newId));
ipcRenderer.on('session-forked', (_event, oldId, newId, kind) => callback(oldId, newId, kind));
},
onSubagentSpawned: (cb) => ipcRenderer.on('subagent-spawned', (_e, payload) => cb(payload)),
onSubagentCompleted: (cb) => ipcRenderer.on('subagent-completed', (_e, payload) => cb(payload)),
Expand Down
25 changes: 13 additions & 12 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ function persistWorkingSet({ final = false } = {}) {
if (entry.closed) continue;
// see docs/session-restore.md ("A session with no transcript yet")
const fresh = pendingSessions.has(sessionId);
if (fresh && entry.session.remoteAlias) continue;
if (fresh && (entry.session.remoteAlias || pendingSessions.get(sessionId).restorable === false)) continue;
set.push({
sessionId,
projectPath: entry.session.projectPath,
Expand Down Expand Up @@ -264,14 +264,15 @@ window.flushStateForExit = flushStateForExit;
if (window.api.onExitFlush) window.api.onExitFlush(() => { flushStateForExit(); });

async function runRestore(list, { retryAfterIndexing = !restoreIndexingDone } = {}) {
const pending = list.filter(item => (item.fresh || sessionMap.has(item.sessionId)) && !openSessions.has(item.sessionId));
const startsFresh = item => item.fresh && !sessionMap.has(item.sessionId);
const pending = list.filter(item => (startsFresh(item) || sessionMap.has(item.sessionId)) && !openSessions.has(item.sessionId));
for (const item of pending) restoreInFlight.set(item.sessionId, item);
const skippedNow = [];
const startedAs = new Map();
try {
const liveById = await liveElsewhereMany(pending.filter(item => !item.fresh).map(item => item.sessionId), { api: window.api });
const liveById = await liveElsewhereMany(pending.filter(item => !startsFresh(item)).map(item => item.sessionId), { api: window.api });
for (const [position, item] of list.entries()) {
if (item.fresh) {
if (startsFresh(item)) {
if (!restoreInFlight.has(item.sessionId)) continue;
try {
const project = { projectPath: item.projectPath };
Expand Down Expand Up @@ -598,7 +599,7 @@ window.api.onSessionDetected((tempId, realId) => {
pollActiveSessions();
});

function applySessionForked(oldId, newId) {
window.api.onSessionForked((oldId, newId, kind) => {
if (window.ATRACE) window.atrace('recv.session-forked', newId, { oldId, known: openSessions.has(oldId) });
const entry = openSessions.get(oldId);
if (!entry) return;
Expand All @@ -620,11 +621,12 @@ function applySessionForked(oldId, newId) {
if (pendingEntry) {
pendingEntry.sessionId = newId;
pendingSessions.set(newId, pendingEntry);
} else if (entry.session.projectPath) {
} else if (kind === 'clear' && entry.session.projectPath) {
// see .ai/contexts/cli-session-state.md ("Owner of a /clear transcript")
const projectPath = entry.session.projectPath;
const session = { ...entry.session, sessionId: newId, summary: 'New session', firstPrompt: '', name: null, messageCount: 0 };
pendingSessions.set(newId, { session, projectPath, folder: encodeProjectPath(projectPath), cleared: true });
const now = new Date().toISOString();
entry.session = { sessionId: newId, summary: 'New session', firstPrompt: '', projectPath, name: null, starred: 0, archived: 0, messageCount: 0, modified: now, created: now };
pendingSessions.set(newId, { session: entry.session, projectPath, folder: encodeProjectPath(projectPath), cleared: true });
}
sessionMap.delete(oldId);
sessionMap.set(newId, entry.session);
Expand All @@ -642,9 +644,7 @@ function applySessionForked(oldId, newId) {
});
schedulePersistWorkingSet();
pollActiveSessions();
}

window.api.onSessionForked(applySessionForked);
});

function applyProcessExit(sessionId, exitCode, signal, stopped) {
if (window.ATRACE) window.atrace('recv.process-exited', sessionId, { exitCode, signal, stopped });
Expand Down Expand Up @@ -1346,7 +1346,8 @@ async function launchNewSession(project, sessionOptions) {

// Track as pending (no .jsonl yet)
const folder = encodeProjectPath(projectPath);
pendingSessions.set(sessionId, { session, projectPath, folder });
const restorable = !(sessionOptions && (sessionOptions.forkFrom || sessionOptions.worktree));
pendingSessions.set(sessionId, { session, projectPath, folder, ...(restorable ? {} : { restorable: false }) });

// Inject into cached project data so it appears in sidebar immediately
sessionMap.set(sessionId, session);
Expand Down
2 changes: 1 addition & 1 deletion public/restore-plan.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function createRestorePlanner({ savedSet, maxTicks = 50, askOnce = false } = {})
return { action: 'nothing', candidates: [], remaining: 0, unavailable: [] };
}

const indexedIds = [...remaining].filter(id => items.get(id).fresh || (sessionMap && sessionMap.has(id)));
const indexedIds = [...remaining].filter(id => (sessionMap && sessionMap.has(id)) || (indexingDone && items.get(id).fresh));
const allIndexed = indexedIds.length === remaining.size;

if (askOnce) {
Expand Down
Loading
Loading