Skip to content
57 changes: 57 additions & 0 deletions .ai/contexts/cli-session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,3 +347,60 @@ reader knows immediately to look outward rather than hunt a bug in Switchboard.

Add one whenever you build on an undocumented external artefact; do not add one
for an assumption a normal unit test can pin.

## Conversation continuations

CLI 2.1.289 and 2.1.295 append `{type:"continued-in", sessionId:<old>,
continuedInSessionId:<new>, timestamp:...}` to the old transcript. These records
can be mid-file and repeated; the new transcript has no back-link. This is an
observed interface, pinned by an always-running synthetic fixture in
`test/session-continuation-index.test.js`. That fixture proves parser agreement,
not CLI drift; the test suite does not sample real conversation transcripts.

`session-continuations` IPC calls `sessionCache.resolveSessionContinuations`.
It traverses indexed links in record order, deduplicates terminal ids and bounds
traversal to 32 edges / 128 node visits. Cycles, missing continuation rows/files
and malformed continuation records return `unresolved`. Unrelated JSON parse
failures and incomplete tails do not block resume. File scanning yields between
chunks and has no total byte cap; chunk size only controls pacing.

`resolveResumeSession` is shared by restore and sidebar clicks. An automatic
restore selects exactly one terminal id. Several ids, unresolved graphs and
failed lookups hold the saved entry and join the non-blocking restore notice,
which names the original entry. A disk-present, unindexed continuation says
it is waiting for indexing; held entries retry once when indexing finishes,
even when the ordinary planner already settled. Ambiguous graphs ask for a
sidebar choice. No continuation confirmation runs during automatic restore. Manual
opens list each candidate's id and last activity before asking whether to open
it. Declining all candidates offers an explicit original-session choice; an
unresolved graph or failed lookup offers the same choice with a warning.
Cancellation keeps the held entry. Remote sessions retain their attach behavior.

`restoreStartupSessions` shares a map of lookup promises between the remembered
active-session open and working-set restore. Each original id is resolved once
during that startup pass; the map is discarded afterward so subsequent manual
opens and indexing retries re-check the transcript.

The remembered active-session `openSession` must be awaited before working-set
restore starts. Continuation and live-elsewhere checks are asynchronous: before
they finish, no terminal entry has been inserted into `openSessions`. Starting
`restoreWorkingSet` concurrently can see the same session as unopened and launch
a second PTY / duplicate `claude --resume`. Awaiting that first open also lets the
working-set pass reuse its completed continuation lookup. The remembered open
retains the existing renderer-reload behaviour: opening outside restore cancels
the working-set planner, so it neither prompts nor resumes the other saved entries.
The separate continuation-retry cancellation flag is set only by non-automatic
opens; the automatic remembered open does not cancel a held indexing retry.

For uncached local targets, continuation IPC uses `liveElsewhereChecked` before
declaring the transcript missing. `known:false` keeps the graph unresolved when
descriptors cannot be read. A live target remains
a candidate before its first transcript record is written. An indexed sibling
therefore cannot silently win over a live new continuation.

After resolution, `runRestore` checks the final id's liveness; a background
continuation requests the guard's existing `{attach}` verdict. Other live kinds
retain their refusal/confirmation rules. Saved entries are rekeyed before
opening; background continuation attachments retain the resolved entry in the
working set. Ordinary attach tabs remain excluded. Part A, live descriptor
rekeying while the original PTY runs, remains out of scope pending #477.
6 changes: 6 additions & 0 deletions .ai/contexts/ipc-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -628,3 +628,9 @@ What Switchboard sets *after* cleaning stays: the plain terminal's `CLAUDECODE=1
3. Renderer: `const result = await window.api.myThing(...)`
4. Test: prefer a unit test for the main-side logic (extract to a pure function the handler calls); jsdom integration tests for renderer side.
5. Document here.

### Conversation continuations (#518B)

