Skip to content
10 changes: 8 additions & 2 deletions .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,16 @@ Residuals: `encodeProjectPath` truncates at 200 characters and appends a 32-bit
- **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`.
- **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`.

- **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").

- **SDK-launched sessions are hidden from the project list, not from the index.** Programs driving Claude through the Agent SDK (the brain-runner's `claude -p` episodes, a Python review tool spawning one session per file batch, strap's developers) write ordinary top-level transcripts in the project's folder: `isSidechain: false`, no `subagents/` directory, and no record pointing back at the session that started them. They cannot be nested under a parent the way Task subagents are; the only reliable marker is the `entrypoint` field the CLI stamps on every record (`cli` when typed in a terminal, `sdk-cli` / `sdk-py` / `sdk-ts` for the SDK; measured 2026-10-07: ~4 500 SDK transcripts against ~540 interactive ones on one machine). Rows stay in `session_cache`, `session_metrics` and FTS, so the heatmap and token totals still count that activity.
- **What `session_cache.entrypoint` holds.** The first `type: 'user'` record's `entrypoint`, or `cli` as soon as any user record says `cli` (an SDK session someone resumed and typed into is theirs again — 5 strap developer transcripts measured). `''` when the first user record carries none: a scheduled run is pre-seeded by `createScheduleSession` without one, then resumed by `claude --resume -p` whose records say `sdk-cli`, and must stay visible. A non-string value counts as none. `NULL` means not read yet.
- **The column is added without a cache wipe.** Unlike the other reconciliation columns it does not set `mustReindex`: `db.js` runs before `requestSingleInstanceLock` in `main.js`, so a refused second launch would empty the running instance's cache, and a scan failing after the wipe would leave the app empty. Existing rows keep `NULL`, which `buildProjectsFromCache` treats as visible, and `backfillEntrypoints()` (started from `get-projects`, once per process, 200 rows per `setImmediate` tick) fills them with `readSessionEntrypoint`.
- **`readSessionEntrypoint` avoids reading interactive transcripts.** It reads 256 KB chunks until the first user turn (an SDK prompt is first written as a `queue-operation` line that can exceed 256 KB on its own: 190 of ~4 500 SDK transcripts measured); a non-SDK one is returned as is (so a session pre-seeded without an entrypoint and later typed into stores `''` here but `cli` from `readSessionFile`; both are visible, only `sdk-*` matters), and only an `sdk-*` one is scanned further for a `cli` user turn, in full up to 2 MB, and beyond that only its first and last 256 KB on the live path (`refreshFolder` runs it at every watcher flush, and the turn just typed is at the end; a full read of a 14 MB transcript costs ~250 ms of main thread), but in full from `backfillEntrypoints`, which runs once per row (sizes measured over 4 495 SDK transcripts: p50 44 KB, p99 711 KB, max 13 MB). `refreshFolder` calls it on the header-only branch for a cached `sdk-*` row, because a turn typed in a terminal lands at the end of the file, beyond the header.
- **What stays listed.** `hiddenSdkSessionIds` hides `sdk-*` rows while the global `hideSdkSessions` setting (default on) is set, except a session open in a terminal (`activeSessions`, not exited) or in the saved working set (`global.openWorkingSet`), so neither disappears from under the user nor fails to restore, and except a parent whose compaction mirror (`mergedIntoSessionId`) is not SDK. Subagent rows of a hidden session are dropped too (they would otherwise land in "Orphan subagents"), and a project left with only hidden rows gets no empty header from the on-disk folder pass. Because `activeSessions` is read when the list is built, opening an SDK session afterwards (a resume from search, a trigger, a remote attach) calls `revealIfSdkSession`, which sends `projects-changed` so the renderer reloads the list with it. The saved-working-set exemption only holds while the entry stays in `openWorkingSet`: `persistWorkingSet` (`public/app.js`) re-adds the entries the restore has not resolved yet (`restorePlanner.pending()`, and `restoreAwaitingConsent` while the restore toast is unanswered), so a persist in the middle of a cold restore does not drop a saved SDK session that is not indexed yet.

## Remote SSH hosts (issue #201)

