Skip to content

Commit 87a4c91

Browse files
feat: multi-agent support — open & track projects with Codex (Antigravity-ready) (#8)
* feat(codex): rollout parsers (cwd + first/last user message) * feat(codex): session index (scan ~/.codex/sessions, group by cwd) + resume cue Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(agents): AgentProvider abstraction + registry (claude/codex) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ipc): route sessions/open/cue through the active agent + agent settings - Replace listSessions/isValidSessionId/lastUserMessageForSession imports with getProvider/availableAgents from ./agents - Add activeAgent()/agent() helpers inside registerIpc - projects:list now calls agent().listSessions / agent().lastUserMessage - projects:open uses agent().buildCommand (resume/continue/new); validation delegated to the provider (session-id regex lives in agents.ts) - Add settings:getAgent, settings:availableAgents, settings:setAgent IPC - usage:report still uses CLAUDE_PROJECTS + scanUsage (Claude-only, v1) - Also forward-port store.ts getAgent/setAgent + StateFile agent field (mirrors commit 5105b96 on main, needed here before that merge) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(store): cover getAgent/setAgent persistence Mirrors the existing baseDir/thresholds settings coverage so every settings pair in store.ts has a round-trip test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ipc): expose agent settings to the renderer Add getAgent/setAgent/availableAgents to the preload bridge and their type declarations, mirroring the getLanguage/setLanguage surface. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(renderer): agent switcher toolbar + agent-aware session label Add <select id="agent-select"> to the projects toolbar (hidden unless >1 agent available), wire it in boot() via availableAgents/getAgent/ setAgent IPC, and make the per-card session label use the active agent name instead of the hardcoded literal 'claude'. Add .hidden CSS rule and agent.label/claude/codex i18n keys to all four locale files. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(renderer): re-localise agent switcher aria-label on language change applyStaticLabels now refreshes the agent-select aria-label so a language switch updates it (option text is Claude/Codex in every locale). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(demo): codex session fixture + agent-switch QA capture Fixture writes ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl (cwd inside the file) for three repos; the capture switches the toolbar agent to Codex and screenshots the deck + an expanded card. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(codex): scan rollout tail backward so large sessions keep their resume cue A real ~4MB Codex rollout puts the last user message ~300KB before EOF (a long trailing agent turn follows it), so the fixed 256KB tail returned no cue. Mirror the Claude reader: backward 1MB chunks, 8MB cap, early-exit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Si Hyeong Lee <writingdeveloper@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent da8b960 commit 87a4c91

22 files changed

Lines changed: 444 additions & 17 deletions

scripts/demo-capture.mjs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,5 +60,21 @@ await showView('next');
6060
await win.waitForTimeout(500);
6161
await shot('demo-next');
6262

63+
// 7) Codex agent — switch the active agent and show the Codex deck (multi-agent QA)
64+
await showView('projects');
65+
await win.evaluate(() => window.devdeck.setAgent('codex'));
66+
await win.reload();
67+
await win.waitForSelector('#cards .card', { timeout: 30000 }).catch(() => {});
68+
await win.waitForTimeout(600);
69+
await shot('demo-codex');
70+
// expand the first card (acme-dashboard, freshest) to show its Codex session + resume cue
71+
await win.evaluate(() => {
72+
const c = document.querySelector('.sessions-head .caret');
73+
if (c) c.parentElement.click();
74+
});
75+
await shot('demo-codex-sessions');
76+
// restore Claude so the captured user-data isn't left on Codex
77+
await win.evaluate(() => window.devdeck.setAgent('claude'));
78+
6379
await app.close();
6480
console.log('done — qa/shots/demo-*.png');

scripts/demo-fixture.mjs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,5 +120,33 @@ writeFileSync(nf, [user('scaffold the prototype'), asst('claude-sonnet-4-6', iso
120120
const nms = NOW - 3 * DAY;
121121
utimesSync(nf, new Date(nms), new Date(nms));
122122

123-
console.log('projects:', PROJECTS.length + 1, '| sessions:', sid);
123+
// ---- Codex sessions (multi-agent demo) ----
124+
// Codex stores rollouts at ~/.codex/sessions/YYYY/MM/DD/rollout-<uuid>.jsonl with the
125+
// project path INSIDE the file (session_meta.payload.cwd) — DevDeck's CodexProvider
126+
// scans the tree and groups by cwd. Giving a few repos Codex sessions lets the demo
127+
// switch the toolbar agent to Codex and show that lens.
128+
const CODEX = join(HOME, '.codex', 'sessions');
129+
const cmeta = (id, cwd) => JSON.stringify({ type: 'session_meta', payload: { id, cwd, timestamp: iso(0) } });
130+
const cuser = (message) => JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message } });
131+
const cagent = (message) => JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message } });
132+
const cuuid = (n) => `c0dec0de-0000-4000-8000-00000000${String(n).padStart(4, '0')}`; // hex-only (passes the resume-id guard)
133+
134+
const CODEX_SESSIONS = [
135+
{ repo: 'acme-dashboard', day: '2026/06/06', first: 'port the theme toggle to Codex', cue: 'extract the color tokens into a shared module', stale: 0 },
136+
{ repo: 'payments-api', day: '2026/06/05', first: 'review the refund endpoint', cue: 'add rate limiting to the refund route', stale: 1 },
137+
{ repo: 'ml-pipeline', day: '2026/06/03', first: 'profile the feature extraction', cue: 'parallelize the batch loader', stale: 3 },
138+
];
139+
let cid = 0;
140+
for (const c of CODEX_SESSIONS) {
141+
const cwd = join(REPOS, c.repo);
142+
const id = cuuid(cid++);
143+
const dir = join(CODEX, c.day);
144+
mkdirSync(dir, { recursive: true });
145+
const file = join(dir, `rollout-${id}.jsonl`);
146+
writeFileSync(file, [cmeta(id, cwd), cuser(c.first), cagent('on it'), cuser(c.cue)].join('\n'));
147+
const ms = NOW - c.stale * DAY;
148+
utimesSync(file, new Date(ms), new Date(ms));
149+
}
150+
151+
console.log('projects:', PROJECTS.length + 1, '| claude sessions:', sid, '| codex sessions:', cid);
124152
console.log(HOME);

src/main/agents.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { getProvider, availableAgents } from './agents';
3+
4+
describe('agent providers', () => {
5+
it('claude buildCommand maps kinds correctly', () => {
6+
const c = getProvider('claude');
7+
expect(c.buildCommand('new')).toBe('claude');
8+
expect(c.buildCommand('continue')).toBe('claude -c');
9+
expect(c.buildCommand('resume', 'a0b1c2d3-e4f5-6789-abcd-ef0123456789')).toBe('claude -r a0b1c2d3-e4f5-6789-abcd-ef0123456789');
10+
});
11+
it('codex buildCommand maps kinds correctly', () => {
12+
const x = getProvider('codex');
13+
expect(x.buildCommand('new')).toBe('codex');
14+
expect(x.buildCommand('continue')).toBe('codex resume --last');
15+
expect(x.buildCommand('resume', 'a0b1c2d3-e4f5-6789-abcd-ef0123456789')).toBe('codex resume a0b1c2d3-e4f5-6789-abcd-ef0123456789');
16+
});
17+
it('resume with an invalid id falls back to continue (no injection)', () => {
18+
expect(getProvider('claude').buildCommand('resume', '$(evil)')).toBe('claude -c');
19+
expect(getProvider('codex').buildCommand('resume', '$(evil)')).toBe('codex resume --last');
20+
});
21+
it('availableAgents filters by isAvailable', () => {
22+
expect(availableAgents(() => false)).toEqual([]);
23+
expect(availableAgents(() => true).sort()).toEqual(['claude', 'codex']);
24+
});
25+
});

src/main/agents.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { homedir } from 'node:os';
2+
import { join } from 'node:path';
3+
import { existsSync } from 'node:fs';
4+
import type { AgentId, SessionMeta } from '../shared/types';
5+
import { listSessions, lastUserMessageForSession } from './sessions';
6+
import { listCodexSessions, lastUserMessageForCodexSession, codexAvailable } from './codexSessions';
7+
8+
const SESSION_ID_RE = /^[0-9a-fA-F][0-9a-fA-F-]{7,}$/;
9+
const CLAUDE_PROJECTS = join(homedir(), '.claude', 'projects');
10+
const CODEX_SESSIONS = join(homedir(), '.codex', 'sessions');
11+
12+
export type LaunchKind = 'new' | 'continue' | 'resume';
13+
14+
export interface AgentProvider {
15+
id: AgentId;
16+
label: string;
17+
isAvailable(): boolean;
18+
listSessions(projectPath: string, limit?: number): SessionMeta[];
19+
lastUserMessage(projectPath: string, sessionId: string): string | null;
20+
buildCommand(kind: LaunchKind, sessionId?: string): string;
21+
}
22+
23+
const claudeProvider: AgentProvider = {
24+
id: 'claude',
25+
label: 'Claude',
26+
isAvailable: () => existsSync(CLAUDE_PROJECTS),
27+
listSessions: (p, limit) => listSessions(p, CLAUDE_PROJECTS, limit),
28+
lastUserMessage: (p, id) => lastUserMessageForSession(p, id, CLAUDE_PROJECTS),
29+
buildCommand: (kind, id) => {
30+
if (kind === 'resume' && id && SESSION_ID_RE.test(id)) return `claude -r ${id}`;
31+
return kind === 'new' ? 'claude' : 'claude -c';
32+
},
33+
};
34+
35+
const codexProvider: AgentProvider = {
36+
id: 'codex',
37+
label: 'Codex',
38+
isAvailable: () => codexAvailable(CODEX_SESSIONS),
39+
listSessions: (p, limit) => listCodexSessions(p, CODEX_SESSIONS, limit),
40+
lastUserMessage: (p, id) => lastUserMessageForCodexSession(p, id, CODEX_SESSIONS),
41+
buildCommand: (kind, id) => {
42+
if (kind === 'resume' && id && SESSION_ID_RE.test(id)) return `codex resume ${id}`;
43+
return kind === 'new' ? 'codex' : 'codex resume --last';
44+
},
45+
};
46+
47+
const PROVIDERS: Record<AgentId, AgentProvider> = { claude: claudeProvider, codex: codexProvider };
48+
49+
export function getProvider(id: AgentId): AgentProvider {
50+
return PROVIDERS[id] ?? claudeProvider;
51+
}
52+
53+
/** Installed agents (claude always; codex if ~/.codex/sessions exists). `probe` overridable for tests. */
54+
export function availableAgents(probe?: (id: AgentId) => boolean): AgentId[] {
55+
const ids: AgentId[] = ['claude', 'codex'];
56+
const isAvail = probe ?? ((id) => PROVIDERS[id].isAvailable());
57+
return ids.filter(isAvail);
58+
}

src/main/codexSessions.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
import { listCodexSessions, lastUserMessageForCodexSession, codexAvailable } from './codexSessions';
6+
7+
let root: string;
8+
beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'devdeck-codex-')); });
9+
afterEach(() => rmSync(root, { recursive: true, force: true }));
10+
11+
const rollout = (id: string, cwd: string, msgs: string[]) => [
12+
JSON.stringify({ type: 'session_meta', payload: { id, cwd, timestamp: '2026-06-06T00:00:00Z' } }),
13+
...msgs.map((m) => JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: m } })),
14+
].join('\n');
15+
16+
function writeRollout(day: string, id: string, cwd: string, msgs: string[], mtime: number) {
17+
const dir = join(root, day); mkdirSync(dir, { recursive: true });
18+
const f = join(dir, `rollout-${id}.jsonl`);
19+
writeFileSync(f, rollout(id, cwd, msgs));
20+
utimesSync(f, mtime, mtime);
21+
}
22+
23+
describe('listCodexSessions', () => {
24+
it('returns sessions for a cwd, newest-first, with first message', () => {
25+
writeRollout('2026/06/06', 'a0b1c2d3-e4f5-6789-abcd-ef0123450001', 'C:\\g\\app', ['older'], 1000);
26+
writeRollout('2026/06/06', 'a0b1c2d3-e4f5-6789-abcd-ef0123450002', 'C:\\g\\app', ['newer one', 'and more'], 2000);
27+
writeRollout('2026/06/06', 'a0b1c2d3-e4f5-6789-abcd-ef0123450003', 'C:\\g\\other', ['elsewhere'], 3000);
28+
const out = listCodexSessions('C:\\g\\app', root);
29+
expect(out.map((s) => s.id)).toEqual([
30+
'a0b1c2d3-e4f5-6789-abcd-ef0123450002', 'a0b1c2d3-e4f5-6789-abcd-ef0123450001',
31+
]);
32+
expect(out[0].firstMessage).toBe('newer one');
33+
});
34+
it('returns [] for an unknown cwd or missing dir', () => {
35+
expect(listCodexSessions('C:\\g\\none', root)).toEqual([]);
36+
expect(listCodexSessions('C:\\g\\none', join(root, 'nope'))).toEqual([]);
37+
});
38+
});
39+
40+
describe('lastUserMessageForCodexSession', () => {
41+
it('returns the last user message of the matching session', () => {
42+
writeRollout('2026/06/06', 'a0b1c2d3-e4f5-6789-abcd-ef0123450009', 'C:\\g\\app', ['first', 'resume this'], 1000);
43+
expect(lastUserMessageForCodexSession('C:\\g\\app', 'a0b1c2d3-e4f5-6789-abcd-ef0123450009', root)).toBe('resume this');
44+
});
45+
it('returns null for an unknown id', () => {
46+
expect(lastUserMessageForCodexSession('C:\\g\\app', 'a0b1c2d3-e4f5-6789-abcd-ef0123450099', root)).toBeNull();
47+
});
48+
it('finds the last user message past a multi-MB trailing agent turn', () => {
49+
// Real rollouts can be multi-MB and the last user message sits hundreds of KB
50+
// before EOF (a long autonomous agent turn follows it) — a small fixed tail misses it.
51+
const id = 'a0b1c2d3-e4f5-6789-abcd-ef0123450010';
52+
const huge = 'x'.repeat(700 * 1024);
53+
const lines = [
54+
JSON.stringify({ type: 'session_meta', payload: { id, cwd: 'C:\\g\\app', timestamp: '2026-06-06T00:00:00Z' } }),
55+
JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'resume from here' } }),
56+
JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: huge } }),
57+
].join('\n');
58+
const dir = join(root, '2026/06/06'); mkdirSync(dir, { recursive: true });
59+
writeFileSync(join(dir, `rollout-${id}.jsonl`), lines);
60+
expect(lastUserMessageForCodexSession('C:\\g\\app', id, root)).toBe('resume from here');
61+
});
62+
});
63+
64+
describe('codexAvailable', () => {
65+
it('true when the sessions dir exists, false otherwise', () => {
66+
expect(codexAvailable(root)).toBe(true);
67+
expect(codexAvailable(join(root, 'nope'))).toBe(false);
68+
});
69+
});

src/main/codexSessions.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { readdirSync, statSync, existsSync, readFileSync, openSync, readSync, closeSync, fstatSync } from 'node:fs';
2+
import { join } from 'node:path';
3+
import type { SessionMeta } from '../shared/types';
4+
import { codexCwd, codexFirstUserMessage, codexLastUserMessage } from '../shared/codexParse';
5+
6+
const HEAD_BYTES = 64 * 1024;
7+
// Codex rollouts can be multi-MB and the last user message often sits hundreds of
8+
// KB before EOF (a long autonomous agent turn follows it), so scan the tail backward
9+
// in chunks and stop as soon as a user message is found, up to a hard cap — mirroring
10+
// the Claude reader in sessions.ts.
11+
const TAIL_CHUNK = 1024 * 1024;
12+
const TAIL_MAX = 8 * 1024 * 1024;
13+
const SESSION_ID_RE = /^[0-9a-fA-F][0-9a-fA-F-]{7,}$/;
14+
15+
function readHead(file: string): string {
16+
try {
17+
const fd = openSync(file, 'r');
18+
try { const buf = Buffer.alloc(HEAD_BYTES); const n = readSync(fd, buf, 0, HEAD_BYTES, 0); return buf.toString('utf8', 0, n); }
19+
finally { closeSync(fd); }
20+
} catch { try { return readFileSync(file, 'utf8'); } catch { return ''; } }
21+
}
22+
23+
/** Last user message of a rollout, scanning backward in chunks up to TAIL_MAX. */
24+
function lastUserMessageInFile(file: string): string | null {
25+
let fd: number;
26+
try { fd = openSync(file, 'r'); } catch { return null; }
27+
try {
28+
const size = fstatSync(fd).size;
29+
let pos = size;
30+
let acc = Buffer.alloc(0);
31+
while (pos > 0 && size - pos < TAIL_MAX) {
32+
const readLen = Math.min(TAIL_CHUNK, pos);
33+
pos -= readLen;
34+
const buf = Buffer.alloc(readLen);
35+
readSync(fd, buf, 0, readLen, pos);
36+
acc = Buffer.concat([buf, acc]); // contiguous byte range [pos, size) — safe to decode whole
37+
const text = acc.toString('utf8');
38+
// Drop the leading partial line until we've read the very start of the file.
39+
const body = pos === 0 ? text : text.slice(text.indexOf('\n') + 1);
40+
const found = codexLastUserMessage(body);
41+
if (found) return found;
42+
}
43+
return null;
44+
} catch { return null; }
45+
finally { closeSync(fd); }
46+
}
47+
48+
/** Recursively collect rollout-*.jsonl files under the sessions dir. */
49+
function rolloutFiles(dir: string): string[] {
50+
const out: string[] = [];
51+
let entries;
52+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
53+
for (const e of entries) {
54+
const full = join(dir, e.name);
55+
if (e.isDirectory()) out.push(...rolloutFiles(full));
56+
else if (e.name.startsWith('rollout-') && e.name.endsWith('.jsonl')) out.push(full);
57+
}
58+
return out;
59+
}
60+
61+
interface CodexHead { id: string | null; cwd: string | null; mtimeMs: number; path: string; }
62+
const _headCache = new Map<string, CodexHead>(); // key: path + ':' + mtimeMs
63+
64+
function head(file: string): CodexHead {
65+
let mtimeMs = 0;
66+
try { mtimeMs = statSync(file).mtimeMs; } catch { /* ignore */ }
67+
const key = file + ':' + mtimeMs;
68+
const cached = _headCache.get(key);
69+
if (cached) return cached;
70+
const text = readHead(file);
71+
const metaLine = text.split('\n', 1)[0] ?? '';
72+
let id: string | null = null;
73+
try { id = (JSON.parse(metaLine)?.payload?.id ?? null) as string | null; } catch { /* ignore */ }
74+
const h: CodexHead = { id, cwd: codexCwd(text), mtimeMs, path: file };
75+
_headCache.set(key, h);
76+
return h;
77+
}
78+
79+
export function codexAvailable(codexSessionsDir: string): boolean {
80+
return existsSync(codexSessionsDir);
81+
}
82+
83+
/** Codex sessions for a project (by cwd), newest-first, with first-message previews. */
84+
export function listCodexSessions(projectPath: string, codexSessionsDir: string, limit = 5): SessionMeta[] {
85+
const matches = rolloutFiles(codexSessionsDir)
86+
.map(head)
87+
.filter((h) => h.cwd === projectPath && h.id)
88+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
89+
.slice(0, limit);
90+
return matches.map((h) => ({
91+
id: h.id as string,
92+
mtimeMs: h.mtimeMs,
93+
firstMessage: codexFirstUserMessage(readHead(h.path)),
94+
}));
95+
}
96+
97+
/** Last user message of a specific Codex session (by id, under a cwd). Null if absent. */
98+
export function lastUserMessageForCodexSession(projectPath: string, sessionId: string, codexSessionsDir: string): string | null {
99+
if (!SESSION_ID_RE.test(sessionId)) return null;
100+
const match = rolloutFiles(codexSessionsDir).map(head).find((h) => h.id === sessionId && h.cwd === projectPath);
101+
if (!match) return null;
102+
return lastUserMessageInFile(match.path);
103+
}

src/main/ipc.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { join, resolve, sep } from 'node:path';
44
import type { Store } from './store';
55
import { scanRepos } from './scanner';
66
import { getGitInfo } from './gitInfo';
7-
import { listSessions, isValidSessionId, lastUserMessageForSession } from './sessions';
7+
import { getProvider, availableAgents } from './agents';
8+
import type { AgentId } from '../shared/types';
89
import { buildProjectList } from './projects';
910
import { openProjects, openInEditor } from './launcher';
1011
import type { WtTab } from '../shared/wtArgs';
@@ -31,15 +32,21 @@ export function registerIpc(cfg: IpcConfig): void {
3132
const effBaseDir = () => cfg.store.getBaseDir() ?? cfg.defaultBaseDir;
3233
const effThresholds = () => cfg.store.getThresholds() ?? DEFAULT_THRESHOLDS;
3334

35+
const activeAgent = (): AgentId => {
36+
const a = cfg.store.getAgent();
37+
return a === 'codex' || a === 'claude' ? a : 'claude';
38+
};
39+
const agent = () => getProvider(activeAgent());
40+
3441
ipcMain.handle('projects:list', async () => {
3542
return buildProjectList({
3643
baseDir: effBaseDir(),
3744
nowMs: Date.now(),
3845
thresholds: effThresholds(),
3946
scan: (base) => scanRepos(base),
4047
git: (dir) => getGitInfo(dir),
41-
sessions: (p) => listSessions(p, CLAUDE_PROJECTS),
42-
resumeCue: (p, sessionId) => lastUserMessageForSession(p, sessionId, CLAUDE_PROJECTS),
48+
sessions: (p) => agent().listSessions(p),
49+
resumeCue: (p, sessionId) => agent().lastUserMessage(p, sessionId),
4350
getEntry: (p) => cfg.store.get(p),
4451
});
4552
});
@@ -61,6 +68,11 @@ export function registerIpc(cfg: IpcConfig): void {
6168
});
6269
ipcMain.handle('settings:getLanguage', () => cfg.store.getLanguage() ?? cfg.defaultLanguage);
6370
ipcMain.handle('settings:setLanguage', (_e, lang: string) => cfg.store.setLanguage(lang));
71+
ipcMain.handle('settings:getAgent', () => activeAgent());
72+
ipcMain.handle('settings:availableAgents', () => availableAgents());
73+
ipcMain.handle('settings:setAgent', (_e, id: string) => {
74+
if (id === 'claude' || id === 'codex') cfg.store.setAgent(id);
75+
});
6476

6577
ipcMain.handle('settings:get', () => ({
6678
baseDir: effBaseDir(), thresholds: effThresholds(), language: cfg.store.getLanguage() ?? cfg.defaultLanguage,
@@ -89,17 +101,11 @@ export function registerIpc(cfg: IpcConfig): void {
89101
cfg.sendError(`Path outside base dir: ${it.path}`);
90102
continue;
91103
}
104+
const a = agent();
92105
let command: string;
93-
// Only interpolate a session id into the shell command if it is a valid
94-
// (UUID-ish) id; otherwise fall back to continue/new so a crafted id cannot
95-
// inject into `claude -r <id>` at this trust boundary.
96-
if (typeof it.sessionId === 'string' && isValidSessionId(it.sessionId)) {
97-
command = `claude -r ${it.sessionId}`;
98-
} else if (listSessions(it.path, CLAUDE_PROJECTS).length > 0) {
99-
command = 'claude -c';
100-
} else {
101-
command = 'claude';
102-
}
106+
if (typeof it.sessionId === 'string') command = a.buildCommand('resume', it.sessionId);
107+
else if (a.listSessions(it.path).length > 0) command = a.buildCommand('continue');
108+
else command = a.buildCommand('new');
103109
tabs.push({
104110
name: it.path.split(/[\\/]/).pop() ?? it.path,
105111
dir: it.path,

src/main/store.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,12 @@ describe('Store', () => {
5757
expect(s.getBaseDir()).toBeNull();
5858
expect(s.getThresholds()).toBeNull();
5959
});
60+
61+
it('persists the active agent and defaults to null', () => {
62+
const s = new Store(file);
63+
expect(s.getAgent()).toBeNull();
64+
s.setAgent('codex');
65+
const re = new Store(file);
66+
expect(re.getAgent()).toBe('codex');
67+
});
6068
});

0 commit comments

Comments
 (0)