| IPC | Args | Returns | Notes |
|---|---|---|---|
| `session-continuations` | `(sessionId)` | `{candidates:[{sessionId,modified}],unresolved,continued}` | Bounded traversal of the transcript continuation index; exposed as `api.getSessionContinuations`. Restore and manual resume share the renderer resolver. See cli-session-state.md, Conversation continuations. |
76 changes: 76 additions & 0 deletions .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -1671,3 +1671,79 @@ search_fts USING fts5(id, type, folder, title, body, tokenize='trigram')
search_map(id PK, type, folder) -- backref for FTS delete
settings(key PK, value JSON)
```

## Continuation index (#518B)

`session_cache.continuationIndex` is nullable JSON with `format:3`, `ids`,
`bytes`, `complete`, `mtime` and `unresolved`. Schema reconciliation adds the
column without wiping old rows. Full transcript scans collect links while
already parsing the file, including worker scans. Missing or older-format
indexes are rebuilt lazily, including format-2 indexes that marked a mere
`continued-in` mention in an unrelated malformed or oversized record unresolved.

Header-only refresh skips subagent continuation indexes and shares a 1 MiB
synchronous continuation budget across all changed parent files in that refresh.
Other files retain their stale index until a later refresh or resume lookup.
Existing display-header and SDK-entrypoint reads keep their own budgets.

`resolveSessionContinuations` verifies format, size and mtime before trusting
an index. A complete fresh index requires only stat. A legacy, incomplete or
stale index is read in 1 MiB chunks, yielding with `setImmediate` between chunks,
with no total scan cap. Tests can inject smaller `chunkBytes`. Persistence uses
`setCachedContinuationIndex`, which updates only that column, preserving display
fields written by a concurrent refresh; it does not upsert an old row snapshot.

When a target has no cache row, resolution lazily inventories transcript filenames
recursively under the source session's projects root (or that host's mirror root).
This asynchronous directory inventory is shared within one resolution and reads
no transcript contents. A file found anywhere in that root remains unresolved
until indexed; resolution does not index it on demand. Directory read failures
or symbolic links that may hide files also keep absence unconfirmed.
For a local target without a cache row, the IPC also supplies the existing
CLI `liveElsewhereChecked` lookup. A live descriptor keeps the target as a terminal
candidate even before its first transcript record exists; `known:false`, including
an unreadable descriptor directory, keeps resolution unresolved. A confirmed
`known:true, live:null` result permits the disk inventory to check absence.
Remote mirror targets never query local CLI descriptors.
Only a target absent from the cache, liveness lookup and inventory is marked missing.
The graph drops that target if its parent has another existing continuation.
If every child is confirmed missing, that parent remains unresolved, including
inside a longer chain: silently resuming a discontinued branch is unsafe, and
manual opening already offers an explicit original-id choice. Cached rows with
missing or unreadable transcripts retain the existing unresolved behaviour.

Full-scan and chunk indexes both carry `version:3`, `size`, `sealedIds`, a bounded base64
`pending` line, and the last 64 indexed bytes as hexadecimal `tail`. The tail is
verified before reusing a cursor after growth; mismatch, shrink or a same-size
mtime change starts at byte zero. This is a local tail check, not a proof that
an arbitrary rewrite preserved the entire prefix. Full reads retain the same
cursor witness and tail state, so the first scan after an append reads only
the appended bytes and the bounded witness. Unterminated valid records remain
tentative until sealed by a newline; partial tails are reprocessed on append.

Lines are retained up to 1 MiB. Oversized records are ignored by both continuation
indexers; chunk scans carry `skipLine` until the newline, including across appends.
Actual CLI continuation records are small bookkeeping records. Malformed lines
only hold resolution when they match `"type"\s*:\s*"continued-in"`; an unrelated
mention of the word does not. Valid continuation records with invalid targets
also remain unresolved. Partial tails remain buffered for a
later append; tail errors are recomputed rather than carried forward forever.
No transcript content is sent through the IPC, only candidate ids and activity
times.

A disk-present target without a cache row returns `waitingForIndex` with the
unresolved graph. Automatic restore uses this to show a waiting notice instead
of a continuation-choice notice. `markRestoreIndexingDone` reloads projects and
retries held continuation entries, including after the ordinary restore planner
has settled. If completion arrived during an in-flight resolution before it could
be held, that restore pass notices completion and performs the same retry when
it returns. Passes starting after completion do not schedule another retry.
It invalidates their startup lookup promises before resolving again,
persists the resolved id and active state, and consumes each retry once. A failed
project reload leaves retries available for the next completion event. A manual
open cancels automatic retry through `continuationRetryCancelled`; a remembered
automatic open does not. The separate `sessionOpenedOutsideRestore` guard keeps
its original behaviour: any open outside working-set restore cancels the planner,
including the automatic remembered open on a renderer reload. Entries still
ambiguous after indexing retain the sidebar-choice notice. Entries still unindexed
after the retry name that state and offer a manual sidebar retry.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc

### Fixed
- On Windows, writing to a terminal that is closing no longer raises an uncaught main-process exception, including when closing an attached agent tab. (#517)
- Restoring a conversation that continued under another id now opens its continuation and saves the new id, waits and retries when indexing finishes, and keeps live continuations available before their first transcript is saved. Unrelated damaged or oversized records no longer block restore; multiple or unresolved continuations, including when process status cannot be read, stay saved with a notice to open the old session and choose a continuation or explicitly reopen the original. (#518)

## v0.0.91 — 2026-10-09

Expand Down
12 changes: 8 additions & 4 deletions db.js
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ if (migrations.length > currentDbVersion) {
// see .ai/contexts/session-cache.md ("SDK-launched sessions")
if (!cols.has('entrypoint')) db.exec('ALTER TABLE session_cache ADD COLUMN entrypoint TEXT');
if (!cols.has('scheduleSlug')) db.exec('ALTER TABLE session_cache ADD COLUMN scheduleSlug TEXT');
if (!cols.has('continuationIndex')) db.exec('ALTER TABLE session_cache ADD COLUMN continuationIndex TEXT');
db.exec('CREATE INDEX IF NOT EXISTS idx_session_cache_parent ON session_cache(parentSessionId)');
// Fork table (shipped in our v5), referenced unconditionally by prepare()
// below — must exist whatever db_version claims.
Expand Down Expand Up @@ -421,8 +422,8 @@ const stmts = {
cacheCount: db.prepare('SELECT COUNT(*) as cnt FROM session_cache'),
cacheGetAll: db.prepare('SELECT * FROM session_cache'),
cacheUpsert: db.prepare(`
INSERT INTO session_cache (sessionId, folder, projectPath, summary, firstPrompt, created, modified, messageCount, slug, aiTitle, parentSessionId, agentId, subagentType, description, fileMtime, bridgeSessionId, mergedIntoSessionId, entrypoint, scheduleSlug)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO session_cache (sessionId, folder, projectPath, summary, firstPrompt, created, modified, messageCount, slug, aiTitle, parentSessionId, agentId, subagentType, description, fileMtime, bridgeSessionId, mergedIntoSessionId, entrypoint, scheduleSlug, continuationIndex)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(sessionId) DO UPDATE SET
folder = excluded.folder, projectPath = excluded.projectPath,
summary = excluded.summary, firstPrompt = excluded.firstPrompt,
Expand All @@ -433,7 +434,8 @@ const stmts = {
subagentType = excluded.subagentType, description = excluded.description,
bridgeSessionId = excluded.bridgeSessionId,
mergedIntoSessionId = excluded.mergedIntoSessionId,
entrypoint = excluded.entrypoint, scheduleSlug = excluded.scheduleSlug
entrypoint = excluded.entrypoint, scheduleSlug = excluded.scheduleSlug,
continuationIndex = excluded.continuationIndex
`),
cacheGetByParent: db.prepare('SELECT * FROM session_cache WHERE parentSessionId = ? ORDER BY created ASC'),
// Kept as SELECT * (upstream narrowed this to sessionId+fileMtime): our
Expand All @@ -443,6 +445,7 @@ const stmts = {
cacheGetByFolder: db.prepare('SELECT * FROM session_cache WHERE folder = ?'),
cacheGetMissingEntrypoint: db.prepare('SELECT sessionId, folder FROM session_cache WHERE entrypoint IS NULL AND parentSessionId IS NULL'),
cacheSetEntrypoint: db.prepare('UPDATE session_cache SET entrypoint = ? WHERE sessionId = ?'),
cacheSetContinuationIndex: db.prepare('UPDATE session_cache SET continuationIndex = ? WHERE sessionId = ?'),
cacheGetFolder: db.prepare('SELECT folder FROM session_cache WHERE sessionId = ?'),
cacheGetSession: db.prepare('SELECT * FROM session_cache WHERE sessionId = ?'),
cacheDeleteSession: db.prepare('DELETE FROM session_cache WHERE sessionId = ?'),
Expand Down Expand Up @@ -564,7 +567,7 @@ const upsertCachedSessionsBatch = db.transaction((sessions) => {
s.subagentType || null, s.description || null,
s.fileMtime || null, s.bridgeSessionId || null, s.mergedIntoSessionId || null,
typeof s.entrypoint === 'string' ? s.entrypoint : null,
s.scheduleSlug || null
s.scheduleSlug || null, s.continuationIndex || null
);
}
});
Expand Down Expand Up @@ -904,6 +907,7 @@ module.exports = {
getMeta, getAllMeta, setName, toggleStar, setArchived,
isCachePopulated, getAllCached, getCachedByFolder, getCachedMissingEntrypoint, setCachedEntrypoints, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions,
touchCachedModified: (sessionId, modified, fileMtime = modified) => stmts.cacheTouchModified.run(modified, fileMtime, sessionId),
setCachedContinuationIndex: (sessionId, index) => stmts.cacheSetContinuationIndex.run(index, sessionId),
deleteCachedSession, deleteCachedFolder,
replaceSessionMetrics,
getFolderMeta, getAllFolderMeta, setFolderMeta,
Expand Down
32 changes: 32 additions & 0 deletions docs/session-restore.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,37 @@ its previous launch. Sessions reopen one after another, half a second apart.
restoring N session(s) from last time…*.
- Opening a session yourself while restore is running cancels the rest of it.

Reloading the renderer with a remembered active session reopens that session and
cancels the working-set planner, so it does not ask to restore or reopen the
other saved entries. A manual open also cancels held continuation retries;
an automatic remembered open does not cancel those retries.

If a conversation continued under another id, restore follows its continuation
chain and saves the final id with the saved entry's project and active state.
One final id is selected automatically. A target absent from the index, the
transcripts on disk and live CLI processes is ignored when its parent has another existing
continuation. A transcript present on disk but not yet indexed still holds
restore with a waiting-for-indexing notice; held entries retry once when indexing
finishes. If process status cannot be checked, a missing target remains unresolved
instead of being discarded. If all of a node's targets are missing,
restore stays unresolved rather than reopening that node automatically.
Several final ids, a cycle or a malformed continuation record hold the saved entry
without launching it. A non-blocking notice names the held conversation and
asks you to open it from the sidebar to choose; other saved sessions continue
restoring. Automatic restore never displays a continuation confirmation dialog.

Clicking an old sidebar row offers its continuations, listing each id and its
last activity. After declining the candidates, you can explicitly choose
**Open the original session** or cancel. An unresolved continuation also offers
this explicit original-session choice, with a warning and any known candidates.
The original id is never silently selected when continuation information exists.
Ordinary malformed lines that mention continuations, unrelated incomplete tails
and oversized records do not block opening the original session. Large transcripts
are indexed in yielded chunks without a total byte limit.

A live continuation remains a candidate even before its first transcript record
exists. A live background continuation uses the existing attach path and its final id
stays in the saved working set for a later restore.

Within one run of the app, going back to a session whose process still runs is
a reattach: the terminal replays its buffered output onto the same process.
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ const rendererCrossFileGlobals = {
createRestorePlanner: 'readonly',
// Resume guard for sessions live in another process (public/resume-guard.js)
guardResume: 'readonly',
resolveResumeSession: 'readonly',
liveElsewhereMany: 'readonly',
// Agents view (public/agents-view.js) — see .ai/contexts/bg-agents.md
agentsViewActive: 'writable',
Expand Down
7 changes: 5 additions & 2 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
}

// Shell profiles → shell-profiles.js
const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, quoteArgvForShell } = require('./shell-profiles');

Check warning on line 71 in main.js

View workflow job for this annotation

GitHub Actions / lint

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler, scheduleBindRefusals, resolveScheduleSandbox, scheduleRegistry, initialScheduleProjects, scanSchedules, setScheduleEnabled } = require('./schedule-runner');
const { applyAndPersistArchived, clearArchivedEntry, archivePlanForGroups, validArchiveGroups, archiveProjectFolders, reenableOfferedSchedules, dismissReenableOffer } = require('./archived-projects');
const { encodeProjectPath } = require('./encode-project-path');
Expand Down Expand Up @@ -151,7 +151,7 @@
}
const {
getMeta, getAllMeta, toggleStar, setName, setArchived,
isCachePopulated, getAllCached, getCachedByFolder, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions,
isCachePopulated, getAllCached, getCachedByFolder, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions, setCachedContinuationIndex,
deleteCachedSession, deleteCachedFolder, replaceSessionMetrics, touchCachedModified,
getFolderMeta, getAllFolderMeta, setFolderMeta,
upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType,
Expand Down Expand Up @@ -489,11 +489,11 @@
deleteCachedFolder, getCachedByFolder, upsertCachedSessions, deleteCachedSession, replaceSessionMetrics, touchCachedModified,
deleteSearchFolder, deleteSearchSession, upsertSearchEntries,
setFolderMeta, getFolderMeta, getAllFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName,
isInitialScanComplete, setInitialScanComplete, getCachedMissingEntrypoint, setCachedEntrypoints, getCachedSession,
isInitialScanComplete, setInitialScanComplete, getCachedMissingEntrypoint, setCachedEntrypoints, getCachedSession, setCachedContinuationIndex,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 495 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 495 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u
buildProjectsFromCache, backfillEntrypoints, revealIfSdkSession, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker,

Check warning on line 496 in main.js

View workflow job for this annotation

GitHub Actions / lint

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u
scanFoldersViaWorker, setRemoteRoots, resolveFolderDir, isIndexingFinished } = sessionCache;
const { resolveJsonlPath, readSubagentMeta } = require('./read-session-file');

Expand Down Expand Up @@ -2659,7 +2659,7 @@
// WSL profiles only work for plain terminals — Claude CLI sessions need the
// Windows shell because session data lives on the Windows filesystem.
const requestedProfile = resolveShell(effectiveProfileId);
const useWslProfile = isWslShell(requestedProfile.path) && isPlainTerminal;

Check warning on line 2662 in main.js

View workflow job for this annotation

GitHub Actions / lint

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u
const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal)
? resolveShell('auto')
: requestedProfile;
Expand Down Expand Up @@ -3139,6 +3139,9 @@

// see .ai/contexts/cli-session-state.md ("Live elsewhere")
ipcMain.handle('session-live-elsewhere', (_event, sessionId) => cliSessionState.liveElsewhere(sessionId, sessionHasPty, ptyPids));
ipcMain.handle('session-continuations', (_event, sessionId) => sessionCache.resolveSessionContinuations(sessionId, {
getSessionLiveElsewhere: id => cliSessionState.liveElsewhereChecked(id, sessionHasPty, ptyPids),
}));
ipcMain.handle('sessions-live-elsewhere', (_event, sessionIds) => cliSessionState.liveElsewhereMany(sessionIds, sessionHasPty, ptyPids));

// see .ai/contexts/bg-agents.md
Expand Down
Loading
Loading