From 44504a10901cca335d9532c89471793763a782df Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 7 Oct 2026 14:24:46 +0200 Subject: [PATCH 1/9] (sidebar): hide sessions launched through the Agent SDK Programs driving Claude through the Agent SDK (headless claude -p runs, review tools spawning one session per file batch) write top-level transcripts with no link to the session that started them, so they flooded project groups as unrelated sessions. Keep the entrypoint of the first user turn in session_cache and leave sdk-* rows out of the project list while the new hideSdkSessions setting (default on) is set. Scheduled runs stay visible: their first turn is pre-seeded without an entrypoint. Claude-Session: https://claude.ai/code/session_0121XfXy4y1Fwt3wJsD4v82q --- .ai/contexts/session-cache.md | 4 +- db.js | 12 ++-- docs/session-browser.md | 10 +++ docs/settings.md | 1 + public/setting-defaults.js | 1 + public/settings-panel.js | 18 ++++- read-session-file.js | 4 ++ session-cache.js | 8 +++ test/hide-sdk-sessions.test.js | 120 +++++++++++++++++++++++++++++++++ 9 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 test/hide-sdk-sessions.test.js diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 0a9e6ee9f..e450eb1b2 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -100,6 +100,8 @@ Residuals: `encodeProjectPath` truncates at 200 characters and appends a 32-bit - **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). `readSessionFile` keeps the `entrypoint` of the **first** `type: 'user'` record, and `buildProjectsFromCache` drops `sdk-*` rows while the global `hideSdkSessions` setting (default on) is set. First record, not any record: a scheduled run is pre-seeded by `createScheduleSession` with a user line carrying no `entrypoint`, then resumed by `claude --resume -p` whose records say `sdk-cli` — it stays visible. Rows stay in `session_cache`, `session_metrics` and FTS, so the heatmap and token totals still count that activity. The column was added through the schema-reconciliation block with `mustReindex`, so an existing cache is rebuilt once to fill it. + ## Remote SSH hosts (issue #201) A declared SSH host's `~/.claude/projects` is mirrored into @@ -1636,7 +1638,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/db.js b/db.js index 7bdbbaced..11da639a2 100644 --- a/db.js +++ b/db.js @@ -310,7 +310,7 @@ if (migrations.length > currentDbVersion) { // Fork columns (shipped in our v4): a foreign-version DB may have skipped // that migration the same way. Their absence means subagent rows were never // indexed, so a re-index is needed too. - for (const col of ['parentSessionId', 'agentId', 'subagentType', 'description', 'bridgeSessionId', 'mergedIntoSessionId']) { + for (const col of ['parentSessionId', 'agentId', 'subagentType', 'description', 'bridgeSessionId', 'mergedIntoSessionId', 'entrypoint']) { if (!cols.has(col)) { db.exec(`ALTER TABLE session_cache ADD COLUMN ${col} TEXT`); mustReindex = true; @@ -418,8 +418,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 +429,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 @@ -556,7 +557,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, + s.entrypoint || null ); } }); diff --git a/docs/session-browser.md b/docs/session-browser.md index ac08281a4..907d57353 100644 --- a/docs/session-browser.md +++ b/docs/session-browser.md @@ -127,6 +127,16 @@ 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. 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/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..ddc8c6487 100644 --- a/read-session-file.js +++ b/read-session-file.js @@ -179,6 +179,8 @@ 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; // 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 +201,7 @@ 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 = entry.entrypoint || null; // Compaction mirror dedup key -- see .ai/contexts/session-cache.md if (entry.type === 'bridge-session' && typeof entry.bridgeSessionId === 'string' && entry.bridgeSessionId && !bridgeSessionId) { @@ -282,6 +285,7 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { fileMtime: stat.mtime.toISOString(), messageCount, textContent, slug, customTitle, aiTitle, bridgeSessionId, + entrypoint: entrypoint || null, dailyMetrics, }; } catch { diff --git a/session-cache.js b/session-cache.js index ec6a138d3..d5301bd56 100644 --- a/session-cache.js +++ b/session-cache.js @@ -8,6 +8,7 @@ const { readSessionFile, readSessionDisplayHeader, enumerateSessionFiles, resolv 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. @@ -427,12 +428,18 @@ function isProjectHidden(hiddenProjects, alias, projectPath) { return alias !== null && hiddenProjects.has(joinFolderKey(alias, projectPath)); } +function isSdkEntrypoint(entrypoint) { + return typeof entrypoint === 'string' && entrypoint.startsWith('sdk-'); +} + /** Build projects response from cached data */ function buildProjectsFromCache(showArchived) { const metaMap = getAllMeta(); const cachedRows = getAllCached(); const global = getSetting('global') || {}; const hiddenProjects = new Set(global.hiddenProjects || []); + // see .ai/contexts/session-cache.md ("SDK-launched sessions") + const hideSdkSessions = global.hideSdkSessions ?? SETTING_DEFAULTS.hideSdkSessions; // Group by projectPath, not on-disk folder name. Multiple ~/.claude/projects// // directories can resolve to the same projectPath (Claude Code's folder-name encoding @@ -464,6 +471,7 @@ function buildProjectsFromCache(showArchived) { for (const row of cachedRows) { if (row.mergedIntoSessionId) continue; // rolled up into its parent below, not its own entry if (!row.projectPath) continue; + if (hideSdkSessions && isSdkEntrypoint(row.entrypoint)) continue; const { alias } = parseFolderKey(row.folder); if (isProjectHidden(hiddenProjects, alias, row.projectPath)) continue; const meta = metaMap.get(row.sessionId); diff --git a/test/hide-sdk-sessions.test.js b/test/hide-sdk-sessions.test.js new file mode 100644 index 000000000..847d95ea2 --- /dev/null +++ b/test/hide-sdk-sessions.test.js @@ -0,0 +1,120 @@ +// Sessions started through the Agent SDK (entrypoint sdk-cli / sdk-py / sdk-ts) +// are hidden from the project list unless the hideSdkSessions setting is off. +// 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 } = 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, null); + } finally { + cleanup(tmp); + } +}); + +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'), + ]; + 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 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']); +}); From 50c3f8c24aaad77e12feb79d9a846508588b96b2 Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 7 Oct 2026 14:30:26 +0200 Subject: [PATCH 2/9] (changelog): note hidden SDK-launched sessions Claude-Session: https://claude.ai/code/session_0121XfXy4y1Fwt3wJsD4v82q --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b7b21658..ab6466c15 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. Scheduled tasks are always listed, and the activity heatmap still counts the hidden sessions. The session cache is rebuilt once on the first launch after the update. (#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) From 5394ae8f6a2d9fce9164aeac2286b7f0eec5ffdc Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 7 Oct 2026 14:51:01 +0200 Subject: [PATCH 3/9] (sidebar): hide an SDK session's subagents and show it once typed into Subagents of a hidden SDK session fell into Orphan subagents and kept SDK-only projects listed. A session resumed from a terminal stayed hidden because only its first turn counted, and the header-only refresh never saw the later turn: count any cli turn, and re-read cached SDK rows in full. Claude-Session: https://claude.ai/code/session_0121XfXy4y1Fwt3wJsD4v82q --- .ai/contexts/session-cache.md | 2 +- docs/session-browser.md | 5 ++- read-session-file.js | 4 +- session-cache.js | 8 +++- test/hide-sdk-sessions.test.js | 67 +++++++++++++++++++++++++++++++++- 5 files changed, 78 insertions(+), 8 deletions(-) diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index e450eb1b2..bb53c5137 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -100,7 +100,7 @@ Residuals: `encodeProjectPath` truncates at 200 characters and appends a 32-bit - **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). `readSessionFile` keeps the `entrypoint` of the **first** `type: 'user'` record, and `buildProjectsFromCache` drops `sdk-*` rows while the global `hideSdkSessions` setting (default on) is set. First record, not any record: a scheduled run is pre-seeded by `createScheduleSession` with a user line carrying no `entrypoint`, then resumed by `claude --resume -p` whose records say `sdk-cli` — it stays visible. Rows stay in `session_cache`, `session_metrics` and FTS, so the heatmap and token totals still count that activity. The column was added through the schema-reconciliation block with `mustReindex`, so an existing cache is rebuilt once to fill it. +- **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). `readSessionFile` keeps the `entrypoint` of the **first** `type: 'user'` record, or `cli` as soon as any user record says `cli` (an SDK session someone later resumed and typed into is theirs again — 5 strap developer transcripts measured). `buildProjectsFromCache` drops `sdk-*` rows, and the subagent rows whose `parentSessionId` is one of them (they would otherwise land in "Orphan subagents" and keep an SDK-only project in the list), while the global `hideSdkSessions` setting (default on) is set. Because a terminal turn lands at the end of the file, beyond the header-only read, `refreshFolder` skips the header-only branch for a cached `sdk-*` row and re-reads it in full; SDK transcripts are short headless runs, so the cost stays small. A hidden session is never in the renderer's `sessionMap`, so the working-set restore drops it silently if it was left open with the setting off. First record, not any record: a scheduled run is pre-seeded by `createScheduleSession` with a user line carrying no `entrypoint`, then resumed by `claude --resume -p` whose records say `sdk-cli` — it stays visible. Rows stay in `session_cache`, `session_metrics` and FTS, so the heatmap and token totals still count that activity. The column was added through the schema-reconciliation block with `mustReindex`, so an existing cache is rebuilt once to fill it. ## Remote SSH hosts (issue #201) diff --git a/docs/session-browser.md b/docs/session-browser.md index 907d57353..05b8778b2 100644 --- a/docs/session-browser.md +++ b/docs/session-browser.md @@ -134,8 +134,9 @@ A session that a program started through the Claude Agent SDK — a headless 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. Scheduled tasks are always listed. Hidden sessions still count in the -activity heatmap. +them. This applies to running and pinned ones too, and to their subagents. A +session you later resumed and typed into is listed again. Scheduled tasks are +always listed. Hidden sessions still count in the activity heatmap. ### Groups inside a project diff --git a/read-session-file.js b/read-session-file.js index ddc8c6487..738cb6325 100644 --- a/read-session-file.js +++ b/read-session-file.js @@ -181,6 +181,7 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { 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. @@ -202,6 +203,7 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { if (entry.agentId && !agentId) agentId = entry.agentId; if (entry.isSidechain) sidechainSeen = true; if (entrypoint === undefined && entry.type === 'user') entrypoint = entry.entrypoint || null; + if (entry.type === 'user' && entry.entrypoint === 'cli') typedInTerminal = true; // Compaction mirror dedup key -- see .ai/contexts/session-cache.md if (entry.type === 'bridge-session' && typeof entry.bridgeSessionId === 'string' && entry.bridgeSessionId && !bridgeSessionId) { @@ -285,7 +287,7 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { fileMtime: stat.mtime.toISOString(), messageCount, textContent, slug, customTitle, aiTitle, bridgeSessionId, - entrypoint: entrypoint || null, + entrypoint: typedInTerminal ? 'cli' : (entrypoint || null), dailyMetrics, }; } catch { diff --git a/session-cache.js b/session-cache.js index d5301bd56..51a42ffd4 100644 --- a/session-cache.js +++ b/session-cache.js @@ -242,7 +242,8 @@ function refreshFolder(folder, opts = {}) { continue; // unchanged, skip } - if (cachedEntry) { + // see .ai/contexts/session-cache.md ("SDK-launched sessions") + if (cachedEntry && !isSdkEntrypoint(cachedEntry.entrypoint)) { // EXISTING -- header-only refresh. const h = readSessionDisplayHeader(filePath, { parentSessionId }); if (h) { @@ -440,6 +441,9 @@ function buildProjectsFromCache(showArchived) { const hiddenProjects = new Set(global.hiddenProjects || []); // see .ai/contexts/session-cache.md ("SDK-launched sessions") const hideSdkSessions = global.hideSdkSessions ?? SETTING_DEFAULTS.hideSdkSessions; + const hiddenSdkIds = new Set(hideSdkSessions + ? cachedRows.filter(r => isSdkEntrypoint(r.entrypoint)).map(r => r.sessionId) + : []); // Group by projectPath, not on-disk folder name. Multiple ~/.claude/projects// // directories can resolve to the same projectPath (Claude Code's folder-name encoding @@ -471,7 +475,7 @@ function buildProjectsFromCache(showArchived) { for (const row of cachedRows) { if (row.mergedIntoSessionId) continue; // rolled up into its parent below, not its own entry if (!row.projectPath) continue; - if (hideSdkSessions && isSdkEntrypoint(row.entrypoint)) continue; + if (hiddenSdkIds.has(row.sessionId) || hiddenSdkIds.has(row.parentSessionId)) continue; const { alias } = parseFolderKey(row.folder); if (isProjectHidden(hiddenProjects, alias, row.projectPath)) continue; const meta = metaMap.get(row.sessionId); diff --git a/test/hide-sdk-sessions.test.js b/test/hide-sdk-sessions.test.js index 847d95ea2..80213f6f2 100644 --- a/test/hide-sdk-sessions.test.js +++ b/test/hide-sdk-sessions.test.js @@ -65,6 +65,68 @@ test('a scheduled run keeps no entrypoint: its first user turn is pre-seeded by } }); +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 re-reads a cached SDK session in full, so a terminal turn makes it visible', () => { + 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(), @@ -93,6 +155,7 @@ function visibleIds(global) { 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, @@ -110,11 +173,11 @@ function visibleIds(global) { } } -test('SDK-launched sessions are hidden by default', () => { +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']); + ['interactive', 'scheduled', 'sdk-cli-run', 'sdk-py-run', 'sdk-ts-run', 'sub:sdk-py-run:a1']); }); From 843f6335e6b0a0b3bdb2e56d0fb20c199b3ea011 Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 7 Oct 2026 14:53:00 +0200 Subject: [PATCH 4/9] (sidebar): cap the full re-read of a live SDK session at 2 MB A 13 MB live SDK transcript re-read in full costs ~250 ms of main thread per watcher flush; above 2 MB keep the header-only refresh. Claude-Session: https://claude.ai/code/session_0121XfXy4y1Fwt3wJsD4v82q --- .ai/contexts/session-cache.md | 2 +- session-cache.js | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index bb53c5137..e60432ce6 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -100,7 +100,7 @@ Residuals: `encodeProjectPath` truncates at 200 characters and appends a 32-bit - **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). `readSessionFile` keeps the `entrypoint` of the **first** `type: 'user'` record, or `cli` as soon as any user record says `cli` (an SDK session someone later resumed and typed into is theirs again — 5 strap developer transcripts measured). `buildProjectsFromCache` drops `sdk-*` rows, and the subagent rows whose `parentSessionId` is one of them (they would otherwise land in "Orphan subagents" and keep an SDK-only project in the list), while the global `hideSdkSessions` setting (default on) is set. Because a terminal turn lands at the end of the file, beyond the header-only read, `refreshFolder` skips the header-only branch for a cached `sdk-*` row and re-reads it in full; SDK transcripts are short headless runs, so the cost stays small. A hidden session is never in the renderer's `sessionMap`, so the working-set restore drops it silently if it was left open with the setting off. First record, not any record: a scheduled run is pre-seeded by `createScheduleSession` with a user line carrying no `entrypoint`, then resumed by `claude --resume -p` whose records say `sdk-cli` — it stays visible. Rows stay in `session_cache`, `session_metrics` and FTS, so the heatmap and token totals still count that activity. The column was added through the schema-reconciliation block with `mustReindex`, so an existing cache is rebuilt once to fill it. +- **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). `readSessionFile` keeps the `entrypoint` of the **first** `type: 'user'` record, or `cli` as soon as any user record says `cli` (an SDK session someone later resumed and typed into is theirs again — 5 strap developer transcripts measured). `buildProjectsFromCache` drops `sdk-*` rows, and the subagent rows whose `parentSessionId` is one of them (they would otherwise land in "Orphan subagents" and keep an SDK-only project in the list), while the global `hideSdkSessions` setting (default on) is set. Because a terminal turn lands at the end of the file, beyond the header-only read, `refreshFolder` skips the header-only branch for a cached `sdk-*` row and re-reads it in full, up to 2 MB (`SDK_FULL_REREAD_MAX_BYTES`). Measured 2026-10-07 over 4 495 SDK transcripts: p50 44 KB, p99 711 KB, max 13 MB; a full read costs ~10 ms at 0.7 MB but ~250 ms at 14 MB, on the main thread at every watcher flush. A larger one keeps the header-only refresh and only turns visible at the next cold-start full read. A hidden session is never in the renderer's `sessionMap`, so the working-set restore drops it silently if it was left open with the setting off. First record, not any record: a scheduled run is pre-seeded by `createScheduleSession` with a user line carrying no `entrypoint`, then resumed by `claude --resume -p` whose records say `sdk-cli` — it stays visible. Rows stay in `session_cache`, `session_metrics` and FTS, so the heatmap and token totals still count that activity. The column was added through the schema-reconciliation block with `mustReindex`, so an existing cache is rebuilt once to fill it. ## Remote SSH hosts (issue #201) diff --git a/session-cache.js b/session-cache.js index 51a42ffd4..c9aef68ac 100644 --- a/session-cache.js +++ b/session-cache.js @@ -243,7 +243,8 @@ function refreshFolder(folder, opts = {}) { } // see .ai/contexts/session-cache.md ("SDK-launched sessions") - if (cachedEntry && !isSdkEntrypoint(cachedEntry.entrypoint)) { + const rereadSdk = cachedEntry && isSdkEntrypoint(cachedEntry.entrypoint) && stat.size <= SDK_FULL_REREAD_MAX_BYTES; + if (cachedEntry && !rereadSdk) { // EXISTING -- header-only refresh. const h = readSessionDisplayHeader(filePath, { parentSessionId }); if (h) { @@ -429,6 +430,8 @@ function isProjectHidden(hiddenProjects, alias, projectPath) { return alias !== null && hiddenProjects.has(joinFolderKey(alias, projectPath)); } +const SDK_FULL_REREAD_MAX_BYTES = 2 * 1024 * 1024; + function isSdkEntrypoint(entrypoint) { return typeof entrypoint === 'string' && entrypoint.startsWith('sdk-'); } From 732628f58d3f9de0820e1ba0b0d5bf296f2ae887 Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 7 Oct 2026 15:36:06 +0200 Subject: [PATCH 5/9] (sidebar): keep open SDK sessions listed and add the column without a wipe Review of #486: an open or saved SDK session vanished from the list, a continuation typed in a compaction mirror kept its parent hidden, and the entrypoint column wiped session_cache from db.js, before the single-instance lock. The column now lands as NULL (visible) and a chunked backfill fills it from each transcript's head; a cheap entrypoint scan replaces the full re-read of cached SDK rows; an SDK-only project no longer keeps an empty header; non-string entrypoints are stored as none. Claude-Session: https://claude.ai/code/session_0121XfXy4y1Fwt3wJsD4v82q --- .ai/contexts/session-cache.md | 6 +- CHANGELOG.md | 2 +- db.js | 18 ++- docs/session-browser.md | 8 +- main.js | 7 +- read-session-file.js | 68 ++++++++- session-cache.js | 82 ++++++++-- .../get-projects-cold-start-reconcile.test.js | 4 +- test/hide-sdk-sessions.test.js | 140 +++++++++++++++++- 9 files changed, 302 insertions(+), 33 deletions(-) diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index e60432ce6..7f0789ce7 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -100,7 +100,11 @@ Residuals: `encodeProjectPath` truncates at 200 characters and appends a 32-bit - **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). `readSessionFile` keeps the `entrypoint` of the **first** `type: 'user'` record, or `cli` as soon as any user record says `cli` (an SDK session someone later resumed and typed into is theirs again — 5 strap developer transcripts measured). `buildProjectsFromCache` drops `sdk-*` rows, and the subagent rows whose `parentSessionId` is one of them (they would otherwise land in "Orphan subagents" and keep an SDK-only project in the list), while the global `hideSdkSessions` setting (default on) is set. Because a terminal turn lands at the end of the file, beyond the header-only read, `refreshFolder` skips the header-only branch for a cached `sdk-*` row and re-reads it in full, up to 2 MB (`SDK_FULL_REREAD_MAX_BYTES`). Measured 2026-10-07 over 4 495 SDK transcripts: p50 44 KB, p99 711 KB, max 13 MB; a full read costs ~10 ms at 0.7 MB but ~250 ms at 14 MB, on the main thread at every watcher flush. A larger one keeps the header-only refresh and only turns visible at the next cold-start full read. A hidden session is never in the renderer's `sessionMap`, so the working-set restore drops it silently if it was left open with the setting off. First record, not any record: a scheduled run is pre-seeded by `createScheduleSession` with a user line carrying no `entrypoint`, then resumed by `claude --resume -p` whose records say `sdk-cli` — it stays visible. Rows stay in `session_cache`, `session_metrics` and FTS, so the heatmap and token totals still count that activity. The column was added through the schema-reconciliation block with `mustReindex`, so an existing cache is rebuilt once to fill it. +- **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 the first 256 KB for the first user turn; only an `sdk-*` one is scanned further for a `cli` user turn, in full up to 2 MB and its last 256 KB beyond (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. ## Remote SSH hosts (issue #201) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab6466c15..ab949c24f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +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. Scheduled tasks are always listed, and the activity heatmap still counts the hidden sessions. The session cache is rebuilt once on the first launch after the update. (#486) +- 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 11da639a2..3ce5ba1e7 100644 --- a/db.js +++ b/db.js @@ -310,12 +310,14 @@ if (migrations.length > currentDbVersion) { // Fork columns (shipped in our v4): a foreign-version DB may have skipped // that migration the same way. Their absence means subagent rows were never // indexed, so a re-index is needed too. - for (const col of ['parentSessionId', 'agentId', 'subagentType', 'description', 'bridgeSessionId', 'mergedIntoSessionId', 'entrypoint']) { + for (const col of ['parentSessionId', 'agentId', 'subagentType', 'description', 'bridgeSessionId', 'mergedIntoSessionId']) { if (!cols.has(col)) { db.exec(`ALTER TABLE session_cache ADD COLUMN ${col} TEXT`); 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. @@ -438,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 = ?'), @@ -558,7 +562,7 @@ const upsertCachedSessionsBatch = db.transaction((sessions) => { s.parentSessionId || null, s.agentId || null, s.subagentType || null, s.description || null, s.fileMtime || null, s.bridgeSessionId || null, s.mergedIntoSessionId || null, - s.entrypoint || null + typeof s.entrypoint === 'string' ? s.entrypoint : null ); } }); @@ -590,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); } @@ -888,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 05b8778b2..f8e25894c 100644 --- a/docs/session-browser.md +++ b/docs/session-browser.md @@ -134,9 +134,11 @@ A session that a program started through the Claude Agent SDK — a headless 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. This applies to running and pinned ones too, and to their subagents. A -session you later resumed and typed into is listed again. Scheduled tasks are -always listed. Hidden sessions still count in the activity heatmap. +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 diff --git a/main.js b/main.js index 98d463b42..96b56b6ca 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, }, }); const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem, - buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker, + buildProjectsFromCache, backfillEntrypoints, 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) { diff --git a/read-session-file.js b/read-session-file.js index 738cb6325..f04a265f9 100644 --- a/read-session-file.js +++ b/read-session-file.js @@ -202,8 +202,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 = entry.entrypoint || null; - if (entry.type === 'user' && entry.entrypoint === 'cli') typedInTerminal = 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) { @@ -287,7 +287,7 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { fileMtime: stat.mtime.toISOString(), messageCount, textContent, slug, customTitle, aiTitle, bridgeSessionId, - entrypoint: typedInTerminal ? 'cli' : (entrypoint || null), + entrypoint: typedInTerminal ? 'cli' : (entrypoint ?? ''), dailyMetrics, }; } catch { @@ -591,4 +591,64 @@ 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 readSessionEntrypoint(filePath) { + let fd; + try { + fd = fs.openSync(filePath, 'r'); + const size = fs.fstatSync(fd).size; + const headLen = Math.min(SDK_TAIL_SCAN_BYTES, size); + const headLines = readTextRange(fd, 0, headLen).split('\n'); + if (headLen < size) headLines.pop(); + let first; + for (const line of headLines) { + if (!line) continue; + let entry; + try { entry = JSON.parse(line); } catch { continue; } + if (entry.type === 'user') { first = entrypointOf(entry); break; } + } + if (first === undefined) return null; + if (!isSdkEntrypoint(first)) return first; + const rest = size <= SDK_FULL_SCAN_MAX_BYTES + ? readTextRange(fd, 0, size) + : readTextRange(fd, size - SDK_TAIL_SCAN_BYTES, SDK_TAIL_SCAN_BYTES); + return hasTerminalTurn(rest) ? '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 c9aef68ac..b0187c827 100644 --- a/session-cache.js +++ b/session-cache.js @@ -4,7 +4,7 @@ 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'); @@ -19,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; +let entrypointBackfill = null; function init(ctx) { PROJECTS_DIR = ctx.PROJECTS_DIR; @@ -46,6 +48,9 @@ function init(ctx) { setName = ctx.db.setName; isInitialScanComplete = ctx.db.isInitialScanComplete; setInitialScanComplete = ctx.db.setInitialScanComplete; + getCachedMissingEntrypoint = ctx.db.getCachedMissingEntrypoint; + setCachedEntrypoints = ctx.db.setCachedEntrypoints; + entrypointBackfill = null; } // alias -> that host's mirrored projects root; empty unless one is declared. @@ -242,9 +247,7 @@ function refreshFolder(folder, opts = {}) { continue; // unchanged, skip } - // see .ai/contexts/session-cache.md ("SDK-launched sessions") - const rereadSdk = cachedEntry && isSdkEntrypoint(cachedEntry.entrypoint) && stat.size <= SDK_FULL_REREAD_MAX_BYTES; - if (cachedEntry && !rereadSdk) { + if (cachedEntry) { // EXISTING -- header-only refresh. const h = readSessionDisplayHeader(filePath, { parentSessionId }); if (h) { @@ -269,6 +272,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 }); @@ -430,10 +437,55 @@ function isProjectHidden(hiddenProjects, alias, projectPath) { return alias !== null && hiddenProjects.has(joinFolderKey(alias, projectPath)); } -const SDK_FULL_REREAD_MAX_BYTES = 2 * 1024 * 1024; +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: '.' })); + 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; +} -function isSdkEntrypoint(entrypoint) { - return typeof entrypoint === 'string' && entrypoint.startsWith('sdk-'); +// 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 */ @@ -442,11 +494,6 @@ function buildProjectsFromCache(showArchived) { const cachedRows = getAllCached(); const global = getSetting('global') || {}; const hiddenProjects = new Set(global.hiddenProjects || []); - // see .ai/contexts/session-cache.md ("SDK-launched sessions") - const hideSdkSessions = global.hideSdkSessions ?? SETTING_DEFAULTS.hideSdkSessions; - const hiddenSdkIds = new Set(hideSdkSessions - ? cachedRows.filter(r => isSdkEntrypoint(r.entrypoint)).map(r => r.sessionId) - : []); // Group by projectPath, not on-disk folder name. Multiple ~/.claude/projects// // directories can resolve to the same projectPath (Claude Code's folder-name encoding @@ -468,6 +515,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; @@ -478,8 +528,11 @@ function buildProjectsFromCache(showArchived) { for (const row of cachedRows) { if (row.mergedIntoSessionId) continue; // rolled up into its parent below, not its own entry if (!row.projectPath) continue; - if (hiddenSdkIds.has(row.sessionId) || hiddenSdkIds.has(row.parentSessionId)) 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) || []; @@ -572,7 +625,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); @@ -981,6 +1034,7 @@ module.exports = { refreshFolder, reconcileCacheFromFilesystem, buildProjectsFromCache, + backfillEntrypoints, 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..d00d8d4a3 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, + () => {} ); } diff --git a/test/hide-sdk-sessions.test.js b/test/hide-sdk-sessions.test.js index 80213f6f2..6242eb0da 100644 --- a/test/hide-sdk-sessions.test.js +++ b/test/hide-sdk-sessions.test.js @@ -8,7 +8,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); -const { readSessionFile } = require('../read-session-file'); +const { readSessionFile, readSessionEntrypoint } = require('../read-session-file'); const sessionCache = require('../session-cache'); const { encodeProjectPath } = require('../encode-project-path'); @@ -59,7 +59,7 @@ test('a scheduled run keeps no entrypoint: its first user turn is pre-seeded by user('continue', { entrypoint: 'sdk-cli' }), assistant({ entrypoint: 'sdk-cli' }), ]); - assert.equal(readSessionFile(scheduled, 'f', '/p').entrypoint, null); + assert.equal(readSessionFile(scheduled, 'f', '/p').entrypoint, ''); } finally { cleanup(tmp); } @@ -79,7 +79,7 @@ test('an SDK session later typed into from a terminal counts as interactive', () } }); -test('refreshFolder re-reads a cached SDK session in full, so a terminal turn makes it visible', () => { +test('refreshFolder notices a terminal turn appended to a cached SDK session', () => { const projectsDir = mkTmp(); try { const projectPath = projectsDir; @@ -181,3 +181,137 @@ 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('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'); + } 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); + } +}); From 7f1dd0dddc4358cad64576f1026f3347d4a20e5c Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 7 Oct 2026 15:39:49 +0200 Subject: [PATCH 6/9] (sidebar): find the first user turn past an oversized SDK prompt An SDK prompt is written first as a queue-operation line that can exceed the 256 KB head on its own, so the backfill left ~190 SDK sessions visible. Read the head in chunks until the first user turn. Claude-Session: https://claude.ai/code/session_0121XfXy4y1Fwt3wJsD4v82q --- .ai/contexts/session-cache.md | 2 +- read-session-file.js | 37 +++++++++++++++++++++++++--------- test/hide-sdk-sessions.test.js | 6 ++++++ 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 7f0789ce7..990a7ad3b 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -103,7 +103,7 @@ Residuals: `encodeProjectPath` truncates at 200 characters and appends a 32-bit - **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 the first 256 KB for the first user turn; only an `sdk-*` one is scanned further for a `cli` user turn, in full up to 2 MB and its last 256 KB beyond (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. + - **`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 its last 256 KB beyond (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. ## Remote SSH hosts (issue #201) diff --git a/read-session-file.js b/read-session-file.js index f04a265f9..27d6e3838 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 @@ -623,21 +624,37 @@ function hasTerminalTurn(text) { 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) { let fd; try { fd = fs.openSync(filePath, 'r'); const size = fs.fstatSync(fd).size; - const headLen = Math.min(SDK_TAIL_SCAN_BYTES, size); - const headLines = readTextRange(fd, 0, headLen).split('\n'); - if (headLen < size) headLines.pop(); - let first; - for (const line of headLines) { - if (!line) continue; - let entry; - try { entry = JSON.parse(line); } catch { continue; } - if (entry.type === 'user') { first = entrypointOf(entry); break; } - } + const first = readFirstUserEntrypoint(fd, size); if (first === undefined) return null; if (!isSdkEntrypoint(first)) return first; const rest = size <= SDK_FULL_SCAN_MAX_BYTES diff --git a/test/hide-sdk-sessions.test.js b/test/hide-sdk-sessions.test.js index 6242eb0da..b75559c25 100644 --- a/test/hide-sdk-sessions.test.js +++ b/test/hide-sdk-sessions.test.js @@ -247,6 +247,12 @@ test('readSessionEntrypoint reads the head, and the tail of a large SDK transcri ]); assert.ok(fs.statSync(big).size > 2 * 1024 * 1024); assert.equal(readSessionEntrypoint(big), '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); } From 89e460447ddeaf39c25fa05fe7f7380b3468359f Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 7 Oct 2026 16:01:27 +0200 Subject: [PATCH 7/9] (sidebar): keep pending restore entries and reveal an SDK session once opened Second review of #486: a persist during a cold restore rewrote openWorkingSet without the entries not reached yet, dropping their SDK exemption; keep the planner's pending entries and the ones awaiting the restore toast. Opening a hidden SDK session after the list was built left it out of the sidebar; notify the renderer when it becomes active. Scan the head of large SDK transcripts as well as their tail, and pin the backfill call in get-projects with a test. Claude-Session: https://claude.ai/code/session_0121XfXy4y1Fwt3wJsD4v82q --- .ai/contexts/session-cache.md | 6 +- main.js | 6 +- public/app.js | 18 +++++- public/restore-plan.js | 2 +- read-session-file.js | 8 +-- session-cache.js | 10 ++- .../get-projects-cold-start-reconcile.test.js | 20 +++++- test/hide-sdk-sessions.test.js | 38 ++++++++++- test/restore-live-elsewhere.test.js | 5 +- test/restore-pending-persist.test.js | 64 +++++++++++++++++++ test/terminal-refresh-ipc.test.js | 2 + 11 files changed, 161 insertions(+), 18 deletions(-) create mode 100644 test/restore-pending-persist.test.js diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 990a7ad3b..d280c33ec 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -96,15 +96,15 @@ 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`), 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 its last 256 KB beyond (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. + - **`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 (a terminal turn in the middle of a larger SDK transcript is missed until the next full read) (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) diff --git a/main.js b/main.js index 96b56b6ca..bf8caf375 100644 --- a/main.js +++ b/main.js @@ -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, getCachedMissingEntrypoint, setCachedEntrypoints, + isInitialScanComplete, setInitialScanComplete, getCachedMissingEntrypoint, setCachedEntrypoints, getCachedSession, }, }); const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem, - buildProjectsFromCache, backfillEntrypoints, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker, + buildProjectsFromCache, backfillEntrypoints, revealIfSdkSession, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker, scanFoldersViaWorker, setRemoteRoots, resolveFolderDir, isIndexingFinished } = sessionCache; const { resolveJsonlPath, readSubagentMeta } = require('./read-session-file'); @@ -2513,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; @@ -2882,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..dc2706778 100644 --- a/public/app.js +++ b/public/app.js @@ -164,6 +164,7 @@ let sessionOpenedOutsideRestore = false; // see .ai/contexts/cli-session-state.md ("Live elsewhere") const skippedWorkingSetEntries = new Map(); let restoreSavedIndex = new Map(); +let restoreAwaitingConsent = []; // 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 +186,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 +198,16 @@ function persistWorkingSet() { return _persistChain; } +// see .ai/contexts/session-cache.md ("Working-set restore: retry until indexing is done") +function pendingRestoreEntries() { + const pending = [...restoreAwaitingConsent]; + 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); @@ -301,9 +312,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 +327,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/read-session-file.js b/read-session-file.js index 27d6e3838..4f575487a 100644 --- a/read-session-file.js +++ b/read-session-file.js @@ -657,10 +657,10 @@ function readSessionEntrypoint(filePath) { const first = readFirstUserEntrypoint(fd, size); if (first === undefined) return null; if (!isSdkEntrypoint(first)) return first; - const rest = size <= SDK_FULL_SCAN_MAX_BYTES - ? readTextRange(fd, 0, size) - : readTextRange(fd, size - SDK_TAIL_SCAN_BYTES, SDK_TAIL_SCAN_BYTES); - return hasTerminalTurn(rest) ? 'cli' : first; + if (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 { diff --git a/session-cache.js b/session-cache.js index b0187c827..25fdd7076 100644 --- a/session-cache.js +++ b/session-cache.js @@ -19,7 +19,7 @@ let deleteCachedFolder, getCachedByFolder, upsertCachedSessions, deleteCachedSes let deleteSearchFolder, deleteSearchSession, upsertSearchEntries; let setFolderMeta, getFolderMeta, getAllFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName; let isInitialScanComplete, setInitialScanComplete; -let getCachedMissingEntrypoint, setCachedEntrypoints; +let getCachedMissingEntrypoint, setCachedEntrypoints, getCachedSession; let entrypointBackfill = null; function init(ctx) { @@ -49,6 +49,7 @@ function init(ctx) { isInitialScanComplete = ctx.db.isInitialScanComplete; setInitialScanComplete = ctx.db.setInitialScanComplete; getCachedMissingEntrypoint = ctx.db.getCachedMissingEntrypoint; + getCachedSession = ctx.db.getCachedSession; setCachedEntrypoints = ctx.db.setCachedEntrypoints; entrypointBackfill = null; } @@ -471,6 +472,12 @@ function backfillEntrypoints() { 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(); @@ -1035,6 +1042,7 @@ module.exports = { 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 d00d8d4a3..f688d9740 100644 --- a/test/get-projects-cold-start-reconcile.test.js +++ b/test/get-projects-cold-start-reconcile.test.js @@ -63,7 +63,7 @@ function makeHandler(mocks) { mocks.isInitialScanComplete, mocks.populateCacheViaWorker, mocks.reconcileCacheFromFilesystem, mocks.buildProjectsFromCache, mergePlaceholderSessions, annotateRemoteAttachable, applyAndPersistArchived, () => null, () => {}, false, - () => {} + mocks.backfillEntrypoints || (() => {}) ); } @@ -134,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 index b75559c25..bb03792ec 100644 --- a/test/hide-sdk-sessions.test.js +++ b/test/hide-sdk-sessions.test.js @@ -1,5 +1,3 @@ -// Sessions started through the Agent SDK (entrypoint sdk-cli / sdk-py / sdk-ts) -// are hidden from the project list unless the hideSdkSessions setting is off. // see .ai/contexts/session-cache.md ("SDK-launched sessions") const test = require('node:test'); @@ -216,6 +214,36 @@ test('an SDK session open in a terminal or in the saved working set stays listed 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 = [ @@ -247,6 +275,12 @@ test('readSessionEntrypoint reads the head, and the tail of a large SDK transcri ]); 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 longPrompt = write(tmp, 'long-prompt', [ { type: 'queue-operation', operation: 'enqueue', content: 'é'.repeat(300 * 1024) }, { type: 'queue-operation', operation: 'dequeue' }, diff --git a/test/restore-live-elsewhere.test.js b/test/restore-live-elsewhere.test.js index 5ffeb59c8..de6f8d274 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']) .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..4c07448c5 --- /dev/null +++ b/test/restore-pending-persist.test.js @@ -0,0 +1,64 @@ +// 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 activeSessionId = 'opened'; + var _persistChain = Promise.resolve(); + var restorePlanner = createRestorePlanner({ savedSet: ${JSON.stringify(SAVED)} }); + `, ctx); + const fns = loadAppFunctions(ctx, { + functions: ['persistWorkingSet', 'pendingRestoreEntries'], + declarations: ['skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent'], + }); + vm.runInContext(`restoreSavedIndex = new Map(${JSON.stringify(SAVED.map((item, i) => [item.sessionId, i]))});`, ctx); + return { dom, ctx, settings, ...fns }; +} + +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.settings.global.openWorkingSet.map(i => i.sessionId), ['opened', 'sdk-unindexed']); + await h.persistWorkingSet(); + assert.deepEqual(h.settings.global.openWorkingSet.map(i => i.sessionId), ['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.settings.global.openWorkingSet.map(i => i.sessionId), ['opened', 'sdk-unindexed']); + vm.runInContext('restoreAwaitingConsent = [];', h.ctx); + await h.persistWorkingSet(); + assert.deepEqual(h.settings.global.openWorkingSet.map(i => i.sessionId), ['opened'], + 'once dismissed, the entry is dropped as before'); + 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); From 3dd7fbc75237ecacb74f38ea5a12b40b656bb61b Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 7 Oct 2026 16:26:53 +0200 Subject: [PATCH 8/9] (sidebar): keep restore candidates stored while they are being opened Third review of #486: an entry handed to runRestore left the planner's pending list but only reached openSessions or the skipped list once its live-elsewhere check and open were done, so a persist in between (or an overlapping restore) dropped it. Track those entries in restoreInFlight. Also read large SDK transcripts in full during the one-time backfill, and pin the production reveal calls with a test. Claude-Session: https://claude.ai/code/session_0121XfXy4y1Fwt3wJsD4v82q --- .ai/contexts/session-cache.md | 4 +- public/app.js | 11 ++++- read-session-file.js | 4 +- session-cache.js | 2 +- test/hide-sdk-sessions.test.js | 30 ++++++++++++++ test/restore-live-elsewhere.test.js | 2 +- test/restore-pending-persist.test.js | 62 ++++++++++++++++++++++++---- 7 files changed, 98 insertions(+), 17 deletions(-) diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index d280c33ec..fe0788ff9 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -96,14 +96,14 @@ 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`. A persist during that window keeps the entries not resolved yet (`pendingRestoreEntries`), otherwise any click or close would erase them from `openWorkingSet` before they are reached; see `test/restore-pending-persist.test.js`. +- **Working-set restore retries until indexing is done, not once.** `populateCacheViaWorker` streams `sessionMap` one folder at a time on a cold start, so a saved working-set id can be missing for many ticks before it's genuinely indexed. `createRestorePlanner()` (`public/restore-plan.js`) is ticked from every `projects-changed` handler and from `updateIndexingBanner` on `payload.done`; it keeps returning `'wait'` until every saved id is indexed or indexing is over (then the rest is presumed deleted), restoring incrementally in `auto` mode and asking once (`askOnce: true`) in `ask` mode instead of re-prompting per tick. The end of indexing reaches the renderer on its own `indexing-finished` channel, sent at the end of **every** `populateCacheViaWorker` run, warm start included: `indexing-progress` is first-run only, so a warm start never told the planner that indexing was over and a saved session missing from the index left the "Finishing indexing" toast up for good. When the planner gives up (indexing over, or the tick cap) it returns the saved entries it never found as `unavailable`, and `tickRestorePlanner` names them in a "Not restored" notice. The end is also pullable (`get-indexing-state`, read once when the planner starts) because the startup scan can finish before the renderer listens, and `markRestoreIndexingDone` reloads the projects before the final tick because the last folders may not have reached `sessionMap` yet. See `test/session-restore-cold-cache.test.js`, `test/restore-unavailable.test.js`. A persist during that window keeps the entries not resolved yet (`pendingRestoreEntries`: the planner's pending ones, the ones awaiting the restore toast, and `restoreInFlight`, those handed to `runRestore` while their live-elsewhere check and open are awaited, even across two overlapping restores), otherwise any click or close would erase them from `openWorkingSet` before they are reached; see `test/restore-pending-persist.test.js`. - **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 (a terminal turn in the middle of a larger SDK transcript is missed until the next full read) (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. + - **`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) diff --git a/public/app.js b/public/app.js index dc2706778..af1115129 100644 --- a/public/app.js +++ b/public/app.js @@ -165,6 +165,7 @@ let sessionOpenedOutsideRestore = false; 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) @@ -200,7 +201,7 @@ function persistWorkingSet() { // see .ai/contexts/session-cache.md ("Working-set restore: retry until indexing is done") function pendingRestoreEntries() { - const pending = [...restoreAwaitingConsent]; + const pending = [...restoreAwaitingConsent, ...restoreInFlight.values()]; if (restorePlanner && !restorePlanner.isSettled()) pending.push(...restorePlanner.pending()); return pending.map(item => ({ item, @@ -219,6 +220,7 @@ function schedulePersistWorkingSet() { async function runRestore(list) { const pending = list.filter(item => sessionMap.has(item.sessionId) && !openSessions.has(item.sessionId)); + for (const item of pending) restoreInFlight.set(item.sessionId, item); const liveById = await liveElsewhereMany(pending.map(item => item.sessionId), { api: window.api }); const skippedNow = []; for (const [position, item] of list.entries()) { @@ -228,7 +230,12 @@ async function runRestore(list) { // 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 }); + 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 }); diff --git a/read-session-file.js b/read-session-file.js index 4f575487a..4e09d1b2e 100644 --- a/read-session-file.js +++ b/read-session-file.js @@ -649,7 +649,7 @@ function readFirstUserEntrypoint(fd, size) { return firstUserEntrypointIn([pending + decoder.end()]); } -function readSessionEntrypoint(filePath) { +function readSessionEntrypoint(filePath, { full = false } = {}) { let fd; try { fd = fs.openSync(filePath, 'r'); @@ -657,7 +657,7 @@ function readSessionEntrypoint(filePath) { const first = readFirstUserEntrypoint(fd, size); if (first === undefined) return null; if (!isSdkEntrypoint(first)) return first; - if (size <= SDK_FULL_SCAN_MAX_BYTES) return hasTerminalTurn(readTextRange(fd, 0, size)) ? 'cli' : 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; diff --git a/session-cache.js b/session-cache.js index 25fdd7076..d8e3b2de8 100644 --- a/session-cache.js +++ b/session-cache.js @@ -454,7 +454,7 @@ function backfillEntrypoints() { const row = rows[next]; const dir = resolveFolderDir(row.folder); if (!dir) continue; - const entrypoint = readSessionEntrypoint(resolveJsonlPath(dir, { ...row, folder: '.' })); + const entrypoint = readSessionEntrypoint(resolveJsonlPath(dir, { ...row, folder: '.' }), { full: true }); if (entrypoint === null) continue; if (isSdkEntrypoint(entrypoint)) sdkFound = true; pairs.push({ sessionId: row.sessionId, entrypoint }); diff --git a/test/hide-sdk-sessions.test.js b/test/hide-sdk-sessions.test.js index bb03792ec..3135002f8 100644 --- a/test/hide-sdk-sessions.test.js +++ b/test/hide-sdk-sessions.test.js @@ -281,6 +281,14 @@ test('readSessionEntrypoint reads the head, and the tail of a large SDK transcri ...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' }, @@ -355,3 +363,25 @@ test('adding the entrypoint column keeps the cached sessions', () => { 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'); + + 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 de6f8d274..77fb7057c 100644 --- a/test/restore-live-elsewhere.test.js +++ b/test/restore-live-elsewhere.test.js @@ -88,7 +88,7 @@ function setup({ savedSet, liveIds, batchFails = false }) { function schedulePersistWorkingSet() {} function pollActiveSessions() {} `, ctx); - vm.runInContext(topLevelDeclarations(APP_SRC, ['LIVE_ELSEWHERE_NOTICE_MS', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent']) + 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', 'pendingRestoreEntries', 'runRestore', 'openSession']) { vm.runInContext(functionSource(APP_SRC, name), ctx); diff --git a/test/restore-pending-persist.test.js b/test/restore-pending-persist.test.js index 4c07448c5..e712ccd4c 100644 --- a/test/restore-pending-persist.test.js +++ b/test/restore-pending-persist.test.js @@ -28,26 +28,44 @@ function setup() { 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'], - declarations: ['skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent'], + functions: ['persistWorkingSet', 'pendingRestoreEntries', 'runRestore'], + declarations: ['skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight'], }); vm.runInContext(`restoreSavedIndex = new Map(${JSON.stringify(SAVED.map((item, i) => [item.sessionId, i]))});`, ctx); - return { dom, ctx, settings, ...fns }; + 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.settings.global.openWorkingSet.map(i => i.sessionId), ['opened', 'sdk-unindexed']); + assert.deepEqual(h.stored(), ['opened', 'sdk-unindexed']); await h.persistWorkingSet(); - assert.deepEqual(h.settings.global.openWorkingSet.map(i => i.sessionId), ['opened', 'sdk-unindexed'], - 'a second persist keeps it too'); + assert.deepEqual(h.stored(), ['opened', 'sdk-unindexed'], 'a second persist keeps it too'); h.dom.window.close(); }); @@ -55,10 +73,36 @@ test('entries offered by the restore toast survive a persist until answered', as const h = setup(); vm.runInContext(`restorePlanner.dismiss(); restoreAwaitingConsent = [${JSON.stringify(SAVED[1])}];`, h.ctx); await h.persistWorkingSet(); - assert.deepEqual(h.settings.global.openWorkingSet.map(i => i.sessionId), ['opened', 'sdk-unindexed']); + assert.deepEqual(h.stored(), ['opened', 'sdk-unindexed']); vm.runInContext('restoreAwaitingConsent = [];', h.ctx); await h.persistWorkingSet(); - assert.deepEqual(h.settings.global.openWorkingSet.map(i => i.sessionId), ['opened'], - 'once dismissed, the entry is dropped as before'); + 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('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(); }); From 2b46e282c08e8e360b97d4d0b35a3859bd7e65b7 Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 7 Oct 2026 16:33:35 +0200 Subject: [PATCH 9/9] (sidebar): clear in-flight restore entries on every exit of runRestore An entry opened by hand or deleted during the live-elsewhere check left restoreInFlight through no path, so a session the user then closed was put back into the working set on every persist. Also read main.js with LF line endings in the reveal wiring test, which failed on Windows checkouts. Claude-Session: https://claude.ai/code/session_0121XfXy4y1Fwt3wJsD4v82q --- public/app.js | 46 ++++++++++++++++------------ test/hide-sdk-sessions.test.js | 2 +- test/restore-pending-persist.test.js | 23 ++++++++++++++ test/terminal-refresh.test.js | 2 +- 4 files changed, 51 insertions(+), 22 deletions(-) diff --git a/public/app.js b/public/app.js index af1115129..6420ebab0 100644 --- a/public/app.js +++ b/public/app.js @@ -221,28 +221,34 @@ function schedulePersistWorkingSet() { async function runRestore(list) { const pending = list.filter(item => sessionMap.has(item.sessionId) && !openSessions.has(item.sessionId)); for (const item of pending) restoreInFlight.set(item.sessionId, item); - const liveById = await liveElsewhereMany(pending.map(item => item.sessionId), { api: window.api }); 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; - 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; + 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) diff --git a/test/hide-sdk-sessions.test.js b/test/hide-sdk-sessions.test.js index 3135002f8..419498247 100644 --- a/test/hide-sdk-sessions.test.js +++ b/test/hide-sdk-sessions.test.js @@ -367,7 +367,7 @@ test('adding the entrypoint column keeps the cached sessions', () => { 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'); + const main = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8').replace(/\r\n/g, '\n'); const activeSessions = new Map(); const revealed = []; diff --git a/test/restore-pending-persist.test.js b/test/restore-pending-persist.test.js index e712ccd4c..a62857c96 100644 --- a/test/restore-pending-persist.test.js +++ b/test/restore-pending-persist.test.js @@ -93,6 +93,29 @@ test('a persist while the restore checks the candidates keeps them, and they are 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); 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 }]);