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..c95d0f2 100644 --- a/derive-project-path.js +++ b/derive-project-path.js @@ -15,6 +15,11 @@ function extractCwdFromJsonl(filePath) { return null; } +// Maps a worktree cwd to its parent repo path. Exported for callers that need +// the parent ↔ worktree relationship. Deliberately NOT applied in +// deriveProjectPath: the sidebar nests worktree projects under their parent by +// matching the full worktree projectPath, so collapsing at index time would +// erase the worktree grouping (and break worktree-scoped actions like delete). function resolveWorktreePath(cwd) { if (!cwd) return cwd; // Detect worktree paths: /.claude-worktrees/, /.worktrees/, or /.claude/worktrees/ @@ -61,4 +66,4 @@ function deriveProjectPath(folderPath) { return null; } -module.exports = { deriveProjectPath }; +module.exports = { deriveProjectPath, resolveWorktreePath }; diff --git a/main.js b/main.js index 2c587b7..2040f40 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, @@ -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); @@ -907,6 +912,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/sidebar.js b/public/sidebar.js index 45985dd..3827fe1 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -131,12 +131,15 @@ function renderProjects(projects, resort) { // projects are now in the correct order (data order for resort, preserved order otherwise) // Detect worktree projects and group them under their parent - const worktreePattern = /^(.+?)\/\.claude\/worktrees\/([^/]+)\/?$/; + const worktreePattern = /^(.+?)\/\.(?:claude\/worktrees|claude-worktrees|worktrees)\/([^/]+)\/?$/; + const allProjectPaths = new Set(projects.map(p => p.projectPath)); const worktreeMap = new Map(); // parentPath → [worktreeProject, ...] const worktreeSet = new Set(); for (const project of projects) { const match = project.projectPath.match(worktreePattern); - if (match) { + // Only nest when the parent project is present — worktree groups render + // inside their parent's group, so nesting an orphan would hide it entirely + if (match && allProjectPaths.has(match[1])) { const parentPath = match[1]; if (!worktreeMap.has(parentPath)) worktreeMap.set(parentPath, []); worktreeMap.get(parentPath).push(project); 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; 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/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 }; }