A declared SSH host's `~/.claude/projects` is mirrored into
Expand Down Expand Up @@ -1636,7 +1642,7 @@ it. The state is the `archivedProjects` settings row:
session_cache(sessionId PK, folder, projectPath, summary, firstPrompt,
created, modified, messageCount, slug, aiTitle,
parentSessionId, agentId, subagentType, description,
fileMtime, bridgeSessionId, mergedIntoSessionId)
fileMtime, bridgeSessionId, mergedIntoSessionId, entrypoint)
session_meta(sessionId PK, customTitle, starred, archived)
cache_meta(folder PK, projectPath, indexMtimeMs)
search_fts USING fts5(id, type, folder, title, body, tokenize='trigram')
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc
- **Archive folder** on a project header opens a dialog that archives the folder's sessions and disables its enabled schedules, each optional, and hides the folder with its worktrees. The folder comes back, with its settings, when you add it again or a new session starts in it, and then offers to turn back on the schedules the archive disabled. (#473)
### Changed
- Markdown files open formatted in Touched, with a toggle back to the source that is remembered. (#472)
- Sessions a program started through the Claude Agent SDK, such as headless runs or review agents, no longer fill the sidebar and grid. Turn off **Hide SDK-launched Sessions** in Global Settings to list them again. Sessions you have open, scheduled tasks and sessions you continued by typing in them stay listed, and the activity heatmap still counts the hidden ones. (#486)
### Fixed
- 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)
- 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)
Expand Down
24 changes: 19 additions & 5 deletions db.js
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,8 @@ if (migrations.length > currentDbVersion) {
mustReindex = true;
}
}
// see .ai/contexts/session-cache.md ("SDK-launched sessions")
if (!cols.has('entrypoint')) db.exec('ALTER TABLE session_cache ADD COLUMN entrypoint 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 @@ -418,8 +420,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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO session_cache (sessionId, folder, projectPath, summary, firstPrompt, created, modified, messageCount, slug, aiTitle, parentSessionId, agentId, subagentType, description, fileMtime, bridgeSessionId, mergedIntoSessionId, entrypoint)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(sessionId) DO UPDATE SET
folder = excluded.folder, projectPath = excluded.projectPath,
summary = excluded.summary, firstPrompt = excluded.firstPrompt,
Expand All @@ -429,14 +431,17 @@ const stmts = {
parentSessionId = excluded.parentSessionId, agentId = excluded.agentId,
subagentType = excluded.subagentType, description = excluded.description,
bridgeSessionId = excluded.bridgeSessionId,
mergedIntoSessionId = excluded.mergedIntoSessionId
mergedIntoSessionId = excluded.mergedIntoSessionId,
entrypoint = excluded.entrypoint
`),
cacheGetByParent: db.prepare('SELECT * FROM session_cache WHERE parentSessionId = ? ORDER BY created ASC'),
// Kept as SELECT * (upstream narrowed this to sessionId+fileMtime): our
// reconcile path in session-cache.js caches the whole row so a header-only
// refresh can merge display fields without re-reading the transcript body.
// fileMtime still comes through, so upstream's invalidation key works.
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 = ?'),
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 @@ -556,7 +561,8 @@ const upsertCachedSessionsBatch = db.transaction((sessions) => {
s.slug || null, s.aiTitle || null,
s.parentSessionId || null, s.agentId || null,
s.subagentType || null, s.description || null,
s.fileMtime || null, s.bridgeSessionId || null, s.mergedIntoSessionId || null
s.fileMtime || null, s.bridgeSessionId || null, s.mergedIntoSessionId || null,
typeof s.entrypoint === 'string' ? s.entrypoint : null
);
}
});
Expand Down Expand Up @@ -588,6 +594,14 @@ function upsertCachedSessions(sessions) {
upsertCachedSessionsBatch(sessions);
}

function getCachedMissingEntrypoint() {
return stmts.cacheGetMissingEntrypoint.all();
}

const setCachedEntrypoints = db.transaction((pairs) => {
for (const { sessionId, entrypoint } of pairs) stmts.cacheSetEntrypoint.run(entrypoint, sessionId);
});

function getCachedByFolder(folder) {
return stmts.cacheGetByFolder.all(folder);
}
Expand Down Expand Up @@ -886,7 +900,7 @@ function closeDb() {

module.exports = {
getMeta, getAllMeta, setName, toggleStar, setArchived,
isCachePopulated, getAllCached, getCachedByFolder, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions,
isCachePopulated, getAllCached, getCachedByFolder, getCachedMissingEntrypoint, setCachedEntrypoints, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions,
touchCachedModified: (sessionId, modified, fileMtime = modified) => stmts.cacheTouchModified.run(modified, fileMtime, sessionId),
deleteCachedSession, deleteCachedFolder,
replaceSessionMetrics,
Expand Down
13 changes: 13 additions & 0 deletions docs/session-browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,19 @@ modified within **Session Max Age** (default 3 days); the others sit behind a
`+ N older` link. Running and pinned sessions are always shown. Both limits are
in [Global Settings](settings.md#application).

### Sessions started by a program

A session that a program started through the Claude Agent SDK — a headless
`claude -p` run, a review tool spawning one session per batch of files — is
left out of the list: its transcript carries no link to the session that
launched it, so it cannot be nested under it like a subagent. Turn off **Hide
SDK-launched Sessions** in [Global Settings](settings.md#application) to list
them. Their subagents are hidden with them, and a project holding only such
sessions is not listed. A session you have open, or left open when Switchboard
last closed, stays listed, and so does one you resumed and typed into.
Scheduled tasks are always listed. Hidden sessions still count in the activity
heatmap.

### Groups inside a project

- Sessions whose transcripts carry the same `slug` are grouped under one row,
Expand Down
1 change: 1 addition & 0 deletions docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ for one launch — see [Launching sessions](launching-sessions.md).
| Shell Profile | `shellProfile` | Auto (detect) | The shell for sessions and terminals; new sessions only — see [Launching sessions](launching-sessions.md#how-the-command-is-built) |
| Max Visible Sessions | `visibleSessionCount` | 10 (1–100) | Sessions shown per project before `+ N older` |
| Session Max Age (days) | `sessionMaxAgeDays` | 3 (1–365) | Older sessions go behind `+ N older`; older projects start collapsed |
| Hide SDK-launched Sessions | `hideSdkSessions` | on | Leaves out of the sidebar and grid the sessions a program started through the Claude Agent SDK; scheduled tasks stay — see [Session browser](session-browser.md#sessions-started-by-a-program) |
| IDE Emulation | `mcpEmulation` | off | Switchboard as Claude's IDE; new sessions only — see [IDE emulation](ide-emulation.md) |

### Keyboard Shortcuts
Expand Down
Loading
Loading