diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 0a9e6ee9f..fe0788ff9 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -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_`: the transcript record carries `cse_`, the CLI descriptor and the web URL carry `session_`, 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 @@ -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') diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b7b21658..ab949c24f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/db.js b/db.js index 7bdbbaced..3ce5ba1e7 100644 --- a/db.js +++ b/db.js @@ -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. @@ -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, @@ -429,7 +431,8 @@ 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 @@ -437,6 +440,8 @@ const stmts = { // 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 = ?'), @@ -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 ); } }); @@ -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); } @@ -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, diff --git a/docs/session-browser.md b/docs/session-browser.md index ac08281a4..f8e25894c 100644 --- a/docs/session-browser.md +++ b/docs/session-browser.md @@ -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, diff --git a/docs/settings.md b/docs/settings.md index a91c56f34..a6f0c59d3 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -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 diff --git a/main.js b/main.js index 98d463b42..bf8caf375 100644 --- a/main.js +++ b/main.js @@ -156,7 +156,7 @@ const { upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType, searchByType, isSearchIndexPopulated, searchFtsRecreated, getSetting, setSetting, deleteSetting, listSettingKeys, - isInitialScanComplete, setInitialScanComplete, + isInitialScanComplete, setInitialScanComplete, getCachedMissingEntrypoint, setCachedEntrypoints, getDailyMetrics, getDailyModelTokens, getModelUsage, getTotalCounts, closeDb, DB_PATH, @@ -486,11 +486,11 @@ sessionCache.init({ deleteCachedFolder, getCachedByFolder, upsertCachedSessions, deleteCachedSession, replaceSessionMetrics, touchCachedModified, deleteSearchFolder, deleteSearchSession, upsertSearchEntries, setFolderMeta, getFolderMeta, getAllFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName, - isInitialScanComplete, setInitialScanComplete, + isInitialScanComplete, setInitialScanComplete, getCachedMissingEntrypoint, setCachedEntrypoints, getCachedSession, }, }); const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem, - buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker, + buildProjectsFromCache, backfillEntrypoints, revealIfSdkSession, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker, scanFoldersViaWorker, setRemoteRoots, resolveFolderDir, isIndexingFinished } = sessionCache; const { resolveJsonlPath, readSubagentMeta } = require('./read-session-file'); @@ -1168,6 +1168,7 @@ ipcMain.handle('get-projects', async (_event, showArchived) => { // finding F1 -- see test/get-projects-cold-start-reconcile.test.js). reconcileCacheFromFilesystem(); } + backfillEntrypoints(); return annotateRemoteAttachable(applyAndPersistArchived(mergePlaceholderSessions(buildProjectsFromCache(showArchived)), showArchived, { getSetting, setSetting })); } catch (err) { @@ -2512,6 +2513,7 @@ function registerRemoteAttachSession(sessionId, { alias, projectPath, cwd, ptyPr _openedAt: Date.now(), }; activeSessions.set(sessionId, remoteSession); + revealIfSdkSession(sessionId); wireSessionPty(remoteSession, sessionId, ptyProcess); ptyProcess.onResizeAllowed?.(() => { if (activeSessions.get(sessionId) !== remoteSession || remoteSession.exited || remoteSession.remoteResizeAllowed) return; @@ -2881,6 +2883,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se host: null, kind: 'local-pty', }; activeSessions.set(sessionId, session); + if (!isPlainTerminal) revealIfSdkSession(sessionId); if (!isPlainTerminal && !panelOwnerId) activityReporter.sessionStarted({ sessionId, project: projectPath }); // see .ai/contexts/cli-session-state.md diff --git a/public/app.js b/public/app.js index 10ef9127f..6420ebab0 100644 --- a/public/app.js +++ b/public/app.js @@ -164,6 +164,8 @@ let sessionOpenedOutsideRestore = false; // see .ai/contexts/cli-session-state.md ("Live elsewhere") const skippedWorkingSetEntries = new Map(); let restoreSavedIndex = new Map(); +let restoreAwaitingConsent = []; +const restoreInFlight = new Map(); // Serialise concurrent read-modify-write calls so two async persist paths // (e.g. sidebar-resize and a working-set flush arriving in the same tick) @@ -185,10 +187,10 @@ function persistWorkingSet() { active: sessionId === activeSessionId, }); } - const skipped = [...skippedWorkingSetEntries.values()] + const held = [...skippedWorkingSetEntries.values(), ...pendingRestoreEntries()] .filter(({ item }) => !openSessions.has(item.sessionId)) .sort((a, b) => a.index - b.index); - for (const { item, index } of skipped) { + for (const { item, index } of held) { set.splice(Math.min(index, set.length), 0, { sessionId: item.sessionId, projectPath: item.projectPath, active: false }); } global.openWorkingSet = set; @@ -197,6 +199,16 @@ function persistWorkingSet() { return _persistChain; } +// see .ai/contexts/session-cache.md ("Working-set restore: retry until indexing is done") +function pendingRestoreEntries() { + const pending = [...restoreAwaitingConsent, ...restoreInFlight.values()]; + if (restorePlanner && !restorePlanner.isSettled()) pending.push(...restorePlanner.pending()); + return pending.map(item => ({ + item, + index: restoreSavedIndex.has(item.sessionId) ? restoreSavedIndex.get(item.sessionId) : Number.MAX_SAFE_INTEGER, + })); +} + function schedulePersistWorkingSet() { if (restoringWorkingSet) return; if (persistWorkingSetTimer) clearTimeout(persistWorkingSetTimer); @@ -208,23 +220,35 @@ function schedulePersistWorkingSet() { async function runRestore(list) { const pending = list.filter(item => sessionMap.has(item.sessionId) && !openSessions.has(item.sessionId)); - const liveById = await liveElsewhereMany(pending.map(item => item.sessionId), { api: window.api }); + for (const item of pending) restoreInFlight.set(item.sessionId, item); const skippedNow = []; - for (const [position, item] of list.entries()) { - const s = sessionMap.get(item.sessionId); - if (!s) continue; - if (openSessions.has(item.sessionId)) continue; - // Resume with the project's current "new session" defaults, exactly like a - // manual session relaunch — not options frozen from a previous launch. - const live = liveById[item.sessionId] || null; - const opened = await openSession(s, undefined, { automatic: true, live }); - if (opened === false) { - const index = restoreSavedIndex.has(item.sessionId) ? restoreSavedIndex.get(item.sessionId) : position; - skippedWorkingSetEntries.set(item.sessionId, { item, index }); - skippedNow.push({ session: s, live }); - continue; + try { + const liveById = await liveElsewhereMany(pending.map(item => item.sessionId), { api: window.api }); + for (const [position, item] of list.entries()) { + const s = sessionMap.get(item.sessionId); + if (!s || openSessions.has(item.sessionId)) { + restoreInFlight.delete(item.sessionId); + continue; + } + // Resume with the project's current "new session" defaults, exactly like a + // manual session relaunch — not options frozen from a previous launch. + const live = liveById[item.sessionId] || null; + let opened; + try { + opened = await openSession(s, undefined, { automatic: true, live }); + } finally { + restoreInFlight.delete(item.sessionId); + } + if (opened === false) { + const index = restoreSavedIndex.has(item.sessionId) ? restoreSavedIndex.get(item.sessionId) : position; + skippedWorkingSetEntries.set(item.sessionId, { item, index }); + skippedNow.push({ session: s, live }); + continue; + } + await new Promise(r => setTimeout(r, RESTORE_STAGGER_MS)); } - await new Promise(r => setTimeout(r, RESTORE_STAGGER_MS)); + } finally { + for (const item of pending) restoreInFlight.delete(item.sessionId); } if (skippedNow.length) showLiveElsewhereNotice(skippedNow); // Activate the entry marked active (or the last one) @@ -301,9 +325,11 @@ async function tickRestorePlanner() { `` + ``; document.body.appendChild(toast); + restoreAwaitingConsent = candidates; toast.querySelector('.restore-toast-restore').addEventListener('click', async () => { toast.remove(); + restoreAwaitingConsent = []; restoringWorkingSet = true; try { await runRestore(candidates); @@ -314,6 +340,7 @@ async function tickRestorePlanner() { }); toast.querySelector('.restore-toast-dismiss').addEventListener('click', () => { toast.remove(); + restoreAwaitingConsent = []; }); } diff --git a/public/restore-plan.js b/public/restore-plan.js index 390e7c7ec..d7149658a 100644 --- a/public/restore-plan.js +++ b/public/restore-plan.js @@ -76,7 +76,7 @@ function createRestorePlanner({ savedSet, maxTicks = 50, askOnce = false } = {}) return { action: 'wait', candidates: [], remaining: remaining.size, unavailable: [] }; } - return { tick, dismiss, isSettled: () => settled }; + return { tick, dismiss, isSettled: () => settled, pending: () => [...remaining].map(id => items.get(id)) }; } if (typeof module !== 'undefined' && module.exports) { diff --git a/public/setting-defaults.js b/public/setting-defaults.js index 04b1ccbc4..c3ebfd0c1 100644 --- a/public/setting-defaults.js +++ b/public/setting-defaults.js @@ -19,6 +19,7 @@ const SETTING_DEFAULTS = { addDirs: '', visibleSessionCount: 10, sessionMaxAgeDays: 3, + hideSdkSessions: true, sidebarWidth: 340, terminalTheme: 'switchboard', terminalRightClick: 'menu', diff --git a/public/settings-panel.js b/public/settings-panel.js index bce385ffa..6b546dd5a 100644 --- a/public/settings-panel.js +++ b/public/settings-panel.js @@ -74,6 +74,7 @@ const addDirsValue = fieldValue('addDirs'); const visCountValue = fieldValue('visibleSessionCount'); const maxAgeValue = fieldValue('sessionMaxAgeDays'); + const hideSdkValue = fieldValue('hideSdkSessions'); const themeValue = fieldValue('terminalTheme'); const rightClickValue = fieldValue('terminalRightClick'); const restoreStartupValue = fieldValue('restoreOnStartup'); @@ -294,6 +295,16 @@ +
+
+ Hide SDK-launched Sessions +
Hide sessions started by a program through the Claude Agent SDK (headless runs, review agents) rather than typed in a terminal. Scheduled tasks stay visible.
+
+
+ +
+
+
IDE Emulation @@ -578,6 +589,7 @@ settings.terminalTheme = settingsViewerBody.querySelector('#sv-terminal-theme').value || SETTING_DEFAULTS.terminalTheme; settings.terminalRightClick = settingsViewerBody.querySelector('#sv-right-click').value || SETTING_DEFAULTS.terminalRightClick; settings.restoreOnStartup = settingsViewerBody.querySelector('#sv-restore-startup').value || SETTING_DEFAULTS.restoreOnStartup; + settings.hideSdkSessions = settingsViewerBody.querySelector('#sv-hide-sdk').checked; settings.mcpEmulation = settingsViewerBody.querySelector('#sv-mcp-emulation').checked; settings.autoUpdate = settingsViewerBody.querySelector('#sv-auto-update').checked; settings.shellProfile = settingsViewerBody.querySelector('#sv-shell-profile').value || SETTING_DEFAULTS.shellProfile; @@ -628,7 +640,11 @@ if (settings.shortcuts && typeof window._applyShortcuts === 'function') { window._applyShortcuts(settings.shortcuts); } - if (typeof refreshSidebar === 'function') refreshSidebar(); + if (settings.hideSdkSessions !== hideSdkValue && typeof loadProjects === 'function') { + await loadProjects(); + } else if (typeof refreshSidebar === 'function') { + refreshSidebar(); + } // Re-arm the remote refresher so a host added here pulls immediately. try { await window.api.remoteHostsApply(); } catch {} } diff --git a/read-session-file.js b/read-session-file.js index 124572bee..4e09d1b2e 100644 --- a/read-session-file.js +++ b/read-session-file.js @@ -1,5 +1,6 @@ const path = require('path'); const fs = require('fs'); +const { StringDecoder } = require('string_decoder'); /** Subagent transcripts land under //subagents/agent-.jsonl. * We surface them as first-class rows with a synthetic sessionId so they're addressable @@ -179,6 +180,9 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { let agentId = null; let bridgeSessionId = null; let sidechainSeen = false; + // see .ai/contexts/session-cache.md ("SDK-launched sessions") + let entrypoint; + let typedInTerminal = false; // Real conversation time bounds. Resuming a session appends untimestamped // bookkeeping records (last-prompt, mode, ai-title, …) which bump the file's // mtime without any actual activity, so mtime can't be the displayed time. @@ -199,6 +203,8 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { if (entry.slug && !slug) slug = entry.slug; if (entry.agentId && !agentId) agentId = entry.agentId; if (entry.isSidechain) sidechainSeen = true; + if (entrypoint === undefined && entry.type === 'user') entrypoint = entrypointOf(entry); + if (isTerminalTurn(entry)) typedInTerminal = true; // Compaction mirror dedup key -- see .ai/contexts/session-cache.md if (entry.type === 'bridge-session' && typeof entry.bridgeSessionId === 'string' && entry.bridgeSessionId && !bridgeSessionId) { @@ -282,6 +288,7 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { fileMtime: stat.mtime.toISOString(), messageCount, textContent, slug, customTitle, aiTitle, bridgeSessionId, + entrypoint: typedInTerminal ? 'cli' : (entrypoint ?? ''), dailyMetrics, }; } catch { @@ -585,4 +592,80 @@ function readSessionDisplayHeader(filePath, opts = {}) { } } -module.exports = { readSessionFile, readSessionDisplayHeader, classifyUserText, subagentSessionId, resolveJsonlPath, readSubagentMeta, enumerateSessionFiles, extractDailyMetrics, isToolResultOnly, mergeBridgeGroups }; +// see .ai/contexts/session-cache.md ("SDK-launched sessions") +const SDK_FULL_SCAN_MAX_BYTES = 2 * 1024 * 1024; +const SDK_TAIL_SCAN_BYTES = 256 * 1024; + +function entrypointOf(entry) { + return typeof entry.entrypoint === 'string' ? entry.entrypoint : ''; +} + +function isTerminalTurn(entry) { + return entry.type === 'user' && entry.entrypoint === 'cli'; +} + +function isSdkEntrypoint(entrypoint) { + return typeof entrypoint === 'string' && entrypoint.startsWith('sdk-'); +} + +function readTextRange(fd, start, length) { + const buf = Buffer.alloc(length); + const n = fs.readSync(fd, buf, 0, length, start); + return buf.toString('utf8', 0, n); +} + +function hasTerminalTurn(text) { + for (const line of text.split('\n')) { + if (!line.includes('"cli"')) continue; + let entry; + try { entry = JSON.parse(line); } catch { continue; } + if (isTerminalTurn(entry)) return true; + } + return false; +} + +function firstUserEntrypointIn(lines) { + for (const line of lines) { + if (!line) continue; + let entry; + try { entry = JSON.parse(line); } catch { continue; } + if (entry.type === 'user') return entrypointOf(entry); + } + return undefined; +} + +function readFirstUserEntrypoint(fd, size) { + const decoder = new StringDecoder('utf8'); + let pending = ''; + for (let pos = 0; pos < size; pos += SDK_TAIL_SCAN_BYTES) { + const length = Math.min(SDK_TAIL_SCAN_BYTES, size - pos); + const buf = Buffer.alloc(length); + const n = fs.readSync(fd, buf, 0, length, pos); + const lines = (pending + decoder.write(buf.subarray(0, n))).split('\n'); + pending = lines.pop(); + const found = firstUserEntrypointIn(lines); + if (found !== undefined) return found; + } + return firstUserEntrypointIn([pending + decoder.end()]); +} + +function readSessionEntrypoint(filePath, { full = false } = {}) { + let fd; + try { + fd = fs.openSync(filePath, 'r'); + const size = fs.fstatSync(fd).size; + const first = readFirstUserEntrypoint(fd, size); + if (first === undefined) return null; + if (!isSdkEntrypoint(first)) return first; + if (full || size <= SDK_FULL_SCAN_MAX_BYTES) return hasTerminalTurn(readTextRange(fd, 0, size)) ? 'cli' : first; + const head = readTextRange(fd, 0, SDK_TAIL_SCAN_BYTES); + const tail = readTextRange(fd, size - SDK_TAIL_SCAN_BYTES, SDK_TAIL_SCAN_BYTES); + return hasTerminalTurn(head) || hasTerminalTurn(tail) ? 'cli' : first; + } catch { + return null; + } finally { + if (fd !== undefined) try { fs.closeSync(fd); } catch {} + } +} + +module.exports = { readSessionFile, readSessionDisplayHeader, readSessionEntrypoint, isSdkEntrypoint, classifyUserText, subagentSessionId, resolveJsonlPath, readSubagentMeta, enumerateSessionFiles, extractDailyMetrics, isToolResultOnly, mergeBridgeGroups }; diff --git a/session-cache.js b/session-cache.js index ec6a138d3..d8e3b2de8 100644 --- a/session-cache.js +++ b/session-cache.js @@ -4,10 +4,11 @@ const { Worker } = require('worker_threads'); const { getFolderIndexMtimeMs } = require('./folder-index-state'); const { setRemappedProjectReader } = require('./encode-project-path'); const { deriveProjectPath, storedProjectPathMatchesFolder } = require('./derive-project-path'); -const { readSessionFile, readSessionDisplayHeader, enumerateSessionFiles, resolveJsonlPath, mergeBridgeGroups } = require('./read-session-file'); +const { readSessionFile, readSessionDisplayHeader, readSessionEntrypoint, isSdkEntrypoint, enumerateSessionFiles, resolveJsonlPath, mergeBridgeGroups } = require('./read-session-file'); const { encodeProjectPath, decodeProjectFolderBestEffort } = require('./encode-project-path'); const { parseFolderKey, joinFolderKey } = require('./remote-hosts'); const { isPanelShellSession } = require('./panel-terminal-target'); +const { SETTING_DEFAULTS } = require('./public/setting-defaults'); /** * Session cache module. @@ -18,6 +19,8 @@ let deleteCachedFolder, getCachedByFolder, upsertCachedSessions, deleteCachedSes let deleteSearchFolder, deleteSearchSession, upsertSearchEntries; let setFolderMeta, getFolderMeta, getAllFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName; let isInitialScanComplete, setInitialScanComplete; +let getCachedMissingEntrypoint, setCachedEntrypoints, getCachedSession; +let entrypointBackfill = null; function init(ctx) { PROJECTS_DIR = ctx.PROJECTS_DIR; @@ -45,6 +48,10 @@ function init(ctx) { setName = ctx.db.setName; isInitialScanComplete = ctx.db.isInitialScanComplete; setInitialScanComplete = ctx.db.setInitialScanComplete; + getCachedMissingEntrypoint = ctx.db.getCachedMissingEntrypoint; + getCachedSession = ctx.db.getCachedSession; + setCachedEntrypoints = ctx.db.setCachedEntrypoints; + entrypointBackfill = null; } // alias -> that host's mirrored projects root; empty unless one is declared. @@ -266,6 +273,10 @@ function refreshFolder(folder, opts = {}) { subagentType: h.subagentType || cachedEntry.subagentType, description: h.description || cachedEntry.description, }; + // see .ai/contexts/session-cache.md ("SDK-launched sessions") + if (isSdkEntrypoint(cachedEntry.entrypoint)) { + merged.entrypoint = readSessionEntrypoint(filePath) ?? cachedEntry.entrypoint; + } sessionsToUpsert.push(merged); if (h.customTitle && h.customTitle !== cachedEntry.customTitle) { namesToSet.push({ id: merged.sessionId, name: h.customTitle }); @@ -427,6 +438,63 @@ function isProjectHidden(hiddenProjects, alias, projectPath) { return alias !== null && hiddenProjects.has(joinFolderKey(alias, projectPath)); } +const ENTRYPOINT_BACKFILL_BATCH = 200; + +// see .ai/contexts/session-cache.md ("SDK-launched sessions") +function backfillEntrypoints() { + if (entrypointBackfill) return entrypointBackfill; + entrypointBackfill = new Promise((resolve) => { + const rows = getCachedMissingEntrypoint ? getCachedMissingEntrypoint() : []; + let next = 0; + let sdkFound = false; + const step = () => { + const pairs = []; + const end = Math.min(next + ENTRYPOINT_BACKFILL_BATCH, rows.length); + for (; next < end; next++) { + const row = rows[next]; + const dir = resolveFolderDir(row.folder); + if (!dir) continue; + const entrypoint = readSessionEntrypoint(resolveJsonlPath(dir, { ...row, folder: '.' }), { full: true }); + if (entrypoint === null) continue; + if (isSdkEntrypoint(entrypoint)) sdkFound = true; + pairs.push({ sessionId: row.sessionId, entrypoint }); + } + if (pairs.length) setCachedEntrypoints(pairs); + if (next < rows.length) { + setImmediate(step); + return; + } + if (sdkFound) notifyRendererProjectsChanged(); + resolve(); + }; + step(); + }); + return entrypointBackfill; +} + +// see .ai/contexts/session-cache.md ("SDK-launched sessions") +function revealIfSdkSession(sessionId) { + const row = getCachedSession ? getCachedSession(sessionId) : null; + if (row && isSdkEntrypoint(row.entrypoint)) notifyRendererProjectsChanged(); +} + +// see .ai/contexts/session-cache.md ("SDK-launched sessions") +function hiddenSdkSessionIds(cachedRows, global, mergedChildrenByParent) { + if (!(global.hideSdkSessions ?? SETTING_DEFAULTS.hideSdkSessions)) return new Set(); + const kept = new Set((global.openWorkingSet || []).map(item => item && item.sessionId)); + for (const [sessionId, session] of activeSessions || []) { + if (!session.exited) kept.add(sessionId); + } + const hidden = new Set(); + for (const row of cachedRows) { + if (!isSdkEntrypoint(row.entrypoint) || kept.has(row.sessionId)) continue; + const children = mergedChildrenByParent.get(row.sessionId) || []; + if (children.some(child => !isSdkEntrypoint(child.entrypoint))) continue; + hidden.add(row.sessionId); + } + return hidden; +} + /** Build projects response from cached data */ function buildProjectsFromCache(showArchived) { const metaMap = getAllMeta(); @@ -454,6 +522,9 @@ function buildProjectsFromCache(showArchived) { mergedChildrenByParent.get(row.mergedIntoSessionId).push(row); } + const hiddenSdkIds = hiddenSdkSessionIds(cachedRows, global, mergedChildrenByParent); + const sdkOnlyProjectKeys = new Set(); + const cachedIds = new Set(cachedRows.map(r => r.sessionId)); const isArchivedParent = (id) => cachedIds.has(id) && !!metaMap.get(id)?.archived; @@ -465,6 +536,10 @@ function buildProjectsFromCache(showArchived) { if (row.mergedIntoSessionId) continue; // rolled up into its parent below, not its own entry if (!row.projectPath) continue; const { alias } = parseFolderKey(row.folder); + if (hiddenSdkIds.has(row.sessionId) || hiddenSdkIds.has(row.parentSessionId)) { + sdkOnlyProjectKeys.add(groupKey(alias, row.projectPath)); + continue; + } if (isProjectHidden(hiddenProjects, alias, row.projectPath)) continue; const meta = metaMap.get(row.sessionId); const children = mergedChildrenByParent.get(row.sessionId) || []; @@ -557,7 +632,7 @@ function buildProjectsFromCache(showArchived) { if (!projectPath) continue; if (isProjectHidden(hiddenProjects, alias, projectPath)) continue; const key = groupKey(alias, projectPath); - if (projectMap.has(key)) continue; + if (projectMap.has(key) || sdkOnlyProjectKeys.has(key)) continue; // For a placeholder the on-disk name IS the ground truth — re-encoding // the lossy decode could diverge from it (>200-char hashed names). const bare = placeholder ? d.name : encodeProjectPath(projectPath); @@ -966,6 +1041,8 @@ module.exports = { refreshFolder, reconcileCacheFromFilesystem, buildProjectsFromCache, + backfillEntrypoints, + revealIfSdkSession, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker, diff --git a/test/get-projects-cold-start-reconcile.test.js b/test/get-projects-cold-start-reconcile.test.js index d44baf4ed..f688d9740 100644 --- a/test/get-projects-cold-start-reconcile.test.js +++ b/test/get-projects-cold-start-reconcile.test.js @@ -47,6 +47,7 @@ function makeHandler(mocks) { 'populateCacheViaWorker', 'reconcileCacheFromFilesystem', 'buildProjectsFromCache', 'mergePlaceholderSessions', 'annotateRemoteAttachable', 'applyAndPersistArchived', 'getSetting', 'setSetting', 'showArchived', + 'backfillEntrypoints', body ); // annotateRemoteAttachable (remote-attach join, issue #221), @@ -61,7 +62,8 @@ function makeHandler(mocks) { mocks.isCachePopulated, mocks.isSearchIndexPopulated, mocks.isInitialScanComplete, mocks.populateCacheViaWorker, mocks.reconcileCacheFromFilesystem, mocks.buildProjectsFromCache, mergePlaceholderSessions, - annotateRemoteAttachable, applyAndPersistArchived, () => null, () => {}, false + annotateRemoteAttachable, applyAndPersistArchived, () => null, () => {}, false, + mocks.backfillEntrypoints || (() => {}) ); } @@ -132,3 +134,21 @@ test('get-projects on a partial cache (interrupted first scan: rows present, mar assert.deepEqual(calls, ['populate', 'build'], 'the interrupted scan must be resumed fire-and-forget, then serve whatever is cached'); }); + +// see .ai/contexts/session-cache.md ("SDK-launched sessions") +test('get-projects starts the entrypoint backfill on a cold and on a warm cache, before building the list', () => { + for (const warm of [false, true]) { + const calls = []; + const handler = makeHandler({ + isCachePopulated: () => warm, + isSearchIndexPopulated: () => warm, + isInitialScanComplete: () => warm, + populateCacheViaWorker: () => {}, + reconcileCacheFromFilesystem: () => {}, + backfillEntrypoints: () => { calls.push('backfill'); }, + buildProjectsFromCache: () => { calls.push('build'); return []; }, + }); + handler(); + assert.deepEqual(calls, ['backfill', 'build'], warm ? 'warm cache' : 'cold cache'); + } +}); diff --git a/test/hide-sdk-sessions.test.js b/test/hide-sdk-sessions.test.js new file mode 100644 index 000000000..419498247 --- /dev/null +++ b/test/hide-sdk-sessions.test.js @@ -0,0 +1,387 @@ +// see .ai/contexts/session-cache.md ("SDK-launched sessions") + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { readSessionFile, readSessionEntrypoint } = require('../read-session-file'); +const sessionCache = require('../session-cache'); +const { encodeProjectPath } = require('../encode-project-path'); + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-sdk-')); +} + +function cleanup(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +function write(dir, sessionId, entries) { + const filePath = path.join(dir, `${sessionId}.jsonl`); + fs.writeFileSync(filePath, entries.map(e => JSON.stringify(e)).join('\n') + '\n', 'utf8'); + return filePath; +} + +function user(text, extra = {}) { + return { type: 'user', timestamp: '2026-10-07T11:42:12.808Z', message: { role: 'user', content: text }, ...extra }; +} + +function assistant(extra = {}) { + return { type: 'assistant', timestamp: '2026-10-07T11:42:20.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] }, ...extra }; +} + +test('readSessionFile records the entrypoint of the first user turn', () => { + const tmp = mkTmp(); + try { + const sdk = write(tmp, 'sdk', [ + { type: 'queue-operation', operation: 'enqueue', content: 'Review this change' }, + user('Review this change', { entrypoint: 'sdk-py', promptSource: 'sdk' }), + assistant({ entrypoint: 'sdk-py' }), + ]); + assert.equal(readSessionFile(sdk, 'f', '/p').entrypoint, 'sdk-py'); + + const interactive = write(tmp, 'cli', [user('hello', { entrypoint: 'cli' }), assistant({ entrypoint: 'cli' })]); + assert.equal(readSessionFile(interactive, 'f', '/p').entrypoint, 'cli'); + } finally { + cleanup(tmp); + } +}); + +test('a scheduled run keeps no entrypoint: its first user turn is pre-seeded by Switchboard', () => { + const tmp = mkTmp(); + try { + const scheduled = write(tmp, 'sched', [ + user('Scheduled Task: summarize'), + user('continue', { entrypoint: 'sdk-cli' }), + assistant({ entrypoint: 'sdk-cli' }), + ]); + assert.equal(readSessionFile(scheduled, 'f', '/p').entrypoint, ''); + } finally { + cleanup(tmp); + } +}); + +test('an SDK session later typed into from a terminal counts as interactive', () => { + const tmp = mkTmp(); + try { + const resumed = write(tmp, 'resumed', [ + user('Invoque le skill workitem-develop', { entrypoint: 'sdk-ts' }), + assistant({ entrypoint: 'sdk-ts' }), + user('and now fix the test', { entrypoint: 'cli' }), + ]); + assert.equal(readSessionFile(resumed, 'f', '/p').entrypoint, 'cli'); + } finally { + cleanup(tmp); + } +}); + +test('refreshFolder notices a terminal turn appended to a cached SDK session', () => { + const projectsDir = mkTmp(); + try { + const projectPath = projectsDir; + const folder = encodeProjectPath(projectPath); + const folderPath = path.join(projectsDir, folder); + fs.mkdirSync(folderPath); + const filePath = write(folderPath, 'resumed', [ + user('Invoque le skill workitem-develop', { entrypoint: 'sdk-ts', cwd: projectPath }), + assistant({ entrypoint: 'sdk-ts', cwd: projectPath }), + user('and now fix the test', { entrypoint: 'cli', cwd: projectPath }), + ]); + const store = new Map([['resumed', { + ...row('resumed', folder, projectPath, 'sdk-ts'), + fileMtime: '2000-01-01T00:00:00.000Z', + }]]); + sessionCache.init({ + PROJECTS_DIR: projectsDir, + activeSessions: new Map(), + getMainWindow: () => null, + log: { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} }, + db: { + deleteCachedFolder: () => {}, + deleteSearchFolder: () => {}, + getCachedByFolder: (f) => Array.from(store.values()).filter(r => r.folder === f), + getAllCached: () => Array.from(store.values()), + upsertCachedSessions: (rows) => { for (const r of rows) store.set(r.sessionId, { ...store.get(r.sessionId), ...r }); }, + touchCachedModified: () => {}, + deleteCachedSession: () => {}, + replaceSessionMetrics: () => {}, + deleteSearchSession: () => {}, + upsertSearchEntries: () => {}, + setFolderMeta: () => {}, + getAllFolderMeta: () => new Map(), + getAllMeta: () => new Map(), + getSetting: () => ({}), + getMeta: () => null, + setName: () => {}, + }, + }); + sessionCache.setRemoteRoots(new Map()); + sessionCache.refreshFolder(folder, { files: new Set([path.basename(filePath)]) }); + assert.equal(store.get('resumed').entrypoint, 'cli'); + } finally { + cleanup(projectsDir); + } +}); + +function makeFakeDb({ cachedRows, global }) { + return { + getAllMeta: () => new Map(), + getAllCached: () => cachedRows, + getSetting: (key) => (key === 'global' ? global : {}), + }; +} + +function row(sessionId, folder, projectPath, entrypoint) { + return { + sessionId, folder, projectPath, summary: sessionId, firstPrompt: sessionId, + modified: '2026-10-07T10:00:00.000Z', created: '2026-10-07T10:00:00.000Z', + messageCount: 1, parentSessionId: null, agentId: null, subagentType: null, + description: null, slug: null, aiTitle: null, entrypoint, + }; +} + +function visibleIds(global) { + const projectsDir = mkTmp(); + try { + const projectPath = '/srv/runner'; + const folder = encodeProjectPath(projectPath); + const cachedRows = [ + row('interactive', folder, projectPath, 'cli'), + row('scheduled', folder, projectPath, null), + row('sdk-cli-run', folder, projectPath, 'sdk-cli'), + row('sdk-py-run', folder, projectPath, 'sdk-py'), + row('sdk-ts-run', folder, projectPath, 'sdk-ts'), + { ...row('sub:sdk-py-run:a1', folder, projectPath, null), parentSessionId: 'sdk-py-run', agentId: 'a1' }, + ]; + sessionCache.init({ + PROJECTS_DIR: projectsDir, + activeSessions: new Map(), + getMainWindow: () => null, + log: { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} }, + db: makeFakeDb({ cachedRows, global }), + }); + sessionCache.setRemoteRoots(new Map()); + return sessionCache.buildProjectsFromCache(true) + .flatMap(p => p.sessions.map(s => s.sessionId)) + .sort(); + } finally { + cleanup(projectsDir); + } +} + +test('SDK-launched sessions and their subagents are hidden by default', () => { + assert.deepEqual(visibleIds({}), ['interactive', 'scheduled']); +}); + +test('turning hideSdkSessions off shows them again', () => { + assert.deepEqual(visibleIds({ hideSdkSessions: false }), + ['interactive', 'scheduled', 'sdk-cli-run', 'sdk-py-run', 'sdk-ts-run', 'sub:sdk-py-run:a1']); +}); + +function buildWith({ cachedRows, global = {}, activeSessions = new Map(), folders = [] }) { + const projectsDir = mkTmp(); + try { + for (const f of folders) fs.mkdirSync(path.join(projectsDir, f)); + const folderMeta = new Map(folders.map(f => [f, { folder: f, projectPath: cachedRows.find(r => r.folder === f).projectPath }])); + sessionCache.init({ + PROJECTS_DIR: projectsDir, + activeSessions, + getMainWindow: () => null, + log: { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} }, + db: { ...makeFakeDb({ cachedRows, global }), getAllFolderMeta: () => folderMeta, setFolderMeta: () => {} }, + }); + sessionCache.setRemoteRoots(new Map()); + return sessionCache.buildProjectsFromCache(true); + } finally { + cleanup(projectsDir); + } +} + +const ids = (projects) => projects.flatMap(p => p.sessions.map(s => s.sessionId)).sort(); + +test('an SDK session open in a terminal or in the saved working set stays listed', () => { + const folder = encodeProjectPath('/srv/runner'); + const cachedRows = [ + row('open-now', folder, '/srv/runner', 'sdk-py'), + row('saved', folder, '/srv/runner', 'sdk-py'), + row('closed', folder, '/srv/runner', 'sdk-py'), + row('exited', folder, '/srv/runner', 'sdk-py'), + ]; + const activeSessions = new Map([['open-now', { exited: false }], ['exited', { exited: true }]]); + const projects = buildWith({ cachedRows, activeSessions, global: { openWorkingSet: [{ sessionId: 'saved' }] } }); + assert.deepEqual(ids(projects), ['open-now', 'saved']); +}); + +test('opening a hidden SDK session asks the renderer to reload the list, which then holds it', () => { + const folder = encodeProjectPath('/srv/runner'); + const cachedRows = [row('sdk', folder, '/srv/runner', 'sdk-py'), row('typed', folder, '/srv/runner', 'cli')]; + const activeSessions = new Map(); + const sent = []; + const projectsDir = mkTmp(); + try { + sessionCache.init({ + PROJECTS_DIR: projectsDir, + activeSessions, + getMainWindow: () => ({ isDestroyed: () => false, webContents: { send: (channel) => sent.push(channel) } }), + log: { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} }, + db: { ...makeFakeDb({ cachedRows, global: {} }), getCachedSession: (id) => cachedRows.find(r => r.sessionId === id) || null }, + }); + sessionCache.setRemoteRoots(new Map()); + assert.deepEqual(ids(sessionCache.buildProjectsFromCache(true)), ['typed']); + + activeSessions.set('typed', { exited: false }); + sessionCache.revealIfSdkSession('typed'); + assert.deepEqual(sent, [], 'an interactive session is already listed'); + + activeSessions.set('sdk', { exited: false }); + sessionCache.revealIfSdkSession('sdk'); + assert.deepEqual(sent, ['projects-changed']); + assert.deepEqual(ids(sessionCache.buildProjectsFromCache(true)), ['sdk', 'typed']); + } finally { + cleanup(projectsDir); + } +}); + +test('an SDK session continued from a terminal in a compaction mirror is listed', () => { + const folder = encodeProjectPath('/srv/runner'); + const cachedRows = [ + row('parent', folder, '/srv/runner', 'sdk-ts'), + { ...row('mirror', folder, '/srv/runner', 'cli'), mergedIntoSessionId: 'parent' }, + ]; + assert.deepEqual(ids(buildWith({ cachedRows })), ['parent']); +}); + +test('a project holding only SDK sessions gets no empty header', () => { + const folder = encodeProjectPath('/srv/only-sdk'); + const cachedRows = [row('sdk', folder, '/srv/only-sdk', 'sdk-cli')]; + assert.deepEqual(buildWith({ cachedRows, folders: [folder] }).map(p => p.projectPath), []); +}); + +test('readSessionEntrypoint reads the head, and the tail of a large SDK transcript', () => { + const tmp = mkTmp(); + try { + assert.equal(readSessionEntrypoint(write(tmp, 'cli', [user('hi', { entrypoint: 'cli' }), assistant()])), 'cli'); + assert.equal(readSessionEntrypoint(write(tmp, 'sched', [user('Scheduled Task: x'), user('go', { entrypoint: 'sdk-cli' })])), ''); + assert.equal(readSessionEntrypoint(write(tmp, 'odd', [user('hi', { entrypoint: 42 })])), ''); + assert.equal(readSessionEntrypoint(write(tmp, 'pure', [user('review', { entrypoint: 'sdk-py' }), assistant({ entrypoint: 'sdk-py' })])), 'sdk-py'); + assert.equal(readSessionEntrypoint(write(tmp, 'queued', [{ type: 'queue-operation', operation: 'enqueue' }])), null); + const filler = assistant({ entrypoint: 'sdk-py', pad: 'x'.repeat(4096) }); + const big = write(tmp, 'big', [ + user('review', { entrypoint: 'sdk-py' }), + ...Array(700).fill(filler), + user('typed later', { entrypoint: 'cli' }), + ]); + assert.ok(fs.statSync(big).size > 2 * 1024 * 1024); + assert.equal(readSessionEntrypoint(big), 'cli'); + const earlyTurn = write(tmp, 'early', [ + user('review', { entrypoint: 'sdk-py' }), + user('typed early', { entrypoint: 'cli' }), + ...Array(700).fill(filler), + ]); + assert.equal(readSessionEntrypoint(earlyTurn), 'cli'); + const middleTurn = write(tmp, 'middle', [ + user('review', { entrypoint: 'sdk-py' }), + ...Array(350).fill(filler), + user('typed in the middle', { entrypoint: 'cli' }), + ...Array(350).fill(filler), + ]); + assert.equal(readSessionEntrypoint(middleTurn), 'sdk-py', 'the live path samples the head and the tail only'); + assert.equal(readSessionEntrypoint(middleTurn, { full: true }), 'cli'); + const longPrompt = write(tmp, 'long-prompt', [ + { type: 'queue-operation', operation: 'enqueue', content: 'é'.repeat(300 * 1024) }, + { type: 'queue-operation', operation: 'dequeue' }, + user('review', { entrypoint: 'sdk-cli' }), + ]); + assert.equal(readSessionEntrypoint(longPrompt), 'sdk-cli'); + } finally { + cleanup(tmp); + } +}); + +test('backfillEntrypoints fills the rows cached before the column existed', async () => { + const projectsDir = mkTmp(); + try { + const folder = encodeProjectPath('/srv/runner'); + const folderPath = path.join(projectsDir, folder); + fs.mkdirSync(folderPath); + write(folderPath, 'old-sdk', [user('review', { entrypoint: 'sdk-py' }), assistant()]); + write(folderPath, 'old-cli', [user('hello', { entrypoint: 'cli' }), assistant()]); + const written = []; + sessionCache.init({ + PROJECTS_DIR: projectsDir, + activeSessions: new Map(), + getMainWindow: () => null, + log: { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} }, + db: { + getCachedMissingEntrypoint: () => [{ sessionId: 'old-sdk', folder }, { sessionId: 'old-cli', folder }], + setCachedEntrypoints: (pairs) => written.push(...pairs), + }, + }); + sessionCache.setRemoteRoots(new Map()); + await sessionCache.backfillEntrypoints(); + assert.deepEqual(written, [ + { sessionId: 'old-sdk', entrypoint: 'sdk-py' }, + { sessionId: 'old-cli', entrypoint: 'cli' }, + ]); + } finally { + cleanup(projectsDir); + } +}); + +test('adding the entrypoint column keeps the cached sessions', () => { + const { spawnSync } = require('child_process'); + const electronBin = require('electron'); + const appDir = path.join(__dirname, '..'); + const dir = mkTmp(); + const run = (code) => spawnSync(electronBin, ['-e', code], { + cwd: appDir, + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', SWITCHBOARD_DATA_DIR: dir }, + encoding: 'utf8', + }); + const loadDb = `require(${JSON.stringify(path.join(appDir, 'db.js'))})`; + try { + assert.equal(run(loadDb).status, 0); + const seed = run(` + const Database = require('better-sqlite3'); + const db = new Database(require('path').join(process.env.SWITCHBOARD_DATA_DIR, 'switchboard.db')); + db.exec('ALTER TABLE session_cache DROP COLUMN entrypoint'); + db.prepare('INSERT INTO session_cache (sessionId, folder, projectPath, summary, modified) VALUES (?, ?, ?, ?, ?)') + .run('kept', 'f1', '/tmp/p1', 'hello', '2026-10-07T10:00:00.000Z'); + `); + assert.equal(seed.status, 0, seed.stderr); + assert.equal(run(loadDb).status, 0); + const r = run(` + const Database = require('better-sqlite3'); + const db = new Database(require('path').join(process.env.SWITCHBOARD_DATA_DIR, 'switchboard.db'), { readonly: true }); + console.log(JSON.stringify(db.prepare('SELECT sessionId, entrypoint FROM session_cache').all())); + `); + assert.equal(r.status, 0, r.stderr); + assert.deepEqual(JSON.parse(r.stdout.trim().split('\n').pop()), [{ sessionId: 'kept', entrypoint: null }]); + } finally { + cleanup(dir); + } +}); + +test('both paths that make a session active call revealIfSdkSession right after registering it', () => { + const vm = require('node:vm'); + const { extractFunction } = require('./app-source'); + const main = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8').replace(/\r\n/g, '\n'); + + const activeSessions = new Map(); + const revealed = []; + const context = vm.createContext({ + activeSessions, + mainWindow: { isDestroyed: () => false, webContents: { send() {} } }, + wireSessionPty() {}, + revealIfSdkSession: (id) => revealed.push({ id, active: activeSessions.has(id) }), + }); + vm.runInContext(extractFunction(main, 'registerRemoteAttachSession'), context); + vm.runInContext(`registerRemoteAttachSession('remote-sdk', { alias: 'h', projectPath: '/p', cwd: '/p', ptyProcess: {}, remoteResizeAllowed: false })`, context); + assert.deepEqual(revealed, [{ id: 'remote-sdk', active: true }]); + + const start = main.indexOf("ipcMain.handle('open-terminal'"); + const openTerminal = main.slice(start, main.indexOf('\n});', start)); + assert.match(openTerminal, /activeSessions\.set\(sessionId, session\);\n\s*if \(!isPlainTerminal\) revealIfSdkSession\(sessionId\);/); +}); diff --git a/test/restore-live-elsewhere.test.js b/test/restore-live-elsewhere.test.js index 5ffeb59c8..77fb7057c 100644 --- a/test/restore-live-elsewhere.test.js +++ b/test/restore-live-elsewhere.test.js @@ -66,6 +66,7 @@ function setup({ savedSet, liveIds, batchFails = false }) { var sessionMap = new Map(); var activeSessionId = null; var restoringWorkingSet = false; + var restorePlanner = null; var sessionOpenedOutsideRestore = false; var _persistChain = Promise.resolve(); var RESTORE_STAGGER_MS = 0; @@ -87,9 +88,9 @@ function setup({ savedSet, liveIds, batchFails = false }) { function schedulePersistWorkingSet() {} function pollActiveSessions() {} `, ctx); - vm.runInContext(topLevelDeclarations(APP_SRC, ['LIVE_ELSEWHERE_NOTICE_MS', 'skippedWorkingSetEntries', 'restoreSavedIndex']) + vm.runInContext(topLevelDeclarations(APP_SRC, ['LIVE_ELSEWHERE_NOTICE_MS', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight']) .replace(/^const /gm, 'var ').replace(/^let /gm, 'var '), ctx); - for (const name of ['persistWorkingSet', 'runRestore', 'openSession']) { + for (const name of ['persistWorkingSet', 'pendingRestoreEntries', 'runRestore', 'openSession']) { vm.runInContext(functionSource(APP_SRC, name), ctx); } if (APP_SRC.includes('function showLiveElsewhereNotice(')) { diff --git a/test/restore-pending-persist.test.js b/test/restore-pending-persist.test.js new file mode 100644 index 000000000..a62857c96 --- /dev/null +++ b/test/restore-pending-persist.test.js @@ -0,0 +1,131 @@ +// see .ai/contexts/session-cache.md ("Working-set restore: retry until indexing is done") + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { JSDOM } = require('jsdom'); +const { loadAppFunctions } = require('./app-source'); + +const PLAN_SRC = fs.readFileSync(path.join(__dirname, '..', 'public', 'restore-plan.js'), 'utf8'); + +const SAVED = [ + { sessionId: 'opened', projectPath: '/p', active: true }, + { sessionId: 'sdk-unindexed', projectPath: '/p', active: false }, +]; + +function setup() { + const dom = new JSDOM('', { runScripts: 'outside-only' }); + const ctx = dom.getInternalVMContext(); + const settings = { global: { openWorkingSet: SAVED } }; + dom.window.api = { + getSetting: async (key) => JSON.parse(JSON.stringify(settings[key] || null)), + setSetting: async (key, value) => { settings[key] = JSON.parse(JSON.stringify(value)); }, + }; + vm.runInContext(PLAN_SRC, ctx); + vm.runInContext(` + var openSessions = new Map([['opened', { session: { projectPath: '/p' }, closed: false }]]); + var sessionMap = new Map(); + var activeSessionId = 'opened'; + var _persistChain = Promise.resolve(); + var RESTORE_STAGGER_MS = 0; + var restorePlanner = createRestorePlanner({ savedSet: ${JSON.stringify(SAVED)} }); + var liveCheckRelease = []; + function liveElsewhereMany() { return new Promise(resolve => liveCheckRelease.push(() => resolve({}))); } + async function openSession(s) { + openSessions.set(s.sessionId, { session: { projectPath: s.projectPath }, closed: false }); + return true; + } + function showLiveElsewhereNotice() {} + function showSession() {} + `, ctx); + const fns = loadAppFunctions(ctx, { + functions: ['persistWorkingSet', 'pendingRestoreEntries', 'runRestore'], + declarations: ['skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight'], + }); + vm.runInContext(`restoreSavedIndex = new Map(${JSON.stringify(SAVED.map((item, i) => [item.sessionId, i]))});`, ctx); + const stored = () => settings.global.openWorkingSet.map(i => i.sessionId); + const releaseLiveChecks = () => vm.runInContext('liveCheckRelease.splice(0).forEach(release => release());', ctx); + return { dom, ctx, stored, releaseLiveChecks, ...fns }; +} + +function dispatchToRestore(h) { + vm.runInContext(`sessionMap.set('opened', {}); sessionMap.set('sdk-unindexed', { sessionId: 'sdk-unindexed', projectPath: '/p' });`, h.ctx); + const plan = vm.runInContext('restorePlanner.tick({ sessionMap, openSessions, indexingDone: false })', h.ctx); + assert.equal(plan.action, 'restore'); + return plan.candidates; +} + +test('a persist during a cold restore keeps the saved entries not indexed yet', async () => { + const h = setup(); + vm.runInContext(`restorePlanner.tick({ sessionMap: new Map([['opened', {}]]), openSessions, indexingDone: false });`, h.ctx); + await h.persistWorkingSet(); + assert.deepEqual(h.stored(), ['opened', 'sdk-unindexed']); + await h.persistWorkingSet(); + assert.deepEqual(h.stored(), ['opened', 'sdk-unindexed'], 'a second persist keeps it too'); + h.dom.window.close(); +}); + +test('entries offered by the restore toast survive a persist until answered', async () => { + const h = setup(); + vm.runInContext(`restorePlanner.dismiss(); restoreAwaitingConsent = [${JSON.stringify(SAVED[1])}];`, h.ctx); + await h.persistWorkingSet(); + assert.deepEqual(h.stored(), ['opened', 'sdk-unindexed']); + vm.runInContext('restoreAwaitingConsent = [];', h.ctx); + await h.persistWorkingSet(); + assert.deepEqual(h.stored(), ['opened'], 'once dismissed, the entry is dropped as before'); + h.dom.window.close(); +}); + +test('a persist while the restore checks the candidates keeps them, and they are opened after', async () => { + const h = setup(); + const restoring = h.runRestore(dispatchToRestore(h)); + await h.persistWorkingSet(); + assert.deepEqual(h.stored(), ['opened', 'sdk-unindexed'], 'dispatched to runRestore, not yet opened or skipped'); + h.releaseLiveChecks(); + await restoring; + await h.persistWorkingSet(); + assert.deepEqual(h.stored(), ['opened', 'sdk-unindexed']); + assert.ok(vm.runInContext(`openSessions.has('sdk-unindexed') && restoreInFlight.size === 0`, h.ctx)); + h.dom.window.close(); +}); + +test('a candidate opened by hand during the check, then closed, is not kept in the working set', async () => { + const h = setup(); + const restoring = h.runRestore(dispatchToRestore(h)); + vm.runInContext(`openSessions.set('sdk-unindexed', { session: { projectPath: '/p' }, closed: false });`, h.ctx); + h.releaseLiveChecks(); + await restoring; + vm.runInContext(`openSessions.delete('sdk-unindexed');`, h.ctx); + await h.persistWorkingSet(); + assert.deepEqual(h.stored(), ['opened']); + assert.equal(vm.runInContext('restoreInFlight.size', h.ctx), 0); + h.dom.window.close(); +}); + +test('a restore that fails part-way leaves nothing in flight', async () => { + const h = setup(); + vm.runInContext(`openSession = async () => { throw new Error('pty spawn failed'); };`, h.ctx); + const restoring = h.runRestore(dispatchToRestore(h)); + h.releaseLiveChecks(); + await assert.rejects(restoring, /pty spawn failed/); + assert.equal(vm.runInContext('restoreInFlight.size', h.ctx), 0); + h.dom.window.close(); +}); + +test('two overlapping restores keep the candidate stored until one of them opens it', async () => { + const h = setup(); + const candidates = dispatchToRestore(h); + const first = h.runRestore(candidates); + const second = h.runRestore(candidates); + await h.persistWorkingSet(); + assert.deepEqual(h.stored(), ['opened', 'sdk-unindexed']); + h.releaseLiveChecks(); + await Promise.all([first, second]); + await h.persistWorkingSet(); + assert.deepEqual(h.stored(), ['opened', 'sdk-unindexed']); + h.dom.window.close(); +}); diff --git a/test/terminal-refresh-ipc.test.js b/test/terminal-refresh-ipc.test.js index 7311f1ce3..a55198271 100644 --- a/test/terminal-refresh-ipc.test.js +++ b/test/terminal-refresh-ipc.test.js @@ -55,6 +55,7 @@ async function promotionFixture(t, { initialCount = 1 } = {}) { listeners.get(channel)?.(null, ...args); } } }, wireSessionPty() {}, + revealIfSdkSession() {}, }); const main = fs.readFileSync(path.join(__dirname, '../main.js'), 'utf8'); vm.runInContext(extractFunction(main, 'registerRemoteAttachSession'), context); @@ -145,6 +146,7 @@ test('remote open, reattach and launch preserve the adapter sizing capability in remoteLaunchAdapter: {}, handleLaunchRequest: async () => ({ ok: true, descriptor, attachResult }), wireSessionPty() {}, + revealIfSdkSession() {}, getMcpState: () => null, }); vm.runInContext(extractFunction(main, 'registerRemoteAttachSession'), context); diff --git a/test/terminal-refresh.test.js b/test/terminal-refresh.test.js index 8458dfcfa..ffef83cb5 100644 --- a/test/terminal-refresh.test.js +++ b/test/terminal-refresh.test.js @@ -190,7 +190,7 @@ test('restore refreshes a new remote terminal once even when its saved id is alr ctx.window.activeSessionId = 'remote'; const guardPath = path.join(__dirname, '../public/resume-guard.js'); vm.runInContext(fs.readFileSync(guardPath, 'utf8'), ctx.context, { filename: guardPath }); - const { runRestore } = loadAppFunctions(ctx.context, { functions: ['runRestore'] }); + const { runRestore } = loadAppFunctions(ctx.context, { functions: ['runRestore'], declarations: ['restoreInFlight'] }); await runRestore([{ sessionId: 'remote', active: true }]); await flush(ctx); assert.deepEqual(ctx.refreshes, [{ id: 'remote', cols: 120, rows: 40 }]);