Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
1b46d02
feat(db): add subagent columns and migration v4
JeanBaptisteRenard May 21, 2026
271381e
feat(read-session-file): support subagent layout
JeanBaptisteRenard May 21, 2026
9e36ba2
feat(scan): index subagent transcripts in session cache
JeanBaptisteRenard May 21, 2026
2fee16f
feat(viewer): expand subagent transcripts inline via Agent block
JeanBaptisteRenard May 21, 2026
249749e
ci: add github actions workflow for npm test
JeanBaptisteRenard May 21, 2026
078e8cf
fix(get-projects): await scan when cache is empty post-migration
JeanBaptisteRenard May 21, 2026
a9357e5
fix(projects-payload): expose subagent fields to the renderer
JeanBaptisteRenard May 21, 2026
6db1951
Merge pull request #1 from JeanBaptisteRenard/feat/subagent-support
JeanBaptisteRenard May 21, 2026
2fd730f
fix(read-session-file): tolerate concurrent writes to JSONL files
JeanBaptisteRenard May 21, 2026
46d596c
fix(derive-project-path): collapse worktree cwd to parent project
JeanBaptisteRenard May 22, 2026
3d821b0
feat(transitions): detect subagent spawn/completion + live-tail IPC
JeanBaptisteRenard May 21, 2026
1fea1aa
feat(ui): hierarchical sidebar, grid badges, live indicator
JeanBaptisteRenard May 21, 2026
a6210a6
fix(transitions): silent cold-start to avoid IPC flood on attach
JeanBaptisteRenard May 22, 2026
bd5aff2
Merge pull request #2 from JeanBaptisteRenard/feat/subagent-observabi…
JeanBaptisteRenard May 22, 2026
f8124cf
test: cover resolveWorktreePath and detectSubagentTransitions cold-start
JeanBaptisteRenard May 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
41 changes: 34 additions & 7 deletions db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
`);

Expand Down Expand Up @@ -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
// <folder>/<parentSessionId>/subagents/agent-<agentId>.jsonl alongside a
// .meta.json sidecar holding { agentType, description }. We surface them as
// first-class rows in session_cache, keyed by sessionId = "sub:<parent>:<agentId>".
// 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 = (() => {
Expand Down Expand Up @@ -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 = ?'),
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions derive-project-path.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -52,7 +52,7 @@ function deriveProjectPath(folderPath) {
}
if (jsonlPath) {
const cwd = extractCwdFromJsonl(jsonlPath);
if (cwd) return cwd;
if (cwd) return resolveWorktreePath(cwd);
}
}
} catch {}
Expand All @@ -61,4 +61,4 @@ function deriveProjectPath(folderPath) {
return null;
}

module.exports = { deriveProjectPath };
module.exports = { deriveProjectPath, resolveWorktreePath };
101 changes: 97 additions & 4 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -80,6 +80,10 @@ const MAX_BUFFER_SIZE = 256 * 1024;
const activeSessions = new Map();
let mainWindow = null;

// Subagent live-tail watchers (watchId → { filePath, parentSessionId, agentId })
const subagentWatchers = new Map();
let subagentWatcherSeq = 0;

function createWindow() {
// Restore saved window bounds
const savedBounds = getSetting('global')?.windowBounds;
Expand Down Expand Up @@ -202,6 +206,11 @@ function createWindow() {
}
activeSessions.delete(id);
}
// Release all subagent file watchers
for (const [, entry] of subagentWatchers) {
try { fs.unwatchFile(entry.filePath); } catch {}
}
subagentWatchers.clear();
mainWindow = null;
});
}
Expand Down Expand Up @@ -407,13 +416,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);
Expand Down Expand Up @@ -907,6 +921,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);
Expand Down
7 changes: 7 additions & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -61,6 +65,9 @@ contextBridge.exposeInMainWorld('api', {
onSessionForked: (callback) => {
ipcRenderer.on('session-forked', (_event, oldId, newId) => callback(oldId, newId));
},
onSubagentSpawned: (cb) => ipcRenderer.on('subagent-spawned', (_e, payload) => cb(payload)),
onSubagentCompleted: (cb) => ipcRenderer.on('subagent-completed', (_e, payload) => cb(payload)),
onSubagentWatchEvent: (cb) => ipcRenderer.on('subagent-watch-event', (_e, payload) => cb(payload)),
onProjectsChanged: (callback) => {
ipcRenderer.on('projects-changed', () => callback());
},
Expand Down
Loading