From 1b46d0221a06902db8aa1b0ceb1742f9e81035e9 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:02:27 +0200 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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();