From 1b46d0221a06902db8aa1b0ceb1742f9e81035e9 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:02:27 +0200 Subject: [PATCH 01/66] feat(db): add subagent columns and migration v4 Subagent transcripts written by Claude CLI live at //subagents/agent-.jsonl alongside a .meta.json sidecar holding { agentType, description }. Surface them as first-class rows in session_cache, keyed by sessionId 'sub::'. - adds parentSessionId, agentId, subagentType, description columns - migration v4 clears the cache so a re-index repopulates everything - adds idx_session_cache_parent for hierarchy lookups - new query getCachedByParent + widened cacheGetByFolder --- db.js | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/db.js b/db.js index 4fabae4..eaef03e 100644 --- a/db.js +++ b/db.js @@ -49,7 +49,11 @@ db.exec(` modified TEXT, messageCount INTEGER DEFAULT 0, slug TEXT, - aiTitle TEXT + aiTitle TEXT, + parentSessionId TEXT, + agentId TEXT, + subagentType TEXT, + description TEXT ) `); @@ -99,6 +103,20 @@ const migrations = [ try { db.exec('DELETE FROM session_cache'); } catch {} try { db.exec('DELETE FROM cache_meta'); } catch {} }, + // v4: Add subagent columns. Subagent transcripts live under + // //subagents/agent-.jsonl alongside a + // .meta.json sidecar holding { agentType, description }. We surface them as + // first-class rows in session_cache, keyed by sessionId = "sub::". + // Clear cache so subagent rows get picked up on first re-index. + (db) => { + try { db.exec('ALTER TABLE session_cache ADD COLUMN parentSessionId TEXT'); } catch {} + try { db.exec('ALTER TABLE session_cache ADD COLUMN agentId TEXT'); } catch {} + try { db.exec('ALTER TABLE session_cache ADD COLUMN subagentType TEXT'); } catch {} + try { db.exec('ALTER TABLE session_cache ADD COLUMN description TEXT'); } catch {} + try { db.exec('CREATE INDEX IF NOT EXISTS idx_session_cache_parent ON session_cache(parentSessionId)'); } catch {} + try { db.exec('DELETE FROM session_cache'); } catch {} + try { db.exec('DELETE FROM cache_meta'); } catch {} + }, ]; const currentDbVersion = (() => { @@ -152,16 +170,19 @@ 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) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO session_cache (sessionId, folder, projectPath, summary, firstPrompt, created, modified, messageCount, slug, aiTitle, parentSessionId, agentId, subagentType, description) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(sessionId) DO UPDATE SET folder = excluded.folder, projectPath = excluded.projectPath, summary = excluded.summary, firstPrompt = excluded.firstPrompt, created = excluded.created, modified = excluded.modified, messageCount = excluded.messageCount, slug = excluded.slug, - aiTitle = excluded.aiTitle + aiTitle = excluded.aiTitle, + parentSessionId = excluded.parentSessionId, agentId = excluded.agentId, + subagentType = excluded.subagentType, description = excluded.description `), - cacheGetByFolder: db.prepare('SELECT sessionId, modified FROM session_cache WHERE folder = ?'), + cacheGetByParent: db.prepare('SELECT * FROM session_cache WHERE parentSessionId = ? ORDER BY created ASC'), + cacheGetByFolder: db.prepare('SELECT sessionId, modified, parentSessionId, agentId FROM session_cache WHERE folder = ?'), 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 = ?'), @@ -246,11 +267,17 @@ const upsertCachedSessionsBatch = db.transaction((sessions) => { stmts.cacheUpsert.run( s.sessionId, s.folder, s.projectPath, s.summary, s.firstPrompt, s.created, s.modified, s.messageCount || 0, - s.slug || null, s.aiTitle || null + s.slug || null, s.aiTitle || null, + s.parentSessionId || null, s.agentId || null, + s.subagentType || null, s.description || null ); } }); +function getCachedByParent(parentSessionId) { + return stmts.cacheGetByParent.all(parentSessionId); +} + function upsertCachedSessions(sessions) { upsertCachedSessionsBatch(sessions); } @@ -376,7 +403,7 @@ function closeDb() { module.exports = { getMeta, getAllMeta, setName, toggleStar, setArchived, - isCachePopulated, getAllCached, getCachedByFolder, getCachedFolder, getCachedSession, upsertCachedSessions, + isCachePopulated, getAllCached, getCachedByFolder, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions, deleteCachedSession, deleteCachedFolder, getFolderMeta, getAllFolderMeta, setFolderMeta, upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType, From 271381e0c77b58ea21c5043c3eed909d8d0ed398 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:02:34 +0200 Subject: [PATCH 02/66] feat(read-session-file): support subagent layout Adds helpers for the on-disk subagent layout: - enumerateSessionFiles(folderPath) walks top-level + /subagents/*.jsonl + legacy /*.jsonl fallback - subagentSessionId(parent, agentId) gives the synthetic 'sub:

:' id - resolveJsonlPath(projectsDir, row) reconstructs the absolute path - readSubagentMeta reads the .meta.json sidecar readSessionFile now accepts opts.parentSessionId. When set, it requires isSidechain on at least one entry (defensive guard), reads the sidecar for agentType/description, falls back to filename for agentId if absent in entries. Adds 10 unit tests covering both branches, the sidecar guard, the legacy fallback path, and the synthetic-id format. --- read-session-file.js | 133 +++++++++++++++++++- test/read-session-file.test.js | 219 +++++++++++++++++++++++++++++++++ 2 files changed, 347 insertions(+), 5 deletions(-) create mode 100644 test/read-session-file.test.js diff --git a/read-session-file.js b/read-session-file.js index bb42318..f4a533d 100644 --- a/read-session-file.js +++ b/read-session-file.js @@ -1,9 +1,41 @@ const path = require('path'); const fs = require('fs'); -/** Parse a single .jsonl file into a session object (or null if invalid) */ -function readSessionFile(filePath, folder, projectPath) { - const sessionId = path.basename(filePath, '.jsonl'); +/** Subagent transcripts land under //subagents/agent-.jsonl. + * We surface them as first-class rows with a synthetic sessionId so they're addressable + * exactly like top-level sessions (search, archive, rename, etc). + */ +function subagentSessionId(parentSessionId, agentId) { + return `sub:${parentSessionId}:${agentId}`; +} + +/** Resolve the absolute jsonl path for a row from session_cache. + * Works for both top-level sessions and subagents. */ +function resolveJsonlPath(projectsDir, row) { + if (!row || !row.folder) return null; + if (row.parentSessionId && row.agentId) { + return path.join(projectsDir, row.folder, row.parentSessionId, 'subagents', `agent-${row.agentId}.jsonl`); + } + return path.join(projectsDir, row.folder, row.sessionId + '.jsonl'); +} + +/** Read sidecar { agentType, description } if present. */ +function readSubagentMeta(jsonlPath) { + const metaPath = jsonlPath.replace(/\.jsonl$/, '.meta.json'); + try { + return JSON.parse(fs.readFileSync(metaPath, 'utf8')); + } catch { + return null; + } +} + +/** Parse a single .jsonl file into a session object (or null if invalid). + * opts.parentSessionId — if set, treat as a subagent transcript and stamp the + * parent reference into the returned row. + */ +function readSessionFile(filePath, folder, projectPath, opts = {}) { + const fileBase = path.basename(filePath, '.jsonl'); + const isSubagent = Boolean(opts.parentSessionId); try { const stat = fs.statSync(filePath); const content = fs.readFileSync(filePath, 'utf8'); @@ -14,9 +46,13 @@ function readSessionFile(filePath, folder, projectPath) { let slug = null; let customTitle = null; let aiTitle = null; + let agentId = null; + let sidechainSeen = false; for (const line of lines) { const entry = JSON.parse(line); if (entry.slug && !slug) slug = entry.slug; + if (entry.agentId && !agentId) agentId = entry.agentId; + if (entry.isSidechain) sidechainSeen = true; if (entry.type === 'custom-title' && entry.customTitle) { customTitle = entry.customTitle; } @@ -44,8 +80,37 @@ function readSessionFile(filePath, folder, projectPath) { } } if (!summary || messageCount < 1) return null; + + if (isSubagent) { + // Sidechain marker must be present — otherwise the file lives under a + // subagents/ directory but isn't actually a subagent transcript. Bail. + if (!sidechainSeen) return null; + if (!agentId) { + // Fall back to filename: agent-.jsonl + const m = fileBase.match(/^agent-(.+)$/); + if (m) agentId = m[1]; + } + if (!agentId) return null; + const meta = readSubagentMeta(filePath) || {}; + const subagentType = meta.agentType || null; + const description = meta.description || null; + return { + sessionId: subagentSessionId(opts.parentSessionId, agentId), + folder, projectPath, + summary: description || summary, + firstPrompt: summary, + created: stat.birthtime.toISOString(), + modified: stat.mtime.toISOString(), + messageCount, textContent, slug, customTitle, aiTitle, + parentSessionId: opts.parentSessionId, + agentId, + subagentType, + description, + }; + } + return { - sessionId, folder, projectPath, + sessionId: fileBase, folder, projectPath, summary, firstPrompt: summary, created: stat.birthtime.toISOString(), modified: stat.mtime.toISOString(), @@ -56,4 +121,62 @@ function readSessionFile(filePath, folder, projectPath) { } } -module.exports = { readSessionFile }; +/** Enumerate every jsonl in a project folder: top-level sessions plus any + * subagent transcripts under //subagents/*.jsonl + * (or directly under //*.jsonl for legacy layouts). + * Returns [{ filePath, sessionId, parentSessionId|null }]. */ +function enumerateSessionFiles(folderPath) { + const out = []; + let topEntries; + try { + topEntries = fs.readdirSync(folderPath, { withFileTypes: true }); + } catch { return out; } + + // Top-level .jsonl files = ordinary sessions + for (const e of topEntries) { + if (e.isFile() && e.name.endsWith('.jsonl')) { + out.push({ + filePath: path.join(folderPath, e.name), + sessionId: path.basename(e.name, '.jsonl'), + parentSessionId: null, + }); + } + } + + // UUID subdirs may hold subagent transcripts + for (const e of topEntries) { + if (!e.isDirectory()) continue; + const parentSessionId = e.name; + const subDir = path.join(folderPath, parentSessionId); + // Preferred layout: subagents/ subfolder + const subagentsDir = path.join(subDir, 'subagents'); + try { + if (fs.statSync(subagentsDir).isDirectory()) { + for (const f of fs.readdirSync(subagentsDir)) { + if (!f.endsWith('.jsonl')) continue; + out.push({ + filePath: path.join(subagentsDir, f), + sessionId: path.basename(f, '.jsonl'), + parentSessionId, + }); + } + continue; + } + } catch {} + // Fallback: jsonl directly in the UUID dir (older CLI versions) + try { + for (const f of fs.readdirSync(subDir)) { + if (!f.endsWith('.jsonl')) continue; + out.push({ + filePath: path.join(subDir, f), + sessionId: path.basename(f, '.jsonl'), + parentSessionId, + }); + } + } catch {} + } + + return out; +} + +module.exports = { readSessionFile, subagentSessionId, resolveJsonlPath, readSubagentMeta, enumerateSessionFiles }; diff --git a/test/read-session-file.test.js b/test/read-session-file.test.js new file mode 100644 index 0000000..17dbc03 --- /dev/null +++ b/test/read-session-file.test.js @@ -0,0 +1,219 @@ +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, + subagentSessionId, + resolveJsonlPath, + readSubagentMeta, + enumerateSessionFiles, +} = require('../read-session-file'); + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-rsf-')); +} + +function cleanup(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +test('subagentSessionId formats parent and agent ids into the expected colon-delimited string', () => { + assert.equal(subagentSessionId('parent', 'agent'), 'sub:parent:agent'); + const parent = '11111111-2222-3333-4444-555555555555'; + const agent = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + assert.equal(subagentSessionId(parent, agent), `sub:${parent}:${agent}`); + // Round-trip the prefix structure. + const id = subagentSessionId(parent, agent); + const parts = id.split(':'); + assert.equal(parts[0], 'sub'); + assert.equal(parts[1], parent); + assert.equal(parts[2], agent); +}); + +test('resolveJsonlPath returns top-level path when row has no parent/agent', () => { + const projectsDir = '/projects'; + const row = { folder: 'foo', sessionId: 'session-1' }; + assert.equal(resolveJsonlPath(projectsDir, row), path.join('/projects', 'foo', 'session-1.jsonl')); +}); + +test('resolveJsonlPath returns subagent path when parentSessionId and agentId are set', () => { + const projectsDir = '/projects'; + const row = { + folder: 'foo', + sessionId: 'sub:parent-uuid:agent-1', + parentSessionId: 'parent-uuid', + agentId: 'agent-1', + }; + assert.equal( + resolveJsonlPath(projectsDir, row), + path.join('/projects', 'foo', 'parent-uuid', 'subagents', 'agent-agent-1.jsonl') + ); +}); + +test('readSubagentMeta reads sibling .meta.json when present and returns null when missing', () => { + const tmp = mkTmp(); + try { + const jsonlPath = path.join(tmp, 'agent-x.jsonl'); + const metaPath = path.join(tmp, 'agent-x.meta.json'); + fs.writeFileSync(metaPath, JSON.stringify({ agentType: 'Explore', description: 'find things' }), 'utf8'); + const meta = readSubagentMeta(jsonlPath); + assert.deepEqual(meta, { agentType: 'Explore', description: 'find things' }); + + const missing = readSubagentMeta(path.join(tmp, 'does-not-exist.jsonl')); + assert.equal(missing, null); + } finally { + cleanup(tmp); + } +}); + +test('enumerateSessionFiles discovers top-level sessions plus subagents and ignores junk', () => { + const tmp = mkTmp(); + try { + // Top-level session + fs.writeFileSync(path.join(tmp, 'aaa.jsonl'), '', 'utf8'); + // Subagents under preferred layout + const bbbSubagents = path.join(tmp, 'bbb', 'subagents'); + fs.mkdirSync(bbbSubagents, { recursive: true }); + fs.writeFileSync(path.join(bbbSubagents, 'agent-1.jsonl'), '', 'utf8'); + fs.writeFileSync(path.join(bbbSubagents, 'agent-2.jsonl'), '', 'utf8'); + // Fallback layout: jsonl directly in uuid dir + const cccDir = path.join(tmp, 'ccc'); + fs.mkdirSync(cccDir); + fs.writeFileSync(path.join(cccDir, 'legacy.jsonl'), '', 'utf8'); + // Junk that must be ignored + fs.writeFileSync(path.join(tmp, 'not-a-jsonl.txt'), 'noise', 'utf8'); + fs.writeFileSync(path.join(tmp, 'bbb', 'random-file'), 'noise', 'utf8'); + + const entries = enumerateSessionFiles(tmp); + assert.equal(entries.length, 4, `expected 4 entries, got ${entries.length}: ${JSON.stringify(entries)}`); + + const byId = new Map(entries.map(e => [e.sessionId, e])); + + const top = byId.get('aaa'); + assert.ok(top, 'expected aaa top-level entry'); + assert.equal(top.parentSessionId, null); + assert.equal(top.filePath, path.join(tmp, 'aaa.jsonl')); + + const a1 = byId.get('agent-1'); + assert.ok(a1, 'expected agent-1 subagent entry'); + assert.equal(a1.parentSessionId, 'bbb'); + assert.equal(a1.filePath, path.join(bbbSubagents, 'agent-1.jsonl')); + + const a2 = byId.get('agent-2'); + assert.ok(a2, 'expected agent-2 subagent entry'); + assert.equal(a2.parentSessionId, 'bbb'); + + const legacy = byId.get('legacy'); + assert.ok(legacy, 'expected legacy fallback entry'); + assert.equal(legacy.parentSessionId, 'ccc'); + assert.equal(legacy.filePath, path.join(cccDir, 'legacy.jsonl')); + } finally { + cleanup(tmp); + } +}); + +test('readSessionFile returns a top-level row when called without opts', () => { + const tmp = mkTmp(); + try { + const sessionId = 'plain-session'; + const filePath = path.join(tmp, `${sessionId}.jsonl`); + fs.writeFileSync( + filePath, + JSON.stringify({ type: 'user', message: 'hello world' }) + '\n', + 'utf8' + ); + + const row = readSessionFile(filePath, 'folder-x', '/some/project'); + assert.ok(row, 'expected a row'); + assert.equal(row.sessionId, sessionId); + assert.equal(row.folder, 'folder-x'); + assert.equal(row.projectPath, '/some/project'); + assert.equal(row.summary, 'hello world'); + assert.equal(row.messageCount, 1); + assert.equal(row.parentSessionId, undefined); + assert.equal(row.agentId, undefined); + } finally { + cleanup(tmp); + } +}); + +test('readSessionFile (subagent) returns synthetic id plus parent/agent metadata when sidechain present', () => { + const tmp = mkTmp(); + try { + const parentSessionId = 'parent-uuid-abc'; + const agentId = 'abc123'; + const subagentsDir = path.join(tmp, parentSessionId, 'subagents'); + fs.mkdirSync(subagentsDir, { recursive: true }); + const filePath = path.join(subagentsDir, `agent-${agentId}.jsonl`); + const metaPath = path.join(subagentsDir, `agent-${agentId}.meta.json`); + + fs.writeFileSync( + filePath, + JSON.stringify({ + type: 'user', + message: 'first prompt to subagent', + isSidechain: true, + agentId, + }) + '\n', + 'utf8' + ); + fs.writeFileSync( + metaPath, + JSON.stringify({ agentType: 'Explore', description: 'do a thing' }), + 'utf8' + ); + + const row = readSessionFile(filePath, 'folder-x', '/some/project', { parentSessionId }); + assert.ok(row, 'expected a subagent row'); + assert.equal(row.sessionId, `sub:${parentSessionId}:${agentId}`); + assert.equal(row.parentSessionId, parentSessionId); + assert.equal(row.agentId, agentId); + assert.equal(row.subagentType, 'Explore'); + assert.equal(row.description, 'do a thing'); + // summary prefers description when meta provides one + assert.equal(row.summary, 'do a thing'); + // firstPrompt holds the actual first user text + assert.equal(row.firstPrompt, 'first prompt to subagent'); + } finally { + cleanup(tmp); + } +}); + +test('readSessionFile (subagent) returns null when the file is not actually a sidechain', () => { + const tmp = mkTmp(); + try { + const filePath = path.join(tmp, 'agent-foo.jsonl'); + fs.writeFileSync( + filePath, + JSON.stringify({ type: 'user', message: 'looks normal', agentId: 'foo' }) + '\n', + 'utf8' + ); + + const row = readSessionFile(filePath, 'folder-x', '/p', { parentSessionId: 'parent' }); + assert.equal(row, null); + } finally { + cleanup(tmp); + } +}); + +test('readSessionFile (subagent) falls back to filename when agentId is absent in jsonl entries', () => { + const tmp = mkTmp(); + try { + const filePath = path.join(tmp, 'agent-fallback.jsonl'); + fs.writeFileSync( + filePath, + JSON.stringify({ type: 'user', message: 'prompt', isSidechain: true }) + '\n', + 'utf8' + ); + + const row = readSessionFile(filePath, 'folder-x', '/p', { parentSessionId: 'parent-1' }); + assert.ok(row, 'expected a row even without inline agentId'); + assert.equal(row.agentId, 'fallback'); + assert.equal(row.sessionId, 'sub:parent-1:fallback'); + } finally { + cleanup(tmp); + } +}); From 9e36ba23614adbdcceda6ee18fdd9860868d29c2 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:02:40 +0200 Subject: [PATCH 03/66] feat(scan): index subagent transcripts in session cache Both scanners (synchronous refreshFolder + worker scan-projects) now use enumerateSessionFiles instead of a flat top-level readdir, so subagent transcripts get cached as first-class rows with parentSessionId set. FTS indexing inherits this for free: subagent summaries land in search_fts the same way top-level sessions do, with type='session' and folder=. Full-text search now finds anything an Agent call discussed. --- session-cache.js | 59 +++++++++++++++++++++++----------------- workers/scan-projects.js | 13 ++++----- 2 files changed, 39 insertions(+), 33 deletions(-) diff --git a/session-cache.js b/session-cache.js index f066004..bd46019 100644 --- a/session-cache.js +++ b/session-cache.js @@ -3,7 +3,7 @@ const fs = require('fs'); const { Worker } = require('worker_threads'); const { getFolderIndexMtimeMs } = require('./folder-index-state'); const { deriveProjectPath } = require('./derive-project-path'); -const { readSessionFile } = require('./read-session-file'); +const { readSessionFile, enumerateSessionFiles, resolveJsonlPath } = require('./read-session-file'); const { encodeProjectPath } = require('./encode-project-path'); /** @@ -46,13 +46,10 @@ function readFolderFromFilesystem(folder) { if (!projectPath) return { projectPath: null, sessions: [] }; const sessions = []; - try { - const jsonlFiles = fs.readdirSync(folderPath).filter(f => f.endsWith('.jsonl')); - for (const file of jsonlFiles) { - const s = readSessionFile(path.join(folderPath, file), folder, projectPath); - if (s) sessions.push(s); - } - } catch {} + for (const { filePath, parentSessionId } of enumerateSessionFiles(folderPath)) { + const s = readSessionFile(filePath, folder, projectPath, { parentSessionId }); + if (s) sessions.push(s); + } return { projectPath, sessions }; } @@ -71,19 +68,18 @@ function refreshFolder(folder) { return; } - // Get what's currently cached for this folder + // Get what's currently cached for this folder. + // cachedMap: DB sessionId → { modified, filePath } so we can do mtime comparison + // even for subagents whose DB sessionId differs from the on-disk filename. const cachedSessions = getCachedByFolder(folder); - const cachedMap = new Map(); // sessionId → modified ISO string + const cachedMap = new Map(); // DB sessionId → { modified, filePath } for (const row of cachedSessions) { - cachedMap.set(row.sessionId, row.modified); + cachedMap.set(row.sessionId, { + modified: row.modified, + filePath: resolveJsonlPath(PROJECTS_DIR, row), + }); } - // Scan current .jsonl files - let jsonlFiles; - try { - jsonlFiles = fs.readdirSync(folderPath).filter(f => f.endsWith('.jsonl')); - } catch { return; } - const currentIds = new Set(); let changed = false; @@ -93,22 +89,35 @@ function refreshFolder(folder) { const namesToSet = []; const sessionsToDelete = []; - for (const file of jsonlFiles) { - const filePath = path.join(folderPath, file); - const sessionId = path.basename(file, '.jsonl'); - currentIds.add(sessionId); - - // Check if file mtime changed + for (const { filePath, parentSessionId } of enumerateSessionFiles(folderPath)) { + // Check if file mtime changed. + // We need the DB sessionId to look up the cache, but we don't know it until after + // readSessionFile — for subagents it's sub::. Use the file path + // to find a matching cached entry instead. let fileMtime; try { fileMtime = fs.statSync(filePath).mtime.toISOString(); } catch { continue; } - if (cachedMap.has(sessionId) && cachedMap.get(sessionId) === fileMtime) { + // Find cached entry by file path (handles both top-level and subagent IDs) + let cachedEntry = null; + let cachedDbId = null; + for (const [dbId, entry] of cachedMap) { + if (entry.filePath === filePath) { + cachedEntry = entry; + cachedDbId = dbId; + break; + } + } + + if (cachedDbId !== null) currentIds.add(cachedDbId); + + if (cachedEntry && cachedEntry.modified === fileMtime) { continue; // unchanged, skip } // File is new or modified — re-read it - const s = readSessionFile(filePath, folder, projectPath); + const s = readSessionFile(filePath, folder, projectPath, { parentSessionId }); if (s) { + currentIds.add(s.sessionId); // ensure we don't delete a newly-read subagent row sessionsToUpsert.push(s); // Title precedence: user rename (session_meta.name) > JSONL custom-title > JSONL ai-title. // Only customTitle (Claude /title) promotes to session_meta.name — AI titles must NEVER diff --git a/workers/scan-projects.js b/workers/scan-projects.js index 62bd73a..be3d530 100644 --- a/workers/scan-projects.js +++ b/workers/scan-projects.js @@ -3,7 +3,7 @@ const fs = require('fs'); const path = require('path'); const { getFolderIndexMtimeMs } = require('../folder-index-state'); const { deriveProjectPath } = require('../derive-project-path'); -const { readSessionFile } = require('../read-session-file'); +const { readSessionFile, enumerateSessionFiles } = require('../read-session-file'); const PROJECTS_DIR = workerData.projectsDir; @@ -14,13 +14,10 @@ function readFolderFromFilesystem(folder) { const sessions = []; const indexMtimeMs = getFolderIndexMtimeMs(folderPath); - try { - const jsonlFiles = fs.readdirSync(folderPath).filter(f => f.endsWith('.jsonl')); - for (const file of jsonlFiles) { - const s = readSessionFile(path.join(folderPath, file), folder, projectPath); - if (s) sessions.push(s); - } - } catch {} + for (const { filePath, parentSessionId } of enumerateSessionFiles(folderPath)) { + const s = readSessionFile(filePath, folder, projectPath, { parentSessionId }); + if (s) sessions.push(s); + } return { folder, projectPath, sessions, indexMtimeMs }; } From 2fee16fe8213a24848fd3a776b1e0292c7858607 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:02:47 +0200 Subject: [PATCH 04/66] feat(viewer): expand subagent transcripts inline via Agent block Clicking an Agent tool_use block in the JSONL viewer now loads the matching subagent transcript and renders it nested below the block. Re-click collapses without refetching. - main.js: new IPC read-subagent-jsonl(parentSessionId, agentId) and list-subagents(parentSessionId) - preload.js: window.api.readSubagentJsonl / listSubagents - jsonl-viewer.js: clickable Agent block, caret indicator, recursive nested render. Identical (description, subagentType) pairs are disambiguated by occurrence ordinal so parallel fanout calls each open their own transcript. - style.css: subtle hover + left-border indent for nested transcripts --- main.js | 30 ++++++++++++++- preload.js | 2 + public/jsonl-viewer.js | 86 ++++++++++++++++++++++++++++++++++++++++-- public/style.css | 23 +++++++++++ 4 files changed, 137 insertions(+), 4 deletions(-) diff --git a/main.js b/main.js index 2c587b7..8a89337 100644 --- a/main.js +++ b/main.js @@ -61,7 +61,7 @@ if (app.isPackaged || process.env.FORCE_UPDATER) { } const { getMeta, getAllMeta, toggleStar, setName, setArchived, - isCachePopulated, getAllCached, getCachedByFolder, getCachedFolder, getCachedSession, upsertCachedSessions, + isCachePopulated, getAllCached, getCachedByFolder, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions, deleteCachedSession, deleteCachedFolder, getFolderMeta, getAllFolderMeta, setFolderMeta, upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType, @@ -907,6 +907,34 @@ ipcMain.handle('read-session-jsonl', (_event, sessionId) => { } }); +ipcMain.handle('read-subagent-jsonl', (_event, parentSessionId, agentId) => { + const row = getCachedSession('sub:' + parentSessionId + ':' + agentId); + if (!row) return { error: 'Subagent session not found in cache' }; + const jsonlPath = path.join(PROJECTS_DIR, row.folder, parentSessionId, 'subagents', 'agent-' + agentId + '.jsonl'); + try { + const content = fs.readFileSync(jsonlPath, 'utf-8'); + const entries = []; + for (const line of content.split('\n')) { + if (!line.trim()) continue; + try { entries.push(JSON.parse(line)); } catch {} + } + return { entries }; + } catch (err) { + return { error: err.message }; + } +}); + +ipcMain.handle('list-subagents', (_event, parentSessionId) => { + return getCachedByParent(parentSessionId).map(r => ({ + sessionId: r.sessionId, + agentId: r.agentId, + subagentType: r.subagentType, + description: r.description, + modified: r.modified, + messageCount: r.messageCount, + })); +}); + ipcMain.handle('archive-session', (_event, sessionId, archived) => { const val = archived ? 1 : 0; setArchived(sessionId, val); diff --git a/preload.js b/preload.js index 91d8b5e..252d286 100644 --- a/preload.js +++ b/preload.js @@ -21,6 +21,8 @@ contextBridge.exposeInMainWorld('api', { openTerminal: (id, projectPath, isNew, sessionOptions) => ipcRenderer.invoke('open-terminal', id, projectPath, isNew, sessionOptions), search: (type, query, titleOnly) => ipcRenderer.invoke('search', type, query, titleOnly), readSessionJsonl: (sessionId) => ipcRenderer.invoke('read-session-jsonl', sessionId), + readSubagentJsonl: (parentSessionId, agentId) => ipcRenderer.invoke('read-subagent-jsonl', parentSessionId, agentId), + listSubagents: (parentSessionId) => ipcRenderer.invoke('list-subagents', parentSessionId), // Settings getSetting: (key) => ipcRenderer.invoke('get-setting', key), diff --git a/public/jsonl-viewer.js b/public/jsonl-viewer.js index 9c28bab..ffc3593 100644 --- a/public/jsonl-viewer.js +++ b/public/jsonl-viewer.js @@ -2,6 +2,12 @@ // Depends on globals: escapeHtml (utils.js), hideAllViewers, placeholder, // terminalArea, jsonlViewer, jsonlViewerTitle, jsonlViewerSessionId, jsonlViewerBody (app.js) +// Current viewer session — set once per showJsonlViewer call, read by Agent renderer +let currentViewerSessionId = null; +// Counter for matching identical (description, subagentType) blocks in fanout scenarios +// Reset on each showJsonlViewer call. Key: "||" +let agentMatchCounters = {}; + function renderJsonlText(text) { if (window.marked) { // Escape XML/HTML-like tags so they render as visible text, @@ -211,12 +217,82 @@ const toolRenderers = { return toolBlock('#c090e0', 'Glob', '' + escapeHtml(pattern) + '', null); }, - Agent(input) { + Agent(input, block) { const desc = input.description || ''; const type = input.subagent_type || ''; - const summary = (type ? '' + escapeHtml(type) + ' ' : '') + const caretSpan = ' '; + const summary = caretSpan + + (type ? '' + escapeHtml(type) + ' ' : '') + escapeHtml(desc); - return toolBlock('#f0a050', 'Agent', summary, null); + const el = toolBlock('#f0a050', 'Agent', summary, null); + el.classList.add('jsonl-agent-expandable'); + // Capture context at render time + const parentSessionId = currentViewerSessionId; + if (!parentSessionId) return el; + + // Determine which Nth match this block is for fanout deduplication + const counterKey = parentSessionId + '|' + desc + '|' + type; + if (agentMatchCounters[counterKey] === undefined) agentMatchCounters[counterKey] = 0; + const matchIndex = agentMatchCounters[counterKey]++; + + let expanded = false; + let nestedContainer = null; + + el.addEventListener('click', async () => { + if (expanded && nestedContainer) { + // Collapse + nestedContainer.remove(); + nestedContainer = null; + expanded = false; + const caret = el.querySelector('.jsonl-agent-caret'); + if (caret) caret.innerHTML = '►'; + return; + } + // Fetch subagent list for this parent + const subagents = await window.api.listSubagents(parentSessionId); + const matches = subagents.filter(s => + (s.description || '') === desc && (s.subagentType || '') === type + ); + const match = matches[matchIndex] || matches[0]; + if (!match) return; + + const result = await window.api.readSubagentJsonl(parentSessionId, match.agentId); + if (result.error || !result.entries) return; + + nestedContainer = document.createElement('div'); + nestedContainer.className = 'jsonl-subagent-nested'; + + const subSessionId = match.sessionId; + const rawNested = result.entries; + const nestedEntries = mergeLocalCommandEntries(rawNested); + + // Build tool result map for nested entries + const nestedResultMap = new Map(); + for (const entry of nestedEntries) { + const blocks = entry.message?.content || entry.content; + if (!Array.isArray(blocks)) continue; + for (const b of blocks) { + if (b.type === 'tool_result' && b.tool_use_id) { + nestedResultMap.set(b.tool_use_id, b.content || b.output || ''); + } + } + } + + const prevSessionId = currentViewerSessionId; + currentViewerSessionId = subSessionId; + for (const entry of nestedEntries) { + const entryEl = renderJsonlEntry(entry, nestedResultMap); + if (entryEl) nestedContainer.appendChild(entryEl); + } + currentViewerSessionId = prevSessionId; + + el.after(nestedContainer); + expanded = true; + const caret = el.querySelector('.jsonl-agent-caret'); + if (caret) caret.innerHTML = '▼'; + }); + + return el; }, }; @@ -559,6 +635,10 @@ async function showJsonlViewer(session) { terminalArea.style.display = 'none'; jsonlViewer.style.display = 'flex'; + // Set viewer context for Agent block expansion + currentViewerSessionId = session.sessionId; + agentMatchCounters = {}; + const displayName = session.name || session.aiTitle || session.summary || session.sessionId; jsonlViewerTitle.textContent = displayName; jsonlViewerSessionId.textContent = session.sessionId; diff --git a/public/style.css b/public/style.css index f6070c0..a50b1e0 100644 --- a/public/style.css +++ b/public/style.css @@ -2338,6 +2338,29 @@ body { display: flex; flex-direction: column; } .jsonl-tool-diff::-webkit-scrollbar-thumb, .jsonl-tool-content::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 2px; } +.jsonl-agent-expandable { + cursor: pointer; + transition: background 0.1s; +} +.jsonl-agent-expandable:hover { + background: rgba(240, 160, 80, 0.07); + border-radius: 4px; +} +.jsonl-agent-caret { + display: inline-block; + font-family: monospace; + font-size: 0.8em; + margin-right: 3px; + user-select: none; +} +.jsonl-subagent-nested { + margin-left: 1.5em; + padding-left: 0.8em; + border-left: 2px solid rgba(240, 160, 80, 0.4); + margin-top: 2px; + margin-bottom: 2px; +} + .jsonl-toggle { font-size: 11px; font-weight: 500; From 249749ef5fd168484e8dc44aa6f840f32e8db4f9 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:08:33 +0200 Subject: [PATCH 05/66] ci: add github actions workflow for npm test Runs `npm ci && npm test` on ubuntu-latest against Node 20 and 22. Triggers on pull requests targeting main and on direct pushes to main. --- .github/workflows/test.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..5e29675 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: Test + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20, 22] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test From 078e8cf0fa734413a1f65226b12e2cacdf398ba4 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:26:02 +0200 Subject: [PATCH 06/66] fix(get-projects): await scan when cache is empty post-migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrations that clear session_cache (v2, v3, v4) leave the cache empty on first launch. The previous get-projects handler returned [] immediately and fire-and-forgot the worker scan, relying on a later 'projects-changed' IPC to trigger a reload — but app.js only reloads on that event when the user happens to be on the Sessions tab. If they were on another tab (or hadn't navigated to Sessions yet), the list stayed empty until they manually switched tabs. Make populateCacheViaWorker return a Promise that resolves when the scan finishes. Concurrent callers share the same Promise. get-projects awaits that Promise when the cache is empty, so the response carries the freshly populated cache and the renderer paints immediately. --- main.js | 11 ++++++++--- session-cache.js | 37 +++++++++++++++++++++++-------------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/main.js b/main.js index 8a89337..2040f40 100644 --- a/main.js +++ b/main.js @@ -407,13 +407,18 @@ ipcMain.handle('unwatch-file', (_event, filePath) => { return { ok: true }; }); -ipcMain.handle('get-projects', (_event, showArchived) => { +ipcMain.handle('get-projects', async (_event, showArchived) => { try { const needsPopulate = !isCachePopulated() || !isSearchIndexPopulated(); if (needsPopulate) { - populateCacheViaWorker(); - return []; + // First call after a migration that clears session_cache (e.g. v4) finds + // an empty cache. Returning [] immediately makes the renderer paint an + // empty list and rely on `notifyRendererProjectsChanged` firing later — + // which only triggers a reload if the user is on the Sessions tab. To + // avoid that race, await the scan here so the response carries the + // freshly-populated cache. Concurrent callers share the same Promise. + await populateCacheViaWorker(); } return buildProjectsFromCache(showArchived); diff --git a/session-cache.js b/session-cache.js index bd46019..dd90faa 100644 --- a/session-cache.js +++ b/session-cache.js @@ -306,14 +306,25 @@ function sendStatus(text, type) { } } -// --- Worker-based cache population (non-blocking) --- -let populatingCache = false; +// --- Worker-based cache population --- +// Returns a Promise that resolves when the in-flight scan finishes. Concurrent +// callers share the same Promise so the first get-projects after a migration +// can await it instead of seeing an empty list. +let populatePromise = null; function populateCacheViaWorker() { - if (populatingCache) return; - populatingCache = true; + if (populatePromise) return populatePromise; sendStatus('Scanning projects\u2026', 'active'); + populatePromise = new Promise((resolve) => { + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + populatePromise = null; + resolve(); + }; + const worker = new Worker(path.join(__dirname, 'workers', 'scan-projects.js'), { workerData: { projectsDir: PROJECTS_DIR }, }); @@ -328,7 +339,7 @@ function populateCacheViaWorker() { if (!msg.ok) { console.error('Worker scan error:', msg.error); sendStatus('Scan failed: ' + msg.error, 'error'); - populatingCache = false; + settle(); return; } @@ -360,30 +371,28 @@ function populateCacheViaWorker() { setFolderMeta(folder, projectPath, indexMtimeMs); } - populatingCache = false; sendStatus(`Indexed ${sessionCount} sessions across ${msg.results.length} projects`, 'done'); // Clear status after a few seconds setTimeout(() => sendStatus(''), 5000); notifyRendererProjectsChanged(); + settle(); }); worker.on('error', (err) => { console.error('Worker error:', err); sendStatus('Worker error: ' + err.message, 'error'); - populatingCache = false; + settle(); }); // If the worker exits abnormally (SIGSEGV, OOM, uncaught exception) without // sending a message, neither the 'message' nor 'error' handler will fire. - // Reset the flag here to prevent a permanent lockout where the session list - // stays empty because populateCacheViaWorker() returns immediately. + // Resolve here so awaiters aren't stuck forever and the next call can retry. worker.on('exit', (code) => { - if (populatingCache) { - populatingCache = false; - if (code !== 0) { - sendStatus('Scan worker exited unexpectedly', 'error'); - } + if (!settled && code !== 0) { + sendStatus('Scan worker exited unexpectedly', 'error'); } + settle(); + }); }); } From a9357e556a0350d11b0d40e0816b70219ebecda0 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:31:53 +0200 Subject: [PATCH 07/66] fix(projects-payload): expose subagent fields to the renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildProjectsFromCache was projecting only the pre-PR#1 columns onto the session object sent to the renderer. The renderer's hierarchical sidebar (introduced in PR#2) reads parentSessionId/agentId/subagentType/description from each session and silently flattens to top-level when they're missing — which is what happened: all subagents appeared as siblings of their parent instead of nested under it. Add the four subagent columns to the projection. No DB or wire change. --- session-cache.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/session-cache.js b/session-cache.js index dd90faa..7d55ed4 100644 --- a/session-cache.js +++ b/session-cache.js @@ -206,6 +206,10 @@ function buildProjectsFromCache(showArchived) { projectPath: row.projectPath, slug: row.slug || null, aiTitle: row.aiTitle || null, + parentSessionId: row.parentSessionId || null, + agentId: row.agentId || null, + subagentType: row.subagentType || null, + description: row.description || null, name: meta?.name || null, starred: meta?.starred || 0, archived: meta?.archived || 0, From 2fd730fb3f468037d37599eb9e581d4f7dae9127 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:58:14 +0200 Subject: [PATCH 08/66] fix(read-session-file): tolerate concurrent writes to JSONL files readSessionFile JSON.parse'd every line of a session file in a single outer try/catch. If a Claude CLI session was actively writing the file while the worker scan read it, one mid-write line could throw and invalidate the ENTIRE file's session row. With many parallel live sessions this manifested as 'most projects show no sessions after a fresh index'. - Per-line try/catch inside readSessionFile: skip malformed lines, keep parsing the rest. - Per-file try/catch in workers/scan-projects.js: defensive belt and braces so one unparseable file can't abort an entire folder scan. --- read-session-file.js | 7 ++++++- workers/scan-projects.js | 6 ++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/read-session-file.js b/read-session-file.js index f4a533d..d304d16 100644 --- a/read-session-file.js +++ b/read-session-file.js @@ -49,7 +49,12 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { let agentId = null; let sidechainSeen = false; for (const line of lines) { - const entry = JSON.parse(line); + // Per-line try/catch: a JSONL file being written concurrently by a live + // Claude CLI session can have its tail captured mid-write — one truncated + // line should not invalidate the whole file. Skip the malformed line and + // keep parsing. + let entry; + try { entry = JSON.parse(line); } catch { continue; } if (entry.slug && !slug) slug = entry.slug; if (entry.agentId && !agentId) agentId = entry.agentId; if (entry.isSidechain) sidechainSeen = true; diff --git a/workers/scan-projects.js b/workers/scan-projects.js index be3d530..2e37666 100644 --- a/workers/scan-projects.js +++ b/workers/scan-projects.js @@ -15,8 +15,10 @@ function readFolderFromFilesystem(folder) { const indexMtimeMs = getFolderIndexMtimeMs(folderPath); for (const { filePath, parentSessionId } of enumerateSessionFiles(folderPath)) { - const s = readSessionFile(filePath, folder, projectPath, { parentSessionId }); - if (s) sessions.push(s); + try { + const s = readSessionFile(filePath, folder, projectPath, { parentSessionId }); + if (s) sessions.push(s); + } catch {} } return { folder, projectPath, sessions, indexMtimeMs }; From 46d596c83a107f01bfcacf1c9129aa9370e9b5e4 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 09:28:17 +0200 Subject: [PATCH 09/66] fix(derive-project-path): collapse worktree cwd to parent project resolveWorktreePath was defined and exported in the file but never actually called from deriveProjectPath. As a result every worktree under /.claude/worktrees// appeared as a separate project group in the sidebar instead of being grouped under its parent project. Wire the call in both branches of deriveProjectPath (direct .jsonl path and subdirectory path) and export resolveWorktreePath for other callers. --- derive-project-path.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/derive-project-path.js b/derive-project-path.js index f563e35..829da5e 100644 --- a/derive-project-path.js +++ b/derive-project-path.js @@ -33,7 +33,7 @@ function deriveProjectPath(folderPath) { for (const e of entries) { if (e.isFile() && e.name.endsWith('.jsonl')) { const cwd = extractCwdFromJsonl(path.join(folderPath, e.name)); - if (cwd) return cwd; + if (cwd) return resolveWorktreePath(cwd); } } // Check session subdirectories (UUID folders with subagent .jsonl files) @@ -52,7 +52,7 @@ function deriveProjectPath(folderPath) { } if (jsonlPath) { const cwd = extractCwdFromJsonl(jsonlPath); - if (cwd) return cwd; + if (cwd) return resolveWorktreePath(cwd); } } } catch {} @@ -61,4 +61,4 @@ function deriveProjectPath(folderPath) { return null; } -module.exports = { deriveProjectPath }; +module.exports = { deriveProjectPath, resolveWorktreePath }; From 3d821b0861d558598e63633559ca16dc6b5cdb87 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:10:08 +0200 Subject: [PATCH 10/66] feat(transitions): detect subagent spawn/completion + live-tail IPC session-transitions.js now tracks per-active-session knownSubagents and emits two new IPC events: - subagent-spawned (parentSessionId, agentId, subagentType, description) when a new agent-*.jsonl file appears under /subagents/ - subagent-completed (parentSessionId, agentId) when an existing file's mtime has been stable for >30s Adds two IPCs for read-only live tailing: - start-subagent-watch (parent, agentId) -> watchId - stop-subagent-watch (watchId) The watcher streams new JSONL entries via subagent-watch-event so the renderer can append them to an open inline-expanded subagent transcript without polling. --- main.js | 60 +++++++++++++++++++++++++++ preload.js | 5 +++ session-transitions.js | 92 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 156 insertions(+), 1 deletion(-) diff --git a/main.js b/main.js index 2040f40..6fbef20 100644 --- a/main.js +++ b/main.js @@ -80,6 +80,10 @@ const MAX_BUFFER_SIZE = 256 * 1024; const activeSessions = new Map(); let mainWindow = null; +// Subagent live-tail watchers (watchId → { filePath, parentSessionId, agentId }) +const subagentWatchers = new Map(); +let subagentWatcherSeq = 0; + function createWindow() { // Restore saved window bounds const savedBounds = getSetting('global')?.windowBounds; @@ -202,6 +206,11 @@ function createWindow() { } activeSessions.delete(id); } + // Release all subagent file watchers + for (const [, entry] of subagentWatchers) { + try { fs.unwatchFile(entry.filePath); } catch {} + } + subagentWatchers.clear(); mainWindow = null; }); } @@ -940,6 +949,57 @@ ipcMain.handle('list-subagents', (_event, parentSessionId) => { })); }); +// ── Subagent live-tail watchers ────────────────────────────────────────────── + +ipcMain.handle('start-subagent-watch', (_event, parentSessionId, agentId) => { + const row = getCachedSession('sub:' + parentSessionId + ':' + agentId); + if (!row) return { error: 'Subagent not found in cache' }; + const filePath = path.join(PROJECTS_DIR, row.folder, parentSessionId, 'subagents', 'agent-' + agentId + '.jsonl'); + + const watchId = ++subagentWatcherSeq; + let offset = 0; + // Seek to EOF so we only deliver *new* lines + try { offset = fs.statSync(filePath).size; } catch {} + + function readNewEntries() { + try { + const stat = fs.statSync(filePath); + if (stat.size <= offset) return; + const buf = Buffer.alloc(stat.size - offset); + const fd = fs.openSync(filePath, 'r'); + const bytesRead = fs.readSync(fd, buf, 0, buf.length, offset); + fs.closeSync(fd); + if (bytesRead <= 0) return; + offset += bytesRead; + const text = buf.toString('utf8', 0, bytesRead); + const entries = []; + for (const line of text.split('\n')) { + if (!line.trim()) continue; + try { entries.push(JSON.parse(line)); } catch {} + } + if (entries.length > 0 && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('subagent-watch-event', { parentSessionId, agentId, entries }); + } + } catch {} + } + + // fs.watchFile gives reliable polling on Linux where inotify can be unreliable for JSONL appends + fs.watchFile(filePath, { interval: 1000, persistent: false }, readNewEntries); + + subagentWatchers.set(watchId, { filePath, parentSessionId, agentId }); + log.info(`[subagent-watch] start watchId=${watchId} parent=${parentSessionId} agentId=${agentId}`); + return { watchId }; +}); + +ipcMain.handle('stop-subagent-watch', (_event, watchId) => { + const entry = subagentWatchers.get(watchId); + if (!entry) return { ok: false }; + fs.unwatchFile(entry.filePath); + subagentWatchers.delete(watchId); + log.info(`[subagent-watch] stop watchId=${watchId}`); + return { ok: true }; +}); + ipcMain.handle('archive-session', (_event, sessionId, archived) => { const val = archived ? 1 : 0; setArchived(sessionId, val); diff --git a/preload.js b/preload.js index 252d286..347ef26 100644 --- a/preload.js +++ b/preload.js @@ -23,6 +23,8 @@ contextBridge.exposeInMainWorld('api', { readSessionJsonl: (sessionId) => ipcRenderer.invoke('read-session-jsonl', sessionId), readSubagentJsonl: (parentSessionId, agentId) => ipcRenderer.invoke('read-subagent-jsonl', parentSessionId, agentId), listSubagents: (parentSessionId) => ipcRenderer.invoke('list-subagents', parentSessionId), + startSubagentWatch: (parentSessionId, agentId) => ipcRenderer.invoke('start-subagent-watch', parentSessionId, agentId), + stopSubagentWatch: (watchId) => ipcRenderer.invoke('stop-subagent-watch', watchId), // Settings getSetting: (key) => ipcRenderer.invoke('get-setting', key), @@ -63,6 +65,9 @@ contextBridge.exposeInMainWorld('api', { onSessionForked: (callback) => { ipcRenderer.on('session-forked', (_event, oldId, newId) => callback(oldId, newId)); }, + onSubagentSpawned: (cb) => ipcRenderer.on('subagent-spawned', (_e, payload) => cb(payload)), + onSubagentCompleted: (cb) => ipcRenderer.on('subagent-completed', (_e, payload) => cb(payload)), + onSubagentWatchEvent: (cb) => ipcRenderer.on('subagent-watch-event', (_e, payload) => cb(payload)), onProjectsChanged: (callback) => { ipcRenderer.on('projects-changed', () => callback()); }, diff --git a/session-transitions.js b/session-transitions.js index ef9f25a..54c49bf 100644 --- a/session-transitions.js +++ b/session-transitions.js @@ -1,5 +1,6 @@ const path = require('path'); const fs = require('fs'); +const { readSubagentMeta } = require('./read-session-file'); /** * Fork / plan-accept detection for active PTY sessions. @@ -15,6 +16,89 @@ function init(ctx) { rekeyMcpServer = ctx.rekeyMcpServer; } +// --- Subagent spawn / completion detection --- + +/** Walk //subagents/ and detect new or completed subagent files. + * Mutates session.knownSubagents (Map). + * Emits IPC 'subagent-spawned' and 'subagent-completed' via mainWindow. */ +function detectSubagentTransitions(sessionId, session, folderPath) { + const subagentsDir = path.join(folderPath, sessionId, 'subagents'); + let files; + try { + files = fs.readdirSync(subagentsDir).filter(f => f.endsWith('.jsonl')); + } catch { + return; // directory doesn't exist yet — normal + } + + if (!session.knownSubagents) { + session.knownSubagents = new Map(); + } + + const mainWindow = getMainWindow(); + const now = Date.now(); + const STABLE_MS = 30000; // 30 seconds of no mtime advance → completed + + for (const file of files) { + // agent-.jsonl + const m = file.match(/^agent-(.+)\.jsonl$/); + if (!m) continue; + const agentId = m[1]; + const filePath = path.join(subagentsDir, file); + + let stat; + try { stat = fs.statSync(filePath); } catch { continue; } + const mtimeMs = stat.mtimeMs; + + const known = session.knownSubagents.get(agentId); + + if (!known) { + // First sighting — emit subagent-spawned + const meta = readSubagentMeta(filePath) || {}; + session.knownSubagents.set(agentId, { mtimeMs, completed: false }); + log.info(`[subagent-spawn] parent=${sessionId} agentId=${agentId} type=${meta.agentType || 'unknown'}`); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('subagent-spawned', { + parentSessionId: sessionId, + agentId, + subagentType: meta.agentType || null, + description: meta.description || null, + }); + } + } else if (!known.completed) { + if (mtimeMs !== known.mtimeMs) { + // File is still being written — update mtime, reset stability clock + known.mtimeMs = mtimeMs; + known._stableStart = null; + } else { + // mtime stable — start or continue stability timer + if (!known._stableStart) { + known._stableStart = now; + } else if (now - known._stableStart >= STABLE_MS) { + known.completed = true; + log.info(`[subagent-complete] parent=${sessionId} agentId=${agentId}`); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('subagent-completed', { + parentSessionId: sessionId, + agentId, + }); + } + } + } + } + } + + // GC: remove completed entries after 5 minutes to avoid unbounded growth + const GC_TTL = 5 * 60 * 1000; + for (const [agentId, state] of session.knownSubagents) { + if (state.completed && state._completedAt && now - state._completedAt > GC_TTL) { + session.knownSubagents.delete(agentId); + } + if (state.completed && !state._completedAt) { + state._completedAt = now; + } + } +} + // --- Fork / plan-accept detection --- /** Read first few lines of a new .jsonl to extract signals. @@ -85,6 +169,12 @@ function detectSessionTransitions(folder) { } catch { return; } for (const [sessionId, session] of [...activeSessions]) { + // Run subagent detection for all non-exited, non-terminal sessions in this folder + if (!session.exited && !session.isPlainTerminal && session.projectFolder === folder) { + const effectiveSessionId = session.realSessionId || sessionId; + detectSubagentTransitions(effectiveSessionId, session, folderPath); + } + if (session.exited || session.isPlainTerminal || !session.knownJsonlFiles || session.projectFolder !== folder) { if (!session.exited && !session.isPlainTerminal && session.forkFrom) { log.info(`[fork-detect] skipped session=${sessionId} forkFrom=${session.forkFrom||'none'} reason=${session.exited ? 'exited' : session.isPlainTerminal ? 'terminal' : !session.knownJsonlFiles ? 'noKnown' : 'folderMismatch('+session.projectFolder+' vs '+folder+')'}`); @@ -195,4 +285,4 @@ function detectSessionTransitions(folder) { } -module.exports = { init, detectSessionTransitions }; +module.exports = { init, detectSessionTransitions, detectSubagentTransitions }; From 1fea1aa160f5e53334421f7fa52452a6b50a1329 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:10:17 +0200 Subject: [PATCH 11/66] feat(ui): hierarchical sidebar, grid badges, live indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sidebar.js: subagents are no longer flat siblings of their parent. Each top-level row now shows a 'N subagents' affordance with a disclosure caret; expanded children render indented with a subagentType pill, description as label, and persist expansion state per parent in localStorage. Orphan subagents (parent absent from cache) hoist to a 'Orphan subagents' group. - grid-view.js: each active session card now shows a stack of small pills for currently-running subagents, color-coded by subagentType, capped at 5 with a '+N more' overflow. Listens on onSubagentSpawned/onSubagentCompleted. - jsonl-viewer.js: when an inline-expanded subagent block represents a still- running agent, start an fs.watch via the new IPC and append streamed entries; show a small '● live' indicator until completion. Stops the watch on collapse or subagent-completed. - style.css: minimal styles for new sidebar/grid/live elements, matching existing palette and font weights. --- public/grid-view.js | 109 +++++++++++++++++++++++ public/jsonl-viewer.js | 91 ++++++++++++++++++- public/sidebar.js | 194 +++++++++++++++++++++++++++++++++++++++-- public/style.css | 128 +++++++++++++++++++++++++++ 4 files changed, 514 insertions(+), 8 deletions(-) diff --git a/public/grid-view.js b/public/grid-view.js index 56eea95..f7b7622 100644 --- a/public/grid-view.js +++ b/public/grid-view.js @@ -11,6 +11,111 @@ let gridCards = new Map(); // sessionId → card wrapper element let gridFocusedSessionId = null; +// Active subagents tracked via IPC events (subagent-spawned / subagent-completed). +// parentSessionId → Set of { agentId, subagentType, spawnedAt } +const activeSubagents = new Map(); + +// Subagent type → pill color (matches sidebar palette) +const GRID_SUBAGENT_TYPE_COLORS = { + explore: '#3ecf82', + plan: '#8088ff', + implement: '#ffaa40', + review: '#60bef0', + test: '#ff6464', + default: '#a0a0b4', +}; + +function gridSubagentColor(type) { + return GRID_SUBAGENT_TYPE_COLORS[(type || '').toLowerCase()] || GRID_SUBAGENT_TYPE_COLORS.default; +} + +// Wire IPC listeners (guarded — bindings may not exist yet) +(function initSubagentListeners() { + if (typeof window.api === 'undefined') return; + + if (typeof window.api.onSubagentSpawned === 'function') { + window.api.onSubagentSpawned((event, data) => { + const { parentSessionId, agentId, subagentType } = data || {}; + if (!parentSessionId || !agentId) return; + if (!activeSubagents.has(parentSessionId)) activeSubagents.set(parentSessionId, new Map()); + activeSubagents.get(parentSessionId).set(agentId, { agentId, subagentType, spawnedAt: Date.now() }); + updateGridSubagentPills(parentSessionId); + }); + } + + if (typeof window.api.onSubagentCompleted === 'function') { + window.api.onSubagentCompleted((event, data) => { + const { parentSessionId, agentId } = data || {}; + if (!parentSessionId || !agentId) return; + const map = activeSubagents.get(parentSessionId); + if (map) { + map.delete(agentId); + if (map.size === 0) activeSubagents.delete(parentSessionId); + } + updateGridSubagentPills(parentSessionId); + }); + } +})(); + +// Prune subagents that have been running for more than 60 s without a completion event. +// Called on each grid render cycle. +function pruneStaleSubagents() { + const cutoff = Date.now() - 60000; + for (const [parentId, map] of activeSubagents) { + for (const [agentId, info] of map) { + if (info.spawnedAt < cutoff) map.delete(agentId); + } + if (map.size === 0) activeSubagents.delete(parentId); + } +} + +// Re-render the pill row for a single card (if it exists in the grid). +function updateGridSubagentPills(parentSessionId) { + const card = gridCards.get(parentSessionId); + if (!card) return; + + let pillRow = card.querySelector('.grid-subagent-pills'); + + const map = activeSubagents.get(parentSessionId); + if (!map || map.size === 0) { + if (pillRow) pillRow.remove(); + return; + } + + if (!pillRow) { + pillRow = document.createElement('div'); + pillRow.className = 'grid-subagent-pills'; + // Insert before the footer + const footer = card.querySelector('.grid-card-footer'); + if (footer) { + card.insertBefore(pillRow, footer); + } else { + card.appendChild(pillRow); + } + } + + pillRow.innerHTML = ''; + const entries = [...map.values()]; + const MAX_PILLS = 5; + const shown = entries.slice(0, MAX_PILLS); + const overflow = entries.length - shown.length; + + for (const info of shown) { + const pill = document.createElement('span'); + pill.className = 'grid-subagent-pill'; + pill.title = info.subagentType || 'subagent'; + pill.style.background = gridSubagentColor(info.subagentType); + pillRow.appendChild(pill); + } + + if (overflow > 0) { + const more = document.createElement('span'); + more.className = 'grid-subagent-pill-overflow'; + more.textContent = `+${overflow} more`; + pillRow.appendChild(more); + } +} + function wrapInGridCard(sessionId) { const entry = openSessions.get(sessionId); const session = sessionMap.get(sessionId) || (entry && entry.session); @@ -132,6 +237,10 @@ function wrapInGridCard(sessionId) { gridCards.set(sessionId, card); // Set initial status from the single source of truth updateRunningIndicators(); + + // Render subagent pills for any already-tracked children + pruneStaleSubagents(); + updateGridSubagentPills(sessionId); } function unwrapGridCards() { diff --git a/public/jsonl-viewer.js b/public/jsonl-viewer.js index ffc3593..2c1a2bc 100644 --- a/public/jsonl-viewer.js +++ b/public/jsonl-viewer.js @@ -8,6 +8,34 @@ let currentViewerSessionId = null; // Reset on each showJsonlViewer call. Key: "||" let agentMatchCounters = {}; +// --- Live subagent tracking --- +// Set of agentIds that are currently live (spawned but not yet completed). +// Keyed as ":" so it's globally unique. +const liveSubagents = new Set(); + +// Register IPC listeners for subagent lifecycle events (called once at module load). +(function initSubagentListeners() { + if (!window.api) return; // guard for non-Electron contexts + window.api.onSubagentSpawned((payload) => { + const key = payload.parentSessionId + ':' + payload.agentId; + liveSubagents.add(key); + }); + window.api.onSubagentCompleted((payload) => { + const key = payload.parentSessionId + ':' + payload.agentId; + liveSubagents.delete(key); + // Notify any active watch container so it can stop the watch and hide the indicator + document.querySelectorAll('[data-subagent-watch-key="' + key + '"]').forEach(el => { + el.dispatchEvent(new CustomEvent('subagent-completed-internal')); + }); + }); + window.api.onSubagentWatchEvent((payload) => { + const key = payload.parentSessionId + ':' + payload.agentId; + document.querySelectorAll('[data-subagent-watch-key="' + key + '"]').forEach(el => { + el.dispatchEvent(new CustomEvent('subagent-watch-data', { detail: payload })); + }); + }); +})() + function renderJsonlText(text) { if (window.marked) { // Escape XML/HTML-like tags so they render as visible text, @@ -237,10 +265,24 @@ const toolRenderers = { let expanded = false; let nestedContainer = null; + let activeWatchId = null; + let liveIndicator = null; + + function stopWatch() { + if (activeWatchId !== null) { + window.api.stopSubagentWatch(activeWatchId).catch(() => {}); + activeWatchId = null; + } + if (liveIndicator) { + liveIndicator.remove(); + liveIndicator = null; + } + } el.addEventListener('click', async () => { if (expanded && nestedContainer) { - // Collapse + // Collapse — stop live watch + stopWatch(); nestedContainer.remove(); nestedContainer = null; expanded = false; @@ -290,6 +332,53 @@ const toolRenderers = { expanded = true; const caret = el.querySelector('.jsonl-agent-caret'); if (caret) caret.innerHTML = '▼'; + + // Start live watch if this subagent is still running + const watchKey = parentSessionId + ':' + match.agentId; + if (liveSubagents.has(watchKey)) { + // Attach the watch key to nestedContainer for event routing + nestedContainer.dataset.subagentWatchKey = watchKey; + + const watchResult = await window.api.startSubagentWatch(parentSessionId, match.agentId); + if (watchResult && watchResult.watchId) { + activeWatchId = watchResult.watchId; + + // Show "● live" indicator in the block header + liveIndicator = document.createElement('span'); + liveIndicator.className = 'jsonl-agent-live'; + liveIndicator.textContent = '● live'; + const toolHeader = el.querySelector('.jsonl-tool-header'); + if (toolHeader) toolHeader.appendChild(liveIndicator); + + // Stream new entries into nestedContainer + nestedContainer.addEventListener('subagent-watch-data', (evt) => { + const { entries: newEntries } = evt.detail; + const merged = mergeLocalCommandEntries(newEntries); + const appendResultMap = new Map(); + for (const entry of merged) { + const blocks2 = entry.message?.content || entry.content; + if (!Array.isArray(blocks2)) continue; + for (const b of blocks2) { + if (b.type === 'tool_result' && b.tool_use_id) { + appendResultMap.set(b.tool_use_id, b.content || b.output || ''); + } + } + } + const savedId = currentViewerSessionId; + currentViewerSessionId = subSessionId; + for (const entry of merged) { + const entryEl = renderJsonlEntry(entry, appendResultMap); + if (entryEl) nestedContainer.appendChild(entryEl); + } + currentViewerSessionId = savedId; + }); + + // Stop watch when subagent completes + nestedContainer.addEventListener('subagent-completed-internal', () => { + stopWatch(); + }); + } + } }); return el; diff --git a/public/sidebar.js b/public/sidebar.js index 45985dd..2bf193e 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -17,6 +17,82 @@ function folderId(projectPath) { return 'project-' + projectPath.replace(/[^a-zA-Z0-9_-]/g, '_'); } +// --- Subagent localStorage helpers --- +function getExpandedSubagents() { + try { + return new Set(JSON.parse(localStorage.getItem('expandedSubagents') || '[]')); + } catch (e) { return new Set(); } +} + +function saveExpandedSubagents(set) { + try { + localStorage.setItem('expandedSubagents', JSON.stringify([...set])); + } catch (e) {} +} + +// Subagent type → accent color (background / border) +const SUBAGENT_TYPE_COLORS = { + explore: { bg: 'rgba(62,207,130,0.18)', border: '#3ecf82' }, + plan: { bg: 'rgba(128,136,255,0.20)', border: '#8088ff' }, + implement: { bg: 'rgba(255,170,64,0.18)', border: '#ffaa40' }, + review: { bg: 'rgba(96,190,240,0.18)', border: '#60bef0' }, + test: { bg: 'rgba(255,100,100,0.18)', border: '#ff6464' }, + default: { bg: 'rgba(160,160,180,0.15)', border: '#a0a0b4' }, +}; + +function subagentTypeColor(type) { + const key = (type || '').toLowerCase(); + return SUBAGENT_TYPE_COLORS[key] || SUBAGENT_TYPE_COLORS.default; +} + +function buildSubagentItem(session) { + const item = document.createElement('div'); + item.className = 'sidebar-subagent session-item'; + item.id = 'si-' + session.sessionId; + if (activePtyIds.has(session.sessionId)) item.classList.add('has-running-pty'); + if (attentionSessions.has(session.sessionId)) item.classList.add('needs-attention'); + if (responseReadySessions.has(session.sessionId)) item.classList.add('response-ready'); + if (sessionBusyState.get(session.sessionId)) item.classList.add('cli-busy'); + item.dataset.sessionId = session.sessionId; + item.dataset.subagent = '1'; + + const { bg, border } = subagentTypeColor(session.subagentType); + item.style.borderLeftColor = border; + + const row = document.createElement('div'); + row.className = 'session-row'; + + const typePill = document.createElement('span'); + typePill.className = 'sidebar-subagent-type'; + typePill.textContent = session.subagentType || 'sub'; + typePill.style.background = bg; + typePill.style.borderColor = border; + + const dot = document.createElement('span'); + dot.className = 'session-status-dot' + (activePtyIds.has(session.sessionId) ? ' running' : ''); + + const info = document.createElement('div'); + info.className = 'session-info'; + + const summaryEl = document.createElement('div'); + summaryEl.className = 'session-summary'; + summaryEl.textContent = session.description || session.summary || session.aiTitle || session.sessionId; + + const metaEl = document.createElement('div'); + metaEl.className = 'session-meta'; + metaEl.textContent = session.messageCount ? session.messageCount + ' msgs' : ''; + + info.appendChild(summaryEl); + info.appendChild(metaEl); + + row.appendChild(typePill); + row.appendChild(dot); + row.appendChild(info); + item.appendChild(row); + + return item; +} + function buildSlugGroup(slug, sessions) { const group = document.createElement('div'); const id = slugId(slug); @@ -146,10 +222,25 @@ function renderProjects(projects, resort) { const newSortedOrder = []; + // Build subagent child index from all sessions in this project: parentSessionId → [sessions] + function buildSubagentIndex(sessions) { + const index = new Map(); + for (const s of sessions) { + if (s.parentSessionId) { + if (!index.has(s.parentSessionId)) index.set(s.parentSessionId, []); + index.get(s.parentSessionId).push(s); + } + } + return index; + } + // Process a project's sessions: filter, sort, slug-group, order, and truncate. // Returns { filtered, visible, older, sortOrderEntry } or null if project should be skipped. function processProjectSessions(project, resort) { - let filtered = project.sessions; + // Separate subagents from top-level sessions + const allSessions = project.sessions; + const subagentIndex = buildSubagentIndex(allSessions); + let filtered = allSessions.filter(s => !s.parentSessionId); if (showStarredOnly) filtered = filtered.filter(s => s.starred); if (showRunningOnly) filtered = filtered.filter(s => activePtyIds.has(s.sessionId)); if (showTodayOnly) { @@ -239,17 +330,61 @@ function renderProjects(projects, resort) { } return { - filtered, visible, older, + filtered, visible, older, subagentIndex, sortOrderEntry: { projectPath: project.projectPath, itemIds: allItems.map(item => item.element.id) }, }; } + // Append subagent children beneath a session item element. + function appendSubagentChildren(parentEl, parentSessionId, subagentIndex) { + const children = subagentIndex && subagentIndex.get(parentSessionId); + if (!children || children.length === 0) return; + + const expandedSet = getExpandedSubagents(); + const caretId = 'sub-caret-' + parentSessionId.replace(/[^a-zA-Z0-9_-]/g, '_'); + const isExpanded = expandedSet.has(parentSessionId); + + // Caret/toggle row attached to parent item + const caret = document.createElement('div'); + caret.className = 'sidebar-children-caret'; + caret.id = caretId; + if (isExpanded) caret.classList.add('expanded'); + caret.innerHTML = ` ${children.length} subagent${children.length !== 1 ? 's' : ''}`; + + const childrenContainer = document.createElement('div'); + childrenContainer.className = 'sidebar-subagents-container'; + childrenContainer.id = 'subc-' + parentSessionId.replace(/[^a-zA-Z0-9_-]/g, '_'); + childrenContainer.style.display = isExpanded ? '' : 'none'; + + for (const child of children) { + childrenContainer.appendChild(buildSubagentItem(child)); + } + + caret.addEventListener('click', (e) => { + e.stopPropagation(); + const open = childrenContainer.style.display !== 'none'; + childrenContainer.style.display = open ? 'none' : ''; + caret.classList.toggle('expanded', !open); + const set = getExpandedSubagents(); + if (open) { set.delete(parentSessionId); } else { set.add(parentSessionId); } + saveExpandedSubagents(set); + }); + + parentEl.after(caret); + caret.after(childrenContainer); + } + // Build the sessions list DOM (shared between projects and worktrees) - function buildSessionsList(fId, visible, older) { + function buildSessionsList(fId, visible, older, subagentIndex) { const sessionsList = document.createElement('div'); sessionsList.className = 'project-sessions'; sessionsList.id = 'sessions-' + fId; - for (const item of visible) sessionsList.appendChild(item.element); + for (const item of visible) { + sessionsList.appendChild(item.element); + // Attach subagent children for top-level sessions + const sid = item.element.dataset && item.element.dataset.sessionId; + if (sid) appendSubagentChildren(item.element, sid, subagentIndex); + } if (older.length > 0) { const moreBtn = document.createElement('div'); moreBtn.className = 'sessions-more-toggle'; @@ -259,10 +394,38 @@ function renderProjects(projects, resort) { olderList.className = 'sessions-older'; olderList.id = 'older-list-' + fId; olderList.style.display = 'none'; - for (const item of older) olderList.appendChild(item.element); + for (const item of older) { + olderList.appendChild(item.element); + const sid = item.element.dataset && item.element.dataset.sessionId; + if (sid) appendSubagentChildren(item.element, sid, subagentIndex); + } sessionsList.appendChild(moreBtn); sessionsList.appendChild(olderList); } + + // Orphan subagents: children whose parentSessionId has no top-level session in this project + if (subagentIndex) { + const allTopLevelIds = new Set([...visible, ...older].map(i => i.element.dataset && i.element.dataset.sessionId).filter(Boolean)); + const orphans = []; + for (const [parentId, kids] of subagentIndex) { + if (!allTopLevelIds.has(parentId)) { + for (const k of kids) orphans.push(k); + } + } + if (orphans.length > 0) { + const orphanGroup = document.createElement('div'); + orphanGroup.className = 'sidebar-orphan-subagents'; + const orphanLabel = document.createElement('div'); + orphanLabel.className = 'sidebar-orphan-label'; + orphanLabel.textContent = 'Orphan subagents'; + orphanGroup.appendChild(orphanLabel); + for (const orphan of orphans) { + orphanGroup.appendChild(buildSubagentItem(orphan)); + } + sessionsList.appendChild(orphanGroup); + } + } + return sessionsList; } @@ -311,7 +474,7 @@ function renderProjects(projects, resort) { newBtn.title = 'New session'; header.appendChild(newBtn); - const sessionsList = buildSessionsList(fId, visible, older); + const sessionsList = buildSessionsList(fId, visible, older, subagentIndex); // Auto-collapse if most recent session is older than threshold, or project matched with no sessions if (project._projectMatchedOnly) { @@ -357,7 +520,7 @@ function renderProjects(projects, resort) { wtNewBtn.title = 'New session in worktree'; wtHeader.appendChild(wtNewBtn); - const wtSessionsList = buildSessionsList(wtFId, wtResult.visible, wtResult.older); + const wtSessionsList = buildSessionsList(wtFId, wtResult.visible, wtResult.older, wtResult.subagentIndex); wtSessionsList.className = 'worktree-sessions'; // Auto-collapse worktree if stale @@ -403,6 +566,20 @@ function renderProjects(projects, resort) { toEl.classList.remove('collapsed'); } } + if (fromEl.classList.contains('sidebar-children-caret')) { + if (fromEl.classList.contains('expanded')) { + toEl.classList.add('expanded'); + } else { + toEl.classList.remove('expanded'); + } + } + if (fromEl.classList.contains('sidebar-subagents-container')) { + if (fromEl.style.display !== 'none') { + toEl.style.display = ''; + } else { + toEl.style.display = 'none'; + } + } if (fromEl.classList.contains('sessions-older') && fromEl.style.display !== 'none') { toEl.style.display = ''; } @@ -560,6 +737,9 @@ function rebindSidebarEvents(projects) { item.onclick = () => openSession(session); + // Subagent items are read-only: skip pin, rename, stop, fork, archive, jsonl, launchConfig + if (item.dataset.subagent) return; + const pin = item.querySelector('.session-pin'); if (pin) { pin.onclick = async (e) => { diff --git a/public/style.css b/public/style.css index a50b1e0..39212bc 100644 --- a/public/style.css +++ b/public/style.css @@ -1480,6 +1480,134 @@ body { display: flex; flex-direction: column; } } +/* ========== SUBAGENT SIDEBAR ========== */ + +/* Caret toggle row that appears below a parent session item */ +.sidebar-children-caret { + display: flex; + align-items: center; + gap: 5px; + margin: 0 12px 0 28px; + padding: 2px 6px; + font-size: 10px; + color: #7a7a96; + cursor: pointer; + user-select: none; + border-radius: 4px; + transition: color 0.12s, background 0.12s; +} + +.sidebar-children-caret:hover { + color: #b0b0c8; + background: rgba(255,255,255,0.05); +} + +.sidebar-children-caret .caret-arrow { + display: inline-block; + font-size: 8px; + transition: transform 0.15s; +} + +.sidebar-children-caret.expanded .caret-arrow { + transform: rotate(90deg); +} + +/* Container for the child subagent items */ +.sidebar-subagents-container { + /* display toggled via JS */ +} + +/* Nested subagent row */ +.sidebar-subagent { + padding-left: 28px !important; + border-left: 2px solid transparent; + margin-left: 12px; + font-size: 11.5px; +} + +.sidebar-subagent .session-row { + padding: 6px 8px !important; + gap: 6px !important; +} + +.sidebar-subagent .session-summary { + font-size: 11.5px; + color: #c0c0d4; +} + +.sidebar-subagent .session-meta { + font-size: 10px; + color: #6a6a80; +} + +/* Subagent type pill */ +.sidebar-subagent-type { + flex-shrink: 0; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.3px; + text-transform: uppercase; + padding: 1px 5px; + border-radius: 3px; + border: 1px solid transparent; + color: #e0e0f0; + white-space: nowrap; + line-height: 1.6; +} + +/* Orphan subagent group label */ +.sidebar-orphan-subagents { + margin-top: 4px; + border-top: 1px solid rgba(255,255,255,0.05); +} + +.sidebar-orphan-label { + padding: 4px 12px; + font-size: 9.5px; + color: #6a6a80; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* ========== SUBAGENT GRID PILLS ========== */ + +/* Horizontal pill strip shown in grid card above the footer */ +.grid-subagent-pills { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + background: rgba(255,255,255,0.02); + border-top: 1px solid rgba(255,255,255,0.04); + flex-shrink: 0; + flex-wrap: nowrap; + overflow: hidden; +} + +/* Single colored dot pill — tooltip shows type */ +.grid-subagent-pill { + width: 10px; + height: 6px; + border-radius: 3px; + flex-shrink: 0; + opacity: 0.85; + transition: opacity 0.12s, transform 0.12s; + cursor: default; +} + +.grid-subagent-pill:hover { + opacity: 1; + transform: scaleY(1.3); +} + +/* "+N more" overflow label */ +.grid-subagent-pill-overflow { + font-size: 9px; + color: #7a7a90; + white-space: nowrap; + margin-left: 2px; +} + /* ========== SCROLLBAR ========== */ #sidebar-content::-webkit-scrollbar, #plans-content::-webkit-scrollbar { width: 5px; } #sidebar-content::-webkit-scrollbar-track, #plans-content::-webkit-scrollbar-track { background: transparent; } From a6210a625606ac6f80f31a33a91e7aa0e861d233 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 09:29:20 +0200 Subject: [PATCH 12/66] fix(transitions): silent cold-start to avoid IPC flood on attach When Switchboard attaches to a session that already has many subagent files on disk (e.g. a long-running session with 100+ Agent calls), the first walk of detectSubagentTransitions treated every existing file as a 'first sighting' and emitted subagent-spawned for each. 30s later it emitted subagent-completed for each. Hundreds of IPC events back to back froze the renderer UI. Distinguish bootstrap (first walk for this session) from steady-state. On bootstrap, record every existing file in knownSubagents silently: - files modified in the last 60s stay in the active lifecycle (could be mid-run, will eventually fire subagent-completed) - older files are marked completed immediately with no IPC Only files that appear AFTER the bootstrap walk fire subagent-spawned. --- session-transitions.js | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/session-transitions.js b/session-transitions.js index 54c49bf..6da11bc 100644 --- a/session-transitions.js +++ b/session-transitions.js @@ -30,13 +30,20 @@ function detectSubagentTransitions(sessionId, session, folderPath) { return; // directory doesn't exist yet — normal } - if (!session.knownSubagents) { + // First walk for this session: pre-populate knownSubagents with every + // existing file silently so we don't flood the renderer with spawn/complete + // events for agents that already finished before Switchboard started watching. + // Files modified in the last 60s get a normal lifecycle; older ones are + // recorded as already-completed without IPC. + const isBootstrap = !session.knownSubagents; + if (isBootstrap) { session.knownSubagents = new Map(); } const mainWindow = getMainWindow(); const now = Date.now(); const STABLE_MS = 30000; // 30 seconds of no mtime advance → completed + const BOOTSTRAP_LIVE_MS = 60000; // file modified in last 60s = still alive at boot for (const file of files) { // agent-.jsonl @@ -52,7 +59,19 @@ function detectSubagentTransitions(sessionId, session, folderPath) { const known = session.knownSubagents.get(agentId); if (!known) { - // First sighting — emit subagent-spawned + if (isBootstrap) { + // Cold-start initialization — record silently without firing IPC. + // Treat recently-modified files as still-active so they can complete + // through the normal lifecycle; treat older ones as already done. + const looksAlive = (now - mtimeMs) < BOOTSTRAP_LIVE_MS; + session.knownSubagents.set(agentId, { + mtimeMs, + completed: !looksAlive, + _completedAt: looksAlive ? null : now, + }); + continue; + } + // First sighting post-bootstrap — real spawn event const meta = readSubagentMeta(filePath) || {}; session.knownSubagents.set(agentId, { mtimeMs, completed: false }); log.info(`[subagent-spawn] parent=${sessionId} agentId=${agentId} type=${meta.agentType || 'unknown'}`); From 1c235d1bb0e7e77cf6cac895da8fb9d7a8927920 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:33:28 +0200 Subject: [PATCH 13/66] feat(worktree): add 'Delete worktree' button that actually removes from disk The existing 'Hide worktree' affordance only added the path to hiddenProjects; on-disk worktrees accumulated. This adds a real delete action. - main.js: new IPC delete-worktree(path). Validates the path matches the worktree-layout regex (.claude/worktrees, .claude-worktrees, .worktrees), runs 'git worktree remove -f' via execFile (no shell), retries with '-f -f' on locked worktrees, then purges any matching session_cache rows and FTS entries. Returns { ok, removed } / { ok: false, error }. - preload.js: window.api.deleteWorktree binding. - sidebar.js: 'Delete worktree' button on worktree sub-groups, with confirm dialog. Keeps 'Hide worktree' for the cosmetic case. Hide is unchanged; Delete is opt-in and confirmed before running. --- main.js | 79 +++++++++++++++++++++++++++++++++++++++++++++++ preload.js | 1 + public/sidebar.js | 23 +++++++++++++- 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/main.js b/main.js index 6fbef20..daaa83a 100644 --- a/main.js +++ b/main.js @@ -1,5 +1,6 @@ const { app, BrowserWindow, dialog, ipcMain, Menu, screen, shell } = require('electron'); const { Worker } = require('worker_threads'); +const { execFile } = require('child_process'); const path = require('path'); const fs = require('fs'); const os = require('os'); @@ -351,6 +352,84 @@ ipcMain.handle('remove-project', (_event, projectPath) => { } }); +// --- IPC: delete-worktree --- +// Validated path pattern: /./[worktrees/] +// Matches .claude/worktrees/, .claude-worktrees/, .worktrees/ +const WORKTREE_PATH_RE = /^(.+?)\/\.(?:claude\/worktrees|claude-worktrees|worktrees)\/([^/]+)\/?$/; + +ipcMain.handle('delete-worktree', (_event, worktreePath) => { + return new Promise((resolve) => { + // Normalize trailing slash + const normalizedPath = worktreePath.replace(/\/$/, ''); + + // Validate path matches a known worktree layout + const match = normalizedPath.match(WORKTREE_PATH_RE); + if (!match) { + return resolve({ ok: false, error: 'Path does not match a recognized worktree layout' }); + } + const parentRepo = match[1]; + + // Helper: run git worktree remove, optionally double-force + function runRemove(doubleForce, callback) { + const args = ['-C', parentRepo, 'worktree', 'remove', '-f']; + if (doubleForce) args.push('-f'); + args.push('--', normalizedPath); + execFile('git', args, (err, _stdout, stderr) => callback(err, stderr)); + } + + runRemove(false, (err, stderr) => { + if (err && /locked/i.test(stderr || err.message || '')) { + // Retry with double force for locked worktrees + runRemove(true, (err2, stderr2) => { + if (err2) return resolve({ ok: false, error: (stderr2 || err2.message || String(err2)).trim() }); + afterRemove(); + }); + } else if (err) { + return resolve({ ok: false, error: (stderr || err.message || String(err)).trim() }); + } else { + afterRemove(); + } + }); + + function afterRemove() { + // Clean up DB cache: delete all sessions whose projectPath matches worktreePath + let removed = 0; + try { + const allRows = getAllCached(); + for (const row of allRows) { + if (row.projectPath === normalizedPath) { + deleteCachedSession(row.sessionId); + deleteSearchSession(row.sessionId); + removed++; + } + } + } catch (dbErr) { + log.warn('[delete-worktree] DB cleanup error:', dbErr.message); + } + + // Remove from hiddenProjects if present + try { + const global = getSetting('global') || {}; + if (Array.isArray(global.hiddenProjects) && global.hiddenProjects.includes(normalizedPath)) { + global.hiddenProjects = global.hiddenProjects.filter(p => p !== normalizedPath); + setSetting('global', global); + } + } catch {} + + // Also clean up folder meta + try { + const folder = encodeProjectPath(normalizedPath); + deleteCachedFolder(folder); + deleteSearchFolder(folder); + } catch {} + + log.info(`[delete-worktree] removed=${normalizedPath} sessions=${removed}`); + notifyRendererProjectsChanged(); + resolve({ ok: true, removed }); + } + }); +}); + // --- IPC: get-projects --- ipcMain.handle('open-external', (_event, url) => { log.info('[open-external IPC]', url); diff --git a/preload.js b/preload.js index 347ef26..3341ab4 100644 --- a/preload.js +++ b/preload.js @@ -39,6 +39,7 @@ contextBridge.exposeInMainWorld('api', { browseFolder: () => ipcRenderer.invoke('browse-folder'), addProject: (projectPath) => ipcRenderer.invoke('add-project', projectPath), removeProject: (projectPath) => ipcRenderer.invoke('remove-project', projectPath), + deleteWorktree: (worktreePath) => ipcRenderer.invoke('delete-worktree', worktreePath), openExternal: (url) => ipcRenderer.invoke('open-external', url), // Send (fire-and-forget) diff --git a/public/sidebar.js b/public/sidebar.js index 2bf193e..e16615d 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -514,6 +514,12 @@ function renderProjects(projects, resort) { wtHideBtn.innerHTML = ''; wtHeader.appendChild(wtHideBtn); + const wtDeleteBtn = document.createElement('button'); + wtDeleteBtn.className = 'worktree-delete-btn'; + wtDeleteBtn.title = 'Delete worktree from disk'; + wtDeleteBtn.innerHTML = ''; + wtHeader.appendChild(wtDeleteBtn); + const wtNewBtn = document.createElement('button'); wtNewBtn.className = 'project-new-btn worktree-new-btn'; wtNewBtn.innerHTML = ''; @@ -676,8 +682,23 @@ function rebindSidebarEvents(projects) { loadProjects(); }; } + const wtDeleteBtn = wtHeader.querySelector('.worktree-delete-btn'); + if (wtDeleteBtn) { + wtDeleteBtn.onclick = async (e) => { + e.stopPropagation(); + const name = wtProject.projectPath.split('/').pop(); + if (!confirm(`Delete worktree "${name}" from disk?\n\nThis runs "git worktree remove -f" and permanently removes the working tree. This cannot be undone.`)) return; + const result = await window.api.deleteWorktree(wtProject.projectPath); + if (result && result.ok) { + loadProjects(); + } else { + const msg = (result && result.error) ? result.error : 'Unknown error'; + alert(`Failed to delete worktree: ${msg}`); + } + }; + } wtHeader.onclick = (e) => { - if (e.target.closest('.worktree-new-btn') || e.target.closest('.worktree-hide-btn')) return; + if (e.target.closest('.worktree-new-btn') || e.target.closest('.worktree-hide-btn') || e.target.closest('.worktree-delete-btn')) return; wtHeader.classList.toggle('collapsed'); }; }); From fd822616aa6c0a54e73988d1f1e5c01440ce4b03 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 09:21:33 +0200 Subject: [PATCH 14/66] chore(ui): suffix app name with '(dev)' when not packaged Makes it possible to tell the dev build apart from a released installed binary in the OS task switcher, dock, About menu, and window title. No-op in packaged builds. --- main.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/main.js b/main.js index daaa83a..6277269 100644 --- a/main.js +++ b/main.js @@ -109,11 +109,12 @@ function createWindow() { } } + const appTitle = app.isPackaged ? 'Switchboard' : 'Switchboard (dev)'; mainWindow = new BrowserWindow({ ...bounds, minWidth: 800, minHeight: 500, - title: 'Switchboard', + title: appTitle, icon: path.join(__dirname, 'build', 'icon.png'), webPreferences: { preload: path.join(__dirname, 'preload.js'), @@ -1550,6 +1551,12 @@ ipcMain.handle('updater-install', () => { }); // --- App lifecycle --- +// Differentiate the dev build from the released binary in the OS task switcher, +// dock, and About menu by suffixing the app name. No-op in packaged builds. +if (!app.isPackaged) { + app.setName('Switchboard (dev)'); +} + app.whenReady().then(() => { buildMenu(); createWindow(); From f8124cf201913b7f99bea61bcc7aa108dee24f9c Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 09:57:21 +0200 Subject: [PATCH 15/66] test: cover resolveWorktreePath and detectSubagentTransitions cold-start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 14 unit tests for two load-bearing behaviours shipped via PR#1/#2 that previously had no coverage: - derive-project-path.js — resolveWorktreePath collapses worktree paths (.worktrees, .claude-worktrees, .claude/worktrees) onto the parent project; deriveProjectPath end-to-end via stubbed jsonl - session-transitions.js — bootstrap silent-init (no spawn/complete events on first walk), post-bootstrap spawn detection, completion timing --- test/derive-project-path.test.js | 107 +++++++++++++++++ test/session-transitions.test.js | 197 +++++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 test/derive-project-path.test.js create mode 100644 test/session-transitions.test.js diff --git a/test/derive-project-path.test.js b/test/derive-project-path.test.js new file mode 100644 index 0000000..1b84829 --- /dev/null +++ b/test/derive-project-path.test.js @@ -0,0 +1,107 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { deriveProjectPath, resolveWorktreePath } = require('../derive-project-path'); + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-dpp-')); +} + +function cleanup(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +test('resolveWorktreePath collapses //.claude/worktrees/ back to when parent exists', () => { + const tmp = mkTmp(); + try { + const repo = path.join(tmp, 'repo'); + fs.mkdirSync(repo); + const worktree = path.join(repo, '.claude', 'worktrees', 'agent-abc'); + assert.equal(resolveWorktreePath(worktree), repo); + } finally { + cleanup(tmp); + } +}); + +test('resolveWorktreePath collapses //.claude-worktrees/ back to ', () => { + const tmp = mkTmp(); + try { + const repo = path.join(tmp, 'repo'); + fs.mkdirSync(repo); + const worktree = path.join(repo, '.claude-worktrees', 'foo'); + assert.equal(resolveWorktreePath(worktree), repo); + } finally { + cleanup(tmp); + } +}); + +test('resolveWorktreePath collapses //.worktrees/ back to ', () => { + const tmp = mkTmp(); + try { + const repo = path.join(tmp, 'repo'); + fs.mkdirSync(repo); + const worktree = path.join(repo, '.worktrees', 'bar'); + assert.equal(resolveWorktreePath(worktree), repo); + } finally { + cleanup(tmp); + } +}); + +test('resolveWorktreePath handles trailing-slash variant', () => { + const tmp = mkTmp(); + try { + const repo = path.join(tmp, 'repo'); + fs.mkdirSync(repo); + const worktreeWithSlash = path.join(repo, '.worktrees', 'bar') + '/'; + assert.equal(resolveWorktreePath(worktreeWithSlash), repo); + } finally { + cleanup(tmp); + } +}); + +test('resolveWorktreePath returns input unchanged when the parent dir does not exist on disk', () => { + // /nonexistent-xyzzy-12345/.claude/worktrees/agent-foo — regex matches, but parent dir absent + const fake = '/nonexistent-xyzzy-12345/.claude/worktrees/agent-foo'; + assert.equal(resolveWorktreePath(fake), fake); +}); + +test('resolveWorktreePath returns input unchanged when the path does not match the worktree pattern', () => { + assert.equal(resolveWorktreePath('/repo/src/foo'), '/repo/src/foo'); + assert.equal(resolveWorktreePath('/repo/.claude/agents/foo'), '/repo/.claude/agents/foo'); + // Worktrees segment but two extra components (nested under worktree) — must not match + assert.equal(resolveWorktreePath('/repo/.worktrees/foo/bar'), '/repo/.worktrees/foo/bar'); +}); + +test('resolveWorktreePath passes falsy input through unchanged without throwing', () => { + assert.equal(resolveWorktreePath(null), null); + assert.equal(resolveWorktreePath(undefined), undefined); + assert.equal(resolveWorktreePath(''), ''); +}); + +test('deriveProjectPath end-to-end: jsonl with worktree cwd resolves to parent repo', () => { + const tmp = mkTmp(); + try { + // Real on-disk repo so existsSync returns true + const repo = path.join(tmp, 'repo'); + fs.mkdirSync(repo); + const worktreeCwd = path.join(repo, '.claude', 'worktrees', 'agent-x'); + // worktreeCwd itself doesn't need to exist; only its derived parent does + + // The folder we feed deriveProjectPath is a "projects/foo" style dir + // containing a single jsonl whose first cwd line points at the worktree. + const folder = path.join(tmp, 'project-folder'); + fs.mkdirSync(folder); + fs.writeFileSync( + path.join(folder, 'session-1.jsonl'), + JSON.stringify({ type: 'user', cwd: worktreeCwd }) + '\n', + 'utf8' + ); + + assert.equal(deriveProjectPath(folder), repo); + } finally { + cleanup(tmp); + } +}); diff --git a/test/session-transitions.test.js b/test/session-transitions.test.js new file mode 100644 index 0000000..a036618 --- /dev/null +++ b/test/session-transitions.test.js @@ -0,0 +1,197 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const sessionTransitions = require('../session-transitions'); +const { detectSubagentTransitions, init } = sessionTransitions; + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-st-')); +} + +function cleanup(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +/** Build a mock mainWindow that records every webContents.send call. */ +function makeMockWindow() { + const events = []; + return { + isDestroyed: () => false, + webContents: { + send: (channel, payload) => events.push({ channel, payload }), + }, + _events: events, + }; +} + +/** Initialize the module with mocks. Returns the recorded-events array. */ +function setupModule() { + const win = makeMockWindow(); + init({ + PROJECTS_DIR: '/unused', + activeSessions: new Map(), + getMainWindow: () => win, + log: { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} }, + rekeyMcpServer: () => {}, + }); + return win._events; +} + +/** Create N agent jsonl files under //subagents/ and + * set their mtimes to (now - ageMs). Returns the subagents dir. */ +function seedAgents(folder, sessionId, agents) { + const subDir = path.join(folder, sessionId, 'subagents'); + fs.mkdirSync(subDir, { recursive: true }); + for (const { id, ageMs = 0, content = '' } of agents) { + const filePath = path.join(subDir, `agent-${id}.jsonl`); + fs.writeFileSync(filePath, content, 'utf8'); + if (ageMs) { + const t = (Date.now() - ageMs) / 1000; + fs.utimesSync(filePath, t, t); + } + } + return subDir; +} + +test('bootstrap call with 5 pre-existing subagents emits zero events and populates the map', () => { + const events = setupModule(); + const tmp = mkTmp(); + try { + const sessionId = 'parent-session'; + seedAgents(tmp, sessionId, [ + { id: 'a1' }, { id: 'a2' }, { id: 'a3' }, { id: 'a4' }, { id: 'a5' }, + ]); + + const session = {}; // knownSubagents undefined → bootstrap + detectSubagentTransitions(sessionId, session, tmp); + + assert.equal(events.length, 0, 'bootstrap must not emit IPC'); + assert.ok(session.knownSubagents instanceof Map); + assert.equal(session.knownSubagents.size, 5); + } finally { + cleanup(tmp); + } +}); + +test('bootstrap marks an old-mtime agent (>60s) as completed: true', () => { + const events = setupModule(); + const tmp = mkTmp(); + try { + const sessionId = 'parent'; + seedAgents(tmp, sessionId, [{ id: 'oldie', ageMs: 120_000 }]); // 2 minutes old + + const session = {}; + detectSubagentTransitions(sessionId, session, tmp); + + assert.equal(events.length, 0); + const entry = session.knownSubagents.get('oldie'); + assert.ok(entry, 'expected an entry for oldie'); + assert.equal(entry.completed, true); + assert.ok(entry._completedAt, 'expected _completedAt to be stamped'); + } finally { + cleanup(tmp); + } +}); + +test('bootstrap marks a fresh-mtime agent as completed: false (lifecycle continues)', () => { + const events = setupModule(); + const tmp = mkTmp(); + try { + const sessionId = 'parent'; + seedAgents(tmp, sessionId, [{ id: 'fresh', ageMs: 5_000 }]); // 5s old, well under 60s + + const session = {}; + detectSubagentTransitions(sessionId, session, tmp); + + assert.equal(events.length, 0, 'bootstrap must still be silent for fresh agents'); + const entry = session.knownSubagents.get('fresh'); + assert.ok(entry); + assert.equal(entry.completed, false); + assert.equal(entry._completedAt, null); + } finally { + cleanup(tmp); + } +}); + +test('post-bootstrap: a brand-new agent file emits exactly one subagent-spawned event', () => { + const events = setupModule(); + const tmp = mkTmp(); + try { + const sessionId = 'parent'; + // First, bootstrap with empty subagents dir + fs.mkdirSync(path.join(tmp, sessionId, 'subagents'), { recursive: true }); + const session = {}; + detectSubagentTransitions(sessionId, session, tmp); + assert.equal(events.length, 0); + assert.equal(session.knownSubagents.size, 0); + + // Now drop in a new agent file and re-run + seedAgents(tmp, sessionId, [{ id: 'newcomer' }]); + detectSubagentTransitions(sessionId, session, tmp); + + assert.equal(events.length, 1, `expected 1 event, got ${events.length}`); + assert.equal(events[0].channel, 'subagent-spawned'); + assert.equal(events[0].payload.parentSessionId, sessionId); + assert.equal(events[0].payload.agentId, 'newcomer'); + assert.equal(session.knownSubagents.get('newcomer').completed, false); + } finally { + cleanup(tmp); + } +}); + +test('post-bootstrap with no new agents emits zero events (IPC-flood regression)', () => { + const events = setupModule(); + const tmp = mkTmp(); + try { + const sessionId = 'parent'; + seedAgents(tmp, sessionId, [{ id: 'a' }, { id: 'b' }, { id: 'c' }]); + + const session = {}; + // Bootstrap absorbs all three silently + detectSubagentTransitions(sessionId, session, tmp); + assert.equal(events.length, 0); + + // Subsequent flushes with no new files must stay silent + detectSubagentTransitions(sessionId, session, tmp); + detectSubagentTransitions(sessionId, session, tmp); + detectSubagentTransitions(sessionId, session, tmp); + + assert.equal(events.length, 0, 'no events should fire when nothing changed'); + } finally { + cleanup(tmp); + } +}); + +test('completion: agent alive on call N, stable mtime for >30s on call N+1, emits subagent-completed', () => { + const events = setupModule(); + const tmp = mkTmp(); + try { + const sessionId = 'parent'; + const subDir = seedAgents(tmp, sessionId, [{ id: 'slow' }]); + const filePath = path.join(subDir, 'agent-slow.jsonl'); + const mtimeMs = fs.statSync(filePath).mtimeMs; + + // Pre-seed knownSubagents as if a prior call already saw this agent alive + // and started the stability timer 31 seconds ago. This skips bootstrap mode + // since knownSubagents is already defined. + const session = { knownSubagents: new Map() }; + session.knownSubagents.set('slow', { + mtimeMs, // same as file's actual mtime → "mtime stable" + completed: false, + _stableStart: Date.now() - 31_000, // stability started >30s ago + }); + + detectSubagentTransitions(sessionId, session, tmp); + + assert.equal(events.length, 1, `expected 1 completion event, got ${events.length}: ${JSON.stringify(events)}`); + assert.equal(events[0].channel, 'subagent-completed'); + assert.equal(events[0].payload.parentSessionId, sessionId); + assert.equal(events[0].payload.agentId, 'slow'); + assert.equal(session.knownSubagents.get('slow').completed, true); + } finally { + cleanup(tmp); + } +}); From d58be567f3bf3c5e36d4064e1e3a4f43abb68388 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 10:58:00 +0200 Subject: [PATCH 16/66] perf(refresh): O(1) cached lookup in refreshFolder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refreshFolder used a linear scan of cachedMap for every on-disk file (O(N²) on a folder with N cached sessions). For projects with thousands of subagent transcripts this froze the main process on every fs.watch flush — fs.watch fires often while live Claude sessions append JSONL, so the freeze recurred every 500ms (the debounce interval). Build an inverted filePath → dbId index once and look up O(1) per file. Confirmed: 24/24 tests still pass. --- session-cache.js | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/session-cache.js b/session-cache.js index 7d55ed4..0ce3790 100644 --- a/session-cache.js +++ b/session-cache.js @@ -71,13 +71,17 @@ function refreshFolder(folder) { // Get what's currently cached for this folder. // cachedMap: DB sessionId → { modified, filePath } so we can do mtime comparison // even for subagents whose DB sessionId differs from the on-disk filename. + // filePathToDbId: inverted index so the per-file lookup is O(1) — without it, + // refreshing a folder with N cached sessions costs O(N²) per flush (the watcher + // fires frequently while live Claude sessions append JSONL, freezing the main + // process for folders with thousands of subagents). const cachedSessions = getCachedByFolder(folder); - const cachedMap = new Map(); // DB sessionId → { modified, filePath } + const cachedMap = new Map(); + const filePathToDbId = new Map(); for (const row of cachedSessions) { - cachedMap.set(row.sessionId, { - modified: row.modified, - filePath: resolveJsonlPath(PROJECTS_DIR, row), - }); + const filePath = resolveJsonlPath(PROJECTS_DIR, row); + cachedMap.set(row.sessionId, { modified: row.modified, filePath }); + filePathToDbId.set(filePath, row.sessionId); } const currentIds = new Set(); @@ -97,16 +101,8 @@ function refreshFolder(folder) { let fileMtime; try { fileMtime = fs.statSync(filePath).mtime.toISOString(); } catch { continue; } - // Find cached entry by file path (handles both top-level and subagent IDs) - let cachedEntry = null; - let cachedDbId = null; - for (const [dbId, entry] of cachedMap) { - if (entry.filePath === filePath) { - cachedEntry = entry; - cachedDbId = dbId; - break; - } - } + const cachedDbId = filePathToDbId.get(filePath) || null; + const cachedEntry = cachedDbId ? cachedMap.get(cachedDbId) : null; if (cachedDbId !== null) currentIds.add(cachedDbId); From d1f901cf7d6e8d86e43fe1758f0589db09b7b170 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 11:04:49 +0200 Subject: [PATCH 17/66] =?UTF-8?q?perf(refresh):=20targeted=20refresh=20?= =?UTF-8?q?=E2=80=94=20only=20stat=20files=20the=20watcher=20flagged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Even with the O(1) lookup, refreshFolder still ran enumerateSessionFiles (many readdirSyncs) and fs.statSync on every file in the folder on every flush. For projects with thousands of cached subagents and many concurrent active agents writing JSONL, the main process kept blocking on syscalls every 500ms. The watcher already knows which file changed. Plumb that information through to refreshFolder via opts.files; in targeted mode, skip enumerateSessionFiles entirely and only stat the dirty paths. - main.js: pendingFolders (Set) → pendingChanges (Map|true>) - session-cache.js: refreshFolder(folder, { files }) — when files is a non-empty Set, scan only those entries; otherwise full walk - Targeted-mode deletion: rely on per-file ENOENT in statSync, skip the whole-folder GC sweep (no longer accurate when we only saw a subset) - Falls back to full walk for folder-level events / bootstrap 24/24 tests still pass. --- main.js | 43 ++++++++++++++++++++++++------ session-cache.js | 69 ++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 96 insertions(+), 16 deletions(-) diff --git a/main.js b/main.js index 6277269..7081fc5 100644 --- a/main.js +++ b/main.js @@ -1477,20 +1477,31 @@ let projectsWatcher = null; function startProjectsWatcher() { if (!fs.existsSync(PROJECTS_DIR)) return; - const pendingFolders = new Set(); + // pendingChanges: folder → Set | true. + // Set — only the listed files changed (targeted refresh, fast path) + // true — folder-level event or unknown scope, do a full walk + // The watcher reports the specific filename, so for the common case of a + // subagent appending JSONL we can stat one file instead of thousands. + const pendingChanges = new Map(); let debounceTimer = null; function flushChanges() { debounceTimer = null; - const folders = new Set(pendingFolders); - pendingFolders.clear(); + // Drain pendingChanges into a local copy so events arriving during the + // synchronous flush land in a fresh batch for the next tick. + const work = new Map(pendingChanges); + pendingChanges.clear(); let changed = false; - for (const folder of folders) { + for (const [folder, scope] of work) { const folderPath = path.join(PROJECTS_DIR, folder); if (fs.existsSync(folderPath)) { detectSessionTransitions(folder); - refreshFolder(folder); + if (scope === true) { + refreshFolder(folder); + } else { + refreshFolder(folder, { files: scope }); + } } else { deleteCachedFolder(folder); } @@ -1502,6 +1513,20 @@ function startProjectsWatcher() { } } + function recordChange(folder, relPath) { + const existing = pendingChanges.get(folder); + if (existing === true) return; + if (relPath === null) { + pendingChanges.set(folder, true); + return; + } + if (existing instanceof Set) { + existing.add(relPath); + } else { + pendingChanges.set(folder, new Set([relPath])); + } + } + try { projectsWatcher = fs.watch(PROJECTS_DIR, { recursive: true }, (_eventType, filename) => { if (!filename) return; @@ -1511,12 +1536,14 @@ function startProjectsWatcher() { const folder = parts[0]; if (!folder || folder === '.git') return; - // Only care about .jsonl changes or top-level folder add/remove const basename = parts[parts.length - 1]; if (parts.length === 1) { - pendingFolders.add(folder); + // Top-level folder add/remove — must re-scan the whole folder + recordChange(folder, null); } else if (basename.endsWith('.jsonl')) { - pendingFolders.add(folder); + // Specific .jsonl changed — targeted refresh on just this file + const rel = parts.slice(1).join(path.sep); + recordChange(folder, rel); } else { return; } diff --git a/session-cache.js b/session-cache.js index 0ce3790..4aa73df 100644 --- a/session-cache.js +++ b/session-cache.js @@ -54,8 +54,17 @@ function readFolderFromFilesystem(folder) { return { projectPath, sessions }; } -/** Refresh a single folder incrementally: only re-read changed/new .jsonl files */ -function refreshFolder(folder) { +/** Refresh a single folder incrementally: only re-read changed/new .jsonl files. + * + * @param {string} folder folder name relative to PROJECTS_DIR + * @param {object} [opts] + * @param {Set|null} [opts.files] if provided, ONLY scan these on-disk + * relative paths within the folder instead of walking everything. Used by the + * fs.watch flush to avoid statSync'ing thousands of files when only a handful + * of subagent transcripts were appended. When null/undefined, walk the whole + * folder (used for bootstrap and folder-level events). + */ +function refreshFolder(folder, opts = {}) { const folderPath = path.join(PROJECTS_DIR, folder); if (!fs.existsSync(folderPath)) { deleteCachedFolder(folder); @@ -84,6 +93,32 @@ function refreshFolder(folder) { filePathToDbId.set(filePath, row.sessionId); } + // Targeted refresh: walk only the files the watcher said changed, not the + // entire folder. Skips enumerateSessionFiles (which does many readdirSyncs on + // every subagent subdir) and only stats the dirty files. Falls back to full + // walk when opts.files is omitted (bootstrap / folder-level events / cold + // delete-detection). + const targeted = opts.files instanceof Set && opts.files.size > 0; + let filesToScan; + if (targeted) { + filesToScan = []; + for (const rel of opts.files) { + const filePath = path.join(folderPath, rel); + // Derive parentSessionId for subagent paths: //subagents/agent-X.jsonl + const parts = rel.split(path.sep); + let parentSessionId = null; + if (parts.length === 3 && parts[1] === 'subagents') { + parentSessionId = parts[0]; + } else if (parts.length === 2) { + // legacy //agent-X.jsonl layout (no subagents/ subdir) + parentSessionId = parts[0]; + } + filesToScan.push({ filePath, parentSessionId }); + } + } else { + filesToScan = enumerateSessionFiles(folderPath); + } + const currentIds = new Set(); let changed = false; @@ -93,7 +128,7 @@ function refreshFolder(folder) { const namesToSet = []; const sessionsToDelete = []; - for (const { filePath, parentSessionId } of enumerateSessionFiles(folderPath)) { + for (const { filePath, parentSessionId } of filesToScan) { // Check if file mtime changed. // We need the DB sessionId to look up the cache, but we don't know it until after // readSessionFile — for subagents it's sub::. Use the file path @@ -128,11 +163,29 @@ function refreshFolder(folder) { changed = true; } - // Remove sessions whose .jsonl files were deleted - for (const sessionId of cachedMap.keys()) { - if (!currentIds.has(sessionId)) { - sessionsToDelete.push(sessionId); - changed = true; + // Remove sessions whose .jsonl files were deleted. Skip in targeted mode — + // we only stat'd the dirty files, so cachedMap entries not in currentIds + // weren't checked and may still exist on disk. Targeted-mode deletions are + // handled by the watcher path-stat: missing files surface via statSync's + // ENOENT in the loop above and produce no upsert. A full walk picks up any + // drift on the next folder-level event. + if (!targeted) { + for (const sessionId of cachedMap.keys()) { + if (!currentIds.has(sessionId)) { + sessionsToDelete.push(sessionId); + changed = true; + } + } + } else { + // Targeted mode still needs to delete entries for files explicitly deleted + // in this flush — detected by statSync failing on a path we tried to scan. + for (const { filePath } of filesToScan) { + const dbId = filePathToDbId.get(filePath); + if (!dbId) continue; + try { fs.statSync(filePath); } catch { + sessionsToDelete.push(dbId); + changed = true; + } } } From d32dcaeaad724356f7e28444189c448d7aa0b110 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 11:09:44 +0200 Subject: [PATCH 18/66] perf(refresh): bump-only update for huge cached files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live Claude session JSONLs grow without bound — observed a 218 MB host-session file in a real workload. refreshFolder ran fs.readFileSync + JSON.parse on the full file every time the watcher fired (every ~500 ms while the session was being written), making the main process freeze for 1-2 seconds at a time. When a file is already cached and now exceeds HUGE_FILE_BYTES (5 MB), skip the re-read entirely: just bump the modified timestamp in the DB so the sidebar shows activity. Summary/slug/title were captured when the file was smaller and rarely change after the first turn; the next cold start or a shrink below threshold refreshes them. Adds db.touchCachedModified(sessionId, modified) — a one-row UPDATE that avoids the 14-column upsert when only mtime matters. 24/24 tests still pass. --- db.js | 2 ++ session-cache.js | 27 ++++++++++++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/db.js b/db.js index eaef03e..b2149e6 100644 --- a/db.js +++ b/db.js @@ -187,6 +187,7 @@ const stmts = { cacheGetSession: db.prepare('SELECT * FROM session_cache WHERE sessionId = ?'), cacheDeleteSession: db.prepare('DELETE FROM session_cache WHERE sessionId = ?'), cacheDeleteFolder: db.prepare('DELETE FROM session_cache WHERE folder = ?'), + cacheTouchModified: db.prepare('UPDATE session_cache SET modified = ? WHERE sessionId = ?'), // Cache meta statements metaGet: db.prepare('SELECT * FROM cache_meta WHERE folder = ?'), metaGetAll: db.prepare('SELECT * FROM cache_meta'), @@ -404,6 +405,7 @@ function closeDb() { module.exports = { getMeta, getAllMeta, setName, toggleStar, setArchived, isCachePopulated, getAllCached, getCachedByFolder, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions, + touchCachedModified: (sessionId, modified) => stmts.cacheTouchModified.run(modified, sessionId), deleteCachedSession, deleteCachedFolder, getFolderMeta, getAllFolderMeta, setFolderMeta, upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType, diff --git a/session-cache.js b/session-cache.js index 4aa73df..86c1b31 100644 --- a/session-cache.js +++ b/session-cache.js @@ -11,7 +11,7 @@ const { encodeProjectPath } = require('./encode-project-path'); * Call init(ctx) once with the shared context object. */ let PROJECTS_DIR, activeSessions, getMainWindow, log; -let deleteCachedFolder, getCachedByFolder, upsertCachedSessions, deleteCachedSession; +let deleteCachedFolder, getCachedByFolder, upsertCachedSessions, deleteCachedSession, touchCachedModified; let deleteSearchFolder, deleteSearchSession, upsertSearchEntries; let setFolderMeta, getAllFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName; @@ -24,6 +24,7 @@ function init(ctx) { deleteCachedFolder = ctx.db.deleteCachedFolder; getCachedByFolder = ctx.db.getCachedByFolder; upsertCachedSessions = ctx.db.upsertCachedSessions; + touchCachedModified = ctx.db.touchCachedModified; deleteCachedSession = ctx.db.deleteCachedSession; deleteSearchFolder = ctx.db.deleteSearchFolder; deleteSearchSession = ctx.db.deleteSearchSession; @@ -128,13 +129,25 @@ function refreshFolder(folder, opts = {}) { const namesToSet = []; const sessionsToDelete = []; + // Skip the full re-read for already-cached files above this size. Live + // Claude session JSONLs grow without bound (can exceed 200 MB); re-reading + // and JSON.parsing the whole thing on every fs.watch flush froze the main + // process. The cached metadata (summary, slug, customTitle) was captured + // when the file was small enough to read, and rarely changes after the + // first turn anyway — bump the mtime in the DB so the sidebar reflects + // activity, and trust the next cold-start (or a smaller file) to refresh + // the rest. Subagent transcripts are usually small and stay under this + // threshold; the host conversation's own JSONL is the typical offender. + const HUGE_FILE_BYTES = 5 * 1024 * 1024; + for (const { filePath, parentSessionId } of filesToScan) { // Check if file mtime changed. // We need the DB sessionId to look up the cache, but we don't know it until after // readSessionFile — for subagents it's sub::. Use the file path // to find a matching cached entry instead. - let fileMtime; - try { fileMtime = fs.statSync(filePath).mtime.toISOString(); } catch { continue; } + let stat; + try { stat = fs.statSync(filePath); } catch { continue; } + const fileMtime = stat.mtime.toISOString(); const cachedDbId = filePathToDbId.get(filePath) || null; const cachedEntry = cachedDbId ? cachedMap.get(cachedDbId) : null; @@ -145,6 +158,14 @@ function refreshFolder(folder, opts = {}) { continue; // unchanged, skip } + // Huge cached file: bump mtime only, skip the multi-hundred-MB readFileSync. + if (cachedEntry && stat.size > HUGE_FILE_BYTES) { + touchCachedModified(cachedDbId, fileMtime); + cachedEntry.modified = fileMtime; + changed = true; + continue; + } + // File is new or modified — re-read it const s = readSessionFile(filePath, folder, projectPath, { parentSessionId }); if (s) { From ba1d55ab120332402b3989647a7b593aa5465371 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 11:15:50 +0200 Subject: [PATCH 19/66] perf(refresh): header-only read for cached sessions, full read for new ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: "for display we shouldn't need to go that deep into the file — full read should only happen for search". Implements that split. readSessionDisplayHeader: stream-reads the first ~256 KB / 500 lines and extracts only what the sidebar needs (summary, slug, customTitle, aiTitle, agentId, isSidechain marker, subagent sidecar). No textContent, no messageCount. ~1 ms even for a 200 MB host-session JSONL. refreshFolder flow: - NEW file (no cache row) → full readSessionFile, seeds FTS body - EXISTING file → header-only refresh, merges fresh display fields with cached body. NO FTS write — search index for live sessions lags until cold-start - Header fails → mtime-only touch as last resort cacheGetByFolder widened to SELECT * so refresh can merge unchanged fields (created, messageCount, textContent) without re-reading. Drops the HUGE_FILE_BYTES hack from the previous commit — the header approach handles size uniformly so no special-casing. 24/24 tests still pass. --- db.js | 2 +- read-session-file.js | 90 +++++++++++++++++++++++++++++++++++++++++++- session-cache.js | 66 +++++++++++++++++++++----------- 3 files changed, 134 insertions(+), 24 deletions(-) diff --git a/db.js b/db.js index b2149e6..8203606 100644 --- a/db.js +++ b/db.js @@ -182,7 +182,7 @@ const stmts = { subagentType = excluded.subagentType, description = excluded.description `), cacheGetByParent: db.prepare('SELECT * FROM session_cache WHERE parentSessionId = ? ORDER BY created ASC'), - cacheGetByFolder: db.prepare('SELECT sessionId, modified, parentSessionId, agentId FROM session_cache WHERE folder = ?'), + cacheGetByFolder: db.prepare('SELECT * FROM session_cache WHERE folder = ?'), 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 = ?'), diff --git a/read-session-file.js b/read-session-file.js index d304d16..e8cc41d 100644 --- a/read-session-file.js +++ b/read-session-file.js @@ -184,4 +184,92 @@ function enumerateSessionFiles(folderPath) { return out; } -module.exports = { readSessionFile, subagentSessionId, resolveJsonlPath, readSubagentMeta, enumerateSessionFiles }; +/** Lightweight refresh path. Reads only the first ~256 KB / 500 lines of a + * jsonl file to extract display-level metadata (summary, slug, titles, + * agentId). Does NOT compute textContent or messageCount — the caller is + * expected to merge with the cached row for unchanged fields. Designed so + * the fs.watch flush can update a live 200+ MB host-session JSONL in ~ms + * instead of seconds. + * + * Returns the same shape as the display subset of readSessionFile() so it + * can be merged into a cached row before upsert. Returns null if the chunk + * doesn't yet contain a usable first-user-message. + */ +function readSessionDisplayHeader(filePath, opts = {}) { + const fileBase = path.basename(filePath, '.jsonl'); + const isSubagent = Boolean(opts.parentSessionId); + const MAX_BYTES = 256 * 1024; + const MAX_LINES = 500; + try { + const stat = fs.statSync(filePath); + const readLen = Math.min(MAX_BYTES, stat.size); + const fd = fs.openSync(filePath, 'r'); + const buf = Buffer.alloc(readLen); + const n = fs.readSync(fd, buf, 0, readLen, 0); + fs.closeSync(fd); + const text = buf.toString('utf8', 0, n); + const lines = text.split('\n'); + // Drop the potentially-partial last line unless we read the whole file + if (n < stat.size) lines.pop(); + + let summary = ''; + let slug = null, customTitle = null, aiTitle = null, agentId = null; + let sidechainSeen = false; + let lineCount = 0; + for (const line of lines) { + if (!line) continue; + if (++lineCount > MAX_LINES) break; + let entry; + try { entry = JSON.parse(line); } catch { continue; } + if (entry.slug && !slug) slug = entry.slug; + if (entry.agentId && !agentId) agentId = entry.agentId; + if (entry.isSidechain) sidechainSeen = true; + if (entry.type === 'custom-title' && entry.customTitle && !customTitle) customTitle = entry.customTitle; + if (entry.type === 'ai-title' && entry.aiTitle && !aiTitle) aiTitle = entry.aiTitle; + const msg = entry.message; + const txt = typeof msg === 'string' ? msg : + (typeof msg?.content === 'string' ? msg.content : + (msg?.content?.[0]?.text || '')); + if (!summary && (entry.type === 'user' || (entry.type === 'message' && entry.role === 'user'))) { + if (txt && !/||/.test(txt)) { + const taskMatch = txt.match(/:. Use the file path - // to find a matching cached entry instead. let stat; try { stat = fs.statSync(filePath); } catch { continue; } const fileMtime = stat.mtime.toISOString(); @@ -158,18 +157,41 @@ function refreshFolder(folder, opts = {}) { continue; // unchanged, skip } - // Huge cached file: bump mtime only, skip the multi-hundred-MB readFileSync. - if (cachedEntry && stat.size > HUGE_FILE_BYTES) { - touchCachedModified(cachedDbId, fileMtime); - cachedEntry.modified = fileMtime; + if (cachedEntry) { + // EXISTING — header-only refresh. + const h = readSessionDisplayHeader(filePath, { parentSessionId }); + if (h) { + // Merge: keep cached body/messageCount/created, overlay fresh display fields. + const merged = { + ...cachedEntry, + folder, projectPath, + summary: h.summary || cachedEntry.summary, + firstPrompt: h.firstPrompt || cachedEntry.firstPrompt, + modified: fileMtime, + slug: h.slug || cachedEntry.slug, + aiTitle: h.aiTitle || cachedEntry.aiTitle, + parentSessionId: h.parentSessionId || cachedEntry.parentSessionId, + agentId: h.agentId || cachedEntry.agentId, + subagentType: h.subagentType || cachedEntry.subagentType, + description: h.description || cachedEntry.description, + }; + sessionsToUpsert.push(merged); + if (h.customTitle && h.customTitle !== cachedEntry.customTitle) { + namesToSet.push({ id: merged.sessionId, name: h.customTitle }); + } + } else { + // Header read couldn't extract signal — just bump mtime so sort order stays current. + touchCachedModified(cachedDbId, fileMtime); + cachedEntry.modified = fileMtime; + } changed = true; continue; } - // File is new or modified — re-read it + // NEW file — full readSessionFile so the FTS index gets seeded. const s = readSessionFile(filePath, folder, projectPath, { parentSessionId }); if (s) { - currentIds.add(s.sessionId); // ensure we don't delete a newly-read subagent row + currentIds.add(s.sessionId); sessionsToUpsert.push(s); // Title precedence: user rename (session_meta.name) > JSONL custom-title > JSONL ai-title. // Only customTitle (Claude /title) promotes to session_meta.name — AI titles must NEVER From 65c30dad9cca4d9e45cbf7cd29342b256c2aa722 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 11:41:47 +0200 Subject: [PATCH 20/66] feat(search): explicit reindex via Enter or refresh button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header-only refresh leaves search_fts stale for active sessions — content typed after the last cold-start isn't indexed. Adds a user trigger that runs the full worker re-scan (which rewrites FTS from the live JSONL tails) and then re-fires the current query. UI - New refresh button inside the search bar (circular-arrow icon) - Enter in the search input triggers the same path - Spinner on the button while reindexing - Search debounce bumped from 200ms to 350ms (gentler under heavy concurrent workloads) Wiring - main.js: ipcMain.handle('rebuild-cache') → populateCacheViaWorker - preload.js: window.api.rebuildCache() - app.js: runSearchQuery() extracted; triggerRebuildAndSearch() serialises rebuild + refire and guards against double-clicks 24/24 tests still pass. --- main.js | 15 ++++++ preload.js | 1 + public/app.js | 122 ++++++++++++++++++++++++++++++---------------- public/index.html | 1 + public/style.css | 17 +++++++ 5 files changed, 113 insertions(+), 43 deletions(-) diff --git a/main.js b/main.js index 7081fc5..5f3ef6a 100644 --- a/main.js +++ b/main.js @@ -496,6 +496,21 @@ ipcMain.handle('unwatch-file', (_event, filePath) => { return { ok: true }; }); +// Full re-scan triggered from the UI. Re-reads every jsonl file in the worker +// thread, which is the only path that rebuilds search_fts with the live tail +// of active sessions (refreshFolder uses a header-only read by design — see +// session-cache.js). Concurrent callers share the same in-flight worker via +// populateCacheViaWorker's internal Promise. +ipcMain.handle('rebuild-cache', async () => { + try { + await populateCacheViaWorker(); + return { ok: true }; + } catch (err) { + console.error('Error rebuilding cache:', err); + return { ok: false, error: err.message }; + } +}); + ipcMain.handle('get-projects', async (_event, showArchived) => { try { const needsPopulate = !isCachePopulated() || !isSearchIndexPopulated(); diff --git a/preload.js b/preload.js index 3341ab4..7ace90e 100644 --- a/preload.js +++ b/preload.js @@ -12,6 +12,7 @@ contextBridge.exposeInMainWorld('api', { readMemory: (filePath) => ipcRenderer.invoke('read-memory', filePath), saveMemory: (filePath, content) => ipcRenderer.invoke('save-memory', filePath, content), getProjects: (showArchived) => ipcRenderer.invoke('get-projects', showArchived), + rebuildCache: () => ipcRenderer.invoke('rebuild-cache'), getActiveSessions: () => ipcRenderer.invoke('get-active-sessions'), getActiveTerminals: () => ipcRenderer.invoke('get-active-terminals'), stopSession: (id) => ipcRenderer.invoke('stop-session', id), diff --git a/public/app.js b/public/app.js index 9ab01b8..3ee713f 100644 --- a/public/app.js +++ b/public/app.js @@ -461,56 +461,92 @@ searchClear.addEventListener('click', () => { searchInput.focus(); }); +// Extracted so the rebuild-cache button and Enter handler can call it too. +async function runSearchQuery() { + const query = searchInput.value.trim(); + if (!query) { + clearSearch(); + return; + } + try { + if (activeTab === 'sessions') { + const results = await window.api.search('session', query, searchTitlesOnly); + searchMatchIds = new Set(results.map(r => r.id)); + searchMatchProjectPaths = null; + if (searchTitlesOnly) { + const lowerQ = query.toLowerCase(); + for (const p of cachedAllProjects) { + const shortName = p.projectPath.split('/').filter(Boolean).slice(-2).join('/'); + if (shortName.toLowerCase().includes(lowerQ)) { + if (!searchMatchProjectPaths) searchMatchProjectPaths = new Set(); + searchMatchProjectPaths.add(p.projectPath); + } + } + } + refreshSidebar({ resort: true }); + } else if (activeTab === 'plans') { + const results = await window.api.search('plan', query, searchTitlesOnly); + const matchIds = new Set(results.map(r => r.id)); + renderPlans(cachedPlans.filter(p => matchIds.has(p.filename))); + } else if (activeTab === 'memory') { + const results = await window.api.search('memory', query, searchTitlesOnly); + const matchIds = new Set(results.map(r => r.id)); + renderMemories(matchIds); + } + } catch { + if (activeTab === 'sessions') { + searchMatchIds = null; + searchMatchProjectPaths = null; + refreshSidebar({ resort: true }); + } + } +} + +// Debounced search-as-you-type. Bumped from 200ms to 350ms — gentler under +// heavy workloads (many active subagents) and gives the user time to finish +// a word before searching. Explicit triggers (Enter, refresh button) bypass +// the debounce. searchInput.addEventListener('input', () => { - // Toggle clear button visibility searchBar.classList.toggle('has-query', searchInput.value.length > 0); - if (searchDebounceTimer) clearTimeout(searchDebounceTimer); - searchDebounceTimer = setTimeout(async () => { + searchDebounceTimer = setTimeout(() => { searchDebounceTimer = null; - const query = searchInput.value.trim(); - - if (!query) { - clearSearch(); - return; - } + runSearchQuery(); + }, 350); +}); - try { - if (activeTab === 'sessions') { - const results = await window.api.search('session', query, searchTitlesOnly); - searchMatchIds = new Set(results.map(r => r.id)); - // When title-only, also match project names - searchMatchProjectPaths = null; - if (searchTitlesOnly) { - const lowerQ = query.toLowerCase(); - for (const p of cachedAllProjects) { - const shortName = p.projectPath.split('/').filter(Boolean).slice(-2).join('/'); - if (shortName.toLowerCase().includes(lowerQ)) { - if (!searchMatchProjectPaths) searchMatchProjectPaths = new Set(); - searchMatchProjectPaths.add(p.projectPath); - } - } - } - refreshSidebar({ resort: true }); - } else if (activeTab === 'plans') { - const results = await window.api.search('plan', query, searchTitlesOnly); - const matchIds = new Set(results.map(r => r.id)); - renderPlans(cachedPlans.filter(p => matchIds.has(p.filename))); - } else if (activeTab === 'memory') { - const results = await window.api.search('memory', query, searchTitlesOnly); - const matchIds = new Set(results.map(r => r.id)); - renderMemories(matchIds); - } - } catch { - if (activeTab === 'sessions') { - searchMatchIds = null; - searchMatchProjectPaths = null; - refreshSidebar({ resort: true }); - } - } - }, 200); +// Enter in the search field = "I want fresh results": trigger a full worker +// reindex (which rewrites search_fts with the live content of active session +// JSONLs), then re-run the query. Pending debounce gets cancelled. +searchInput.addEventListener('keydown', async (e) => { + if (e.key !== 'Enter') return; + e.preventDefault(); + if (searchDebounceTimer) { clearTimeout(searchDebounceTimer); searchDebounceTimer = null; } + await triggerRebuildAndSearch(); }); +// Refresh button in the search bar — same behavior as pressing Enter. +const searchRefreshBtn = document.getElementById('search-refresh-btn'); +if (searchRefreshBtn) { + searchRefreshBtn.addEventListener('click', () => triggerRebuildAndSearch()); +} + +let rebuildInFlight = false; +async function triggerRebuildAndSearch() { + if (rebuildInFlight) return; + rebuildInFlight = true; + if (searchRefreshBtn) searchRefreshBtn.classList.add('spinning'); + try { + await window.api.rebuildCache(); + } catch {} + finally { + rebuildInFlight = false; + if (searchRefreshBtn) searchRefreshBtn.classList.remove('spinning'); + } + // After reindex, refire the current query so the user sees fresh hits. + await runSearchQuery(); +} + // --- Stop session helper --- async function confirmAndStopSession(sessionId) { if (!confirm('Stop this session?')) return; diff --git a/public/index.html b/public/index.html index 8484857..6415556 100644 --- a/public/index.html +++ b/public/index.html @@ -34,6 +34,7 @@ +

diff --git a/public/style.css b/public/style.css index 39212bc..c8ca491 100644 --- a/public/style.css +++ b/public/style.css @@ -341,6 +341,23 @@ body { display: flex; flex-direction: column; } color: #ffffff; } +#search-refresh-btn { + position: absolute; + right: 42px; + top: 0; + height: calc(100% - 14px); + display: flex; + align-items: center; + background: none; + border: none; + color: #5a5a70; + cursor: pointer; + padding: 0 4px; + transition: color 0.15s; +} +#search-refresh-btn:hover { color: #b0b0c4; } +#search-refresh-btn.spinning svg { animation: spin-dot 0.8s linear infinite; } + /* ---- Scrollable content ---- */ #sidebar-content { From 206f393ba7a300d8464fcd6b5920c9a4e95e7492 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 12:02:01 +0200 Subject: [PATCH 21/66] fix(sidebar): destructure subagentIndex (empty-sidebar regression) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renderer threw ReferenceError on every project iteration because the result destructure at sidebar.js:438 was missing 'subagentIndex' even though processProjectSessions returns it and buildSessionsList expects it at line 477. The error aborted the project loop, leaving the sidebar blank while the backend correctly returned 13 projects / 1500+ sessions. Pre-existing bug from PR#2's hierarchical sidebar — became visible now because every project in this workload has subagents. Also nudges #search-refresh-btn from right:42px to right:60px so it no longer overlaps with #search-clear (the × button at right:40px). --- public/sidebar.js | 2 +- public/style.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/sidebar.js b/public/sidebar.js index e16615d..3d63900 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -435,7 +435,7 @@ function renderProjects(projects, resort) { const result = processProjectSessions(project, resort); if (!result) continue; - const { filtered, visible, older, sortOrderEntry } = result; + const { filtered, visible, older, subagentIndex, sortOrderEntry } = result; newSortedOrder.push(sortOrderEntry); const fId = folderId(project.projectPath); diff --git a/public/style.css b/public/style.css index c8ca491..11f4b19 100644 --- a/public/style.css +++ b/public/style.css @@ -343,7 +343,7 @@ body { display: flex; flex-direction: column; } #search-refresh-btn { position: absolute; - right: 42px; + right: 60px; top: 0; height: calc(100% - 14px); display: flex; From 50f8743193f85ebfe7fb88c29111d96ac2568c2f Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 13:11:15 +0200 Subject: [PATCH 22/66] feat(sidebar): collapsible 'Orphan subagents' section, collapsed by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On long-lived projects the orphan-subagent list can grow huge (>1000 in this session's host project) and pushes the rest of the project out of view. Default the section to collapsed and let the user toggle it with a click on the label. Adds a right-pointing caret that rotates 90° when expanded and a per-project state in localStorage so the choice sticks across reloads. - localStorage key: 'orphanExpanded:' = '0' | '1' - Default: collapsed (no key set) - Label format: '▸ Orphan subagents ' --- public/sidebar.js | 16 ++++++++++++++-- public/style.css | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/public/sidebar.js b/public/sidebar.js index 3d63900..a1ed0bb 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -413,12 +413,24 @@ function renderProjects(projects, resort) { } } if (orphans.length > 0) { + // Persist expand/collapse per project. Default = collapsed: this + // section is rarely the user's focus and can grow long on long-lived + // projects (this very session has 1300+ orphan subagents). + const orphanStateKey = 'orphanExpanded:' + project.projectPath; + const expanded = localStorage.getItem(orphanStateKey) === '1'; + const orphanGroup = document.createElement('div'); - orphanGroup.className = 'sidebar-orphan-subagents'; + orphanGroup.className = 'sidebar-orphan-subagents' + (expanded ? '' : ' collapsed'); + const orphanLabel = document.createElement('div'); orphanLabel.className = 'sidebar-orphan-label'; - orphanLabel.textContent = 'Orphan subagents'; + orphanLabel.innerHTML = ` Orphan subagents ${orphans.length}`; + orphanLabel.addEventListener('click', () => { + const isCollapsed = orphanGroup.classList.toggle('collapsed'); + localStorage.setItem(orphanStateKey, isCollapsed ? '0' : '1'); + }); orphanGroup.appendChild(orphanLabel); + for (const orphan of orphans) { orphanGroup.appendChild(buildSubagentItem(orphan)); } diff --git a/public/style.css b/public/style.css index 11f4b19..31c85da 100644 --- a/public/style.css +++ b/public/style.css @@ -1584,6 +1584,27 @@ body { display: flex; flex-direction: column; } color: #6a6a80; text-transform: uppercase; letter-spacing: 0.5px; + cursor: pointer; + user-select: none; + display: flex; + align-items: center; + gap: 4px; +} +.sidebar-orphan-label:hover { color: #b0b0c4; } +.sidebar-orphan-label .orphan-caret { + display: inline-block; + transition: transform 0.12s; + font-size: 10px; +} +.sidebar-orphan-subagents:not(.collapsed) .orphan-caret { + transform: rotate(90deg); +} +.sidebar-orphan-label .orphan-count { + margin-left: 4px; + opacity: 0.6; +} +.sidebar-orphan-subagents.collapsed > :not(.sidebar-orphan-label) { + display: none; } /* ========== SUBAGENT GRID PILLS ========== */ From 69476e9ca948f0ff51e09dd9d822a3aaf3c9c0ed Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 13:23:26 +0200 Subject: [PATCH 23/66] fix(sidebar): scope projectPath into buildSessionsList for orphan toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit referenced 'project.projectPath' inside buildSessionsList, which never had 'project' in scope — runtime ReferenceError on every render, sidebar blank again. Pass projectPath as an argument from both call sites (regular projects and worktrees). Also leaves the renderer console→main bridge in place under mainWindow.webContents 'console-message' — paid for itself twice now. --- main.js | 3 +++ public/sidebar.js | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/main.js b/main.js index 5f3ef6a..ce5f058 100644 --- a/main.js +++ b/main.js @@ -129,6 +129,9 @@ function createWindow() { } mainWindow.loadFile(path.join(__dirname, 'public', 'index.html')); + mainWindow.webContents.on('console-message', (_e, level, message, line, sourceId) => { + if (level >= 2) log.error(`[renderer:${level}] ${sourceId}:${line} ${message}`); + }); // Open external links in the system browser instead of a child BrowserWindow mainWindow.webContents.setWindowOpenHandler(({ url }) => { diff --git a/public/sidebar.js b/public/sidebar.js index a1ed0bb..7ca2bc4 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -375,7 +375,7 @@ function renderProjects(projects, resort) { } // Build the sessions list DOM (shared between projects and worktrees) - function buildSessionsList(fId, visible, older, subagentIndex) { + function buildSessionsList(fId, visible, older, subagentIndex, projectPath) { const sessionsList = document.createElement('div'); sessionsList.className = 'project-sessions'; sessionsList.id = 'sessions-' + fId; @@ -416,7 +416,7 @@ function renderProjects(projects, resort) { // Persist expand/collapse per project. Default = collapsed: this // section is rarely the user's focus and can grow long on long-lived // projects (this very session has 1300+ orphan subagents). - const orphanStateKey = 'orphanExpanded:' + project.projectPath; + const orphanStateKey = 'orphanExpanded:' + projectPath; const expanded = localStorage.getItem(orphanStateKey) === '1'; const orphanGroup = document.createElement('div'); @@ -486,7 +486,7 @@ function renderProjects(projects, resort) { newBtn.title = 'New session'; header.appendChild(newBtn); - const sessionsList = buildSessionsList(fId, visible, older, subagentIndex); + const sessionsList = buildSessionsList(fId, visible, older, subagentIndex, project.projectPath); // Auto-collapse if most recent session is older than threshold, or project matched with no sessions if (project._projectMatchedOnly) { @@ -538,7 +538,7 @@ function renderProjects(projects, resort) { wtNewBtn.title = 'New session in worktree'; wtHeader.appendChild(wtNewBtn); - const wtSessionsList = buildSessionsList(wtFId, wtResult.visible, wtResult.older, wtResult.subagentIndex); + const wtSessionsList = buildSessionsList(wtFId, wtResult.visible, wtResult.older, wtResult.subagentIndex, wt.projectPath); wtSessionsList.className = 'worktree-sessions'; // Auto-collapse worktree if stale From e76a3df05ac2d4842c0cf18477725a9478760e19 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 13:32:44 +0200 Subject: [PATCH 24/66] perf(ui): throttle projects-changed notifies + renderer debounce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live JSONL writes fire the watcher every ~500ms, triggering notifyRendererProjectsChanged on each flush. Even with the header-only refresh, the renderer was re-fetching projects and running morphdom diff over 100+ session items at that cadence, producing visible sidebar flicker. User flagged it as 'UI glitch on left side during refresh'. - session-cache.js: leading-edge throttle on notifyRendererProjectsChanged, 1.5s cooldown with trailing flush so the first change is instant but bursts coalesce. - public/app.js: bump renderer debounce 300ms → 900ms. Combined with the main-side throttle the sidebar redraws at most ~1×/sec under heavy load. --- public/app.js | 5 ++++- session-cache.js | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/public/app.js b/public/app.js index 3ee713f..caa40c2 100644 --- a/public/app.js +++ b/public/app.js @@ -1083,10 +1083,13 @@ window.api.onProjectsChanged(() => { projectsChangedWhileAway = true; return; } + // 300ms debounce was visibly flickering while live JSONLs trigger watcher + // flushes every ~500ms; with the main-side notify throttle (1.5s) too the + // sidebar redraws at most ~1×/sec. projectsChangedTimer = setTimeout(() => { projectsChangedTimer = null; loadProjects(); - }, 300); + }, 900); }); // Status bar diff --git a/session-cache.js b/session-cache.js index 08cc1ff..fb606d9 100644 --- a/session-cache.js +++ b/session-cache.js @@ -387,11 +387,27 @@ function buildProjectsFromCache(showArchived) { } +// Throttle projects-changed IPC: live sessions appending JSONL trigger a flush +// every ~500ms; without throttling the renderer re-runs getProjects + morphdom +// over 100+ items at that cadence, producing visible flicker. Leading-edge fire +// + trailing flush so the first change is instant but subsequent bursts coalesce. +const NOTIFY_THROTTLE_MS = 1500; +let _notifyCooldown = false; +let _notifyPending = false; function notifyRendererProjectsChanged() { + if (_notifyCooldown) { _notifyPending = true; return; } const mainWindow = getMainWindow(); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('projects-changed'); } + _notifyCooldown = true; + setTimeout(() => { + _notifyCooldown = false; + if (_notifyPending) { + _notifyPending = false; + notifyRendererProjectsChanged(); + } + }, NOTIFY_THROTTLE_MS); } function sendStatus(text, type) { From 1a6256f3d892f4ebbe73c0f0552a0cd5ebe45a1c Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 13:32:03 +0200 Subject: [PATCH 25/66] chore: add Taskfile.yaml as standard task entrypoint Wraps npm scripts under task(1) with named tasks (install, dev, build, test, lint, check, ci, clean, db:reset, install:lint). Updates README with a Tooling section documenting the preferred workflow. --- README.md | 37 ++++++++++++++++++---- Taskfile.yaml | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 Taskfile.yaml diff --git a/README.md b/README.md index 3d23a6d..f07b504 100644 --- a/README.md +++ b/README.md @@ -78,14 +78,38 @@ Grab the latest release for your platform: - **Linux**: `build-essential`, `python3` (`sudo apt install build-essential python3`) - **Windows**: Visual Studio Build Tools or `npm install -g windows-build-tools` +## Tooling + +[task](https://taskfile.dev) is the preferred entrypoint for all dev operations. Install it once (`brew install go-task` / `snap install task --classic` / see taskfile.dev for other platforms), then: + +```bash +task install # npm install +task dev # launch Electron (--no-sandbox, required on Linux) +task test # node --test (24 tests) +task lint # eslint . +task check # test + lint — pre-commit / pre-push gate +task ci # same as check but sequential, verbose +task build # npm run build:linux +task clean # wipe dist/, codemirror bundle, local DB (asks for confirmation) +task db:reset # wipe ~/.switchboard/switchboard.db only +``` + +Run `task` (no args) to list all tasks with descriptions. + +The npm scripts are still present and work as before; `task` just wraps them as a consistent entrypoint. + ## Development Setup ```bash -# Install dependencies (runs postinstall automatically) -npm install +task install # install dependencies (runs postinstall automatically) +task dev # launch Electron +``` -# Start the app -npm start +Or with npm directly: + +```bash +npm install +npm start # bundles CodeMirror then launches Electron ``` `npm start` bundles CodeMirror and launches Electron. For faster iteration after the first run: @@ -99,10 +123,9 @@ npm run electron All build commands bundle CodeMirror first, then invoke electron-builder. ```bash -# Current platform -npm run build +task build # AppImage + deb (Linux) -# Platform-specific +# npm equivalents: npm run build:mac # DMG + zip (arm64 + x64) npm run build:win # NSIS installer (x64 + arm64) npm run build:linux # AppImage + deb (x64 + arm64) diff --git a/Taskfile.yaml b/Taskfile.yaml new file mode 100644 index 0000000..7d0b723 --- /dev/null +++ b/Taskfile.yaml @@ -0,0 +1,88 @@ +# https://taskfile.dev + +version: "3" + +set: [pipefail] + +vars: + SWITCHBOARD_DB: "{{.HOME}}/.switchboard/switchboard.db" + +tasks: + default: + desc: List all available tasks + silent: true + cmds: + - task --list + + install: + desc: Install npm dependencies + cmds: + - npm install + + dev: + desc: Launch Switchboard in development mode (Electron, no-sandbox) + silent: true + cmds: + - npx electron . --no-sandbox + + build: + desc: Build Linux distribution packages (AppImage + deb) + silent: true + cmds: + - npm run build:linux + + test: + desc: Run the node:test suite (24 tests) + cmds: + - npm test + + lint: + desc: Run ESLint across the codebase + cmds: + - | + if [ ! -f eslint.config.js ] && [ ! -f .eslintrc.js ] && [ ! -f .eslintrc.json ] && [ ! -f .eslintrc.yaml ] && [ ! -f .eslintrc.yml ]; then + echo "ESLint not yet configured; run \`task install:lint\` first" + exit 1 + fi + npx eslint . + + "install:lint": + desc: Install ESLint + jsdom dev deps (idempotent) + cmds: + - | + MISSING="" + node -e "require('eslint')" 2>/dev/null || MISSING="$MISSING eslint" + node -e "require('jsdom')" 2>/dev/null || MISSING="$MISSING jsdom" + if [ -n "$MISSING" ]; then + npm install --save-dev $MISSING + else + echo "ESLint and jsdom already installed — nothing to do." + fi + + check: + desc: Run tests + lint (pre-commit / pre-push gate) + deps: [test, lint] + + ci: + desc: CI gate — runs test then lint, verbose, exits on first failure + cmds: + - npm test + - npx eslint . + + clean: + desc: Remove dist/, codemirror bundle, and local DB (asks for confirmation) + interactive: true + cmds: + - | + read -p "This will delete dist/, public/codemirror-bundle.js, and {{.SWITCHBOARD_DB}}. Type YES to confirm: " ans + [ "$ans" = "YES" ] + - rm -rf dist/ + - rm -f public/codemirror-bundle.js + - rm -f "{{.SWITCHBOARD_DB}}" + - echo "Clean complete." + + "db:reset": + desc: Wipe the local Switchboard SQLite database (no confirmation) + cmds: + - rm -f "{{.SWITCHBOARD_DB}}" + - echo "Removed {{.SWITCHBOARD_DB}}" From 8314f6925a44a64108e2dd175eb7d6ed06be4168 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 13:30:03 +0200 Subject: [PATCH 26/66] test(lint): add ESLint flat config + jsdom renderer tests for sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 1 — ESLint 9 flat config (eslint.config.js) with no-undef enforced on renderer (public/*.js) and main-process files. Verified statically catches the two recent sidebar.js regressions: - undeclared subagentIndex in renderProjects destructure - out-of-scope projectPath in buildSessionsList orphan branch Wired via "lint" + "pretest" scripts; npm test now lints first. Layer 2 — jsdom-backed renderer tests (test/dom-setup.js, test/dom-sidebar.test.js): - bootstraps a JSDOM window, evaluates utils.js / icons.js / sidebar.js in its VM context, stubs cross-file globals (window.api, sessionMap, activePtyIds, etc.) - sample fixture: 2 top-level sessions, 3 subagents (1 orphan), 1 starred, 1 archived - 7 tests covering: structural completeness, starred/archived classes, subagent carets, orphan group, projectPath localStorage key, empty project, idempotent re-render Both bug-class regressions are now caught at lint time AND runtime. Existing 24+ tests still green (npm test → 32 pass). Notes: - Pre-existing no-unused-vars / no-redeclare warnings (204) left as warnings, not errors; not in scope. - One stray no-undef (_shellProfiles in main.js) annotated with eslint-disable + TODO; appears to be dead code from a refactor. --- eslint.config.js | 305 +++++++ main.js | 4 + package-lock.json | 1748 ++++++++++++++++++++++++++++---------- package.json | 5 + test/dom-setup.js | 195 +++++ test/dom-sidebar.test.js | 146 ++++ 6 files changed, 1963 insertions(+), 440 deletions(-) create mode 100644 eslint.config.js create mode 100644 test/dom-setup.js create mode 100644 test/dom-sidebar.test.js diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..81ede52 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,305 @@ +// ESLint flat config for Switchboard (ESLint 9.x). +// +// Goals: +// 1. Catch dumb "undefined variable" mistakes in renderer code +// (no-undef). Two recent regressions in public/sidebar.js +// (subagentIndex undefined; project.projectPath out of scope) +// would have been caught instantly by this rule. +// 2. Warn about unused vars without blocking the build. +// 3. Keep the existing 24+ node:test suite green. +// +// The renderer (public/*.js) loads as a set of classic