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 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, 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 }; diff --git a/main.js b/main.js index 2c587b7..6277269 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'); @@ -61,7 +62,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, @@ -80,6 +81,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; @@ -104,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'), @@ -202,6 +208,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; }); } @@ -342,6 +353,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); @@ -407,13 +496,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); @@ -907,6 +1001,85 @@ 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, + })); +}); + +// ── 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); @@ -1378,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(); diff --git a/preload.js b/preload.js index 91d8b5e..3341ab4 100644 --- a/preload.js +++ b/preload.js @@ -21,6 +21,10 @@ 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), + 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), @@ -35,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) @@ -61,6 +66,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/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 9c28bab..2c1a2bc 100644 --- a/public/jsonl-viewer.js +++ b/public/jsonl-viewer.js @@ -2,6 +2,40 @@ // 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 = {}; + +// --- 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, @@ -211,12 +245,143 @@ 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; + 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 — stop live watch + stopWatch(); + 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 = '▼'; + + // 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; }, }; @@ -559,6 +724,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/sidebar.js b/public/sidebar.js index 45985dd..e16615d 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) { @@ -351,13 +514,19 @@ 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 = ''; 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 +572,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 = ''; } @@ -499,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'); }; }); @@ -560,6 +758,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 f6070c0..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; } @@ -2338,6 +2466,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; diff --git a/read-session-file.js b/read-session-file.js index bb42318..d304d16 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,18 @@ 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); + // 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; if (entry.type === 'custom-title' && entry.customTitle) { customTitle = entry.customTitle; } @@ -44,8 +85,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 +126,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/session-cache.js b/session-cache.js index f066004..7d55ed4 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 @@ -197,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, @@ -297,14 +310,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 }, }); @@ -319,7 +343,7 @@ function populateCacheViaWorker() { if (!msg.ok) { console.error('Worker scan error:', msg.error); sendStatus('Scan failed: ' + msg.error, 'error'); - populatingCache = false; + settle(); return; } @@ -351,30 +375,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(); + }); }); } diff --git a/session-transitions.js b/session-transitions.js index ef9f25a..6da11bc 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,108 @@ 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 + } + + // 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 + 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) { + 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'}`); + 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 +188,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 +304,4 @@ function detectSessionTransitions(folder) { } -module.exports = { init, detectSessionTransitions }; +module.exports = { init, detectSessionTransitions, detectSubagentTransitions }; 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); + } +}); diff --git a/workers/scan-projects.js b/workers/scan-projects.js index 62bd73a..2e37666 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,12 @@ 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); + for (const { filePath, parentSessionId } of enumerateSessionFiles(folderPath)) { + try { + const s = readSessionFile(filePath, folder, projectPath, { parentSessionId }); if (s) sessions.push(s); - } - } catch {} + } catch {} + } return { folder, projectPath, sessions, indexMtimeMs }; }