Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 };
105 changes: 101 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,12 @@ function createWindow() {
}
activeSessions.delete(id);
}
// Release all subagent file watchers — pass the listener so we only remove
// our own poll, not every listener that may be attached to that path.
for (const [, entry] of subagentWatchers) {
try { fs.unwatchFile(entry.filePath, entry.listener); } catch {}
}
subagentWatchers.clear();
mainWindow = null;
});
}
Expand Down Expand Up @@ -407,13 +417,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 +922,88 @@ 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);

// Store the listener so stop-subagent-watch can remove ONLY this listener,
// not every listener on the path. Without the callback arg, fs.unwatchFile
// removes all listeners — a second watcher on the same path would also stop.
subagentWatchers.set(watchId, { filePath, parentSessionId, agentId, listener: readNewEntries });
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, entry.listener);
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