Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 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
1c235d1
feat(worktree): add 'Delete worktree' button that actually removes fr…
JeanBaptisteRenard May 21, 2026
fd82261
chore(ui): suffix app name with '(dev)' when not packaged
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 };
189 changes: 184 additions & 5 deletions main.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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'),
Expand Down Expand Up @@ -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;
});
}
Expand Down Expand Up @@ -342,6 +353,84 @@ ipcMain.handle('remove-project', (_event, projectPath) => {
}
});

// --- IPC: delete-worktree ---
// Validated path pattern: <project>/.<segment>/[worktrees/]<name>
// Matches .claude/worktrees/<n>, .claude-worktrees/<n>, .worktrees/<n>
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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
Loading