dual-brain 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -15,7 +15,26 @@ Route subagents by task complexity:
15
15
  For isolated or parallel work, dispatch to GPT via Codex CLI:
16
16
 
17
17
  - `node .claude/hooks/gpt-work-dispatcher.mjs --task "..." --model gpt-5.4` — execution tasks
18
- - `node .claude/hooks/dual-brain-think.mjs --question "..."` — dual-perspective decisions
18
+
19
+ ## Dual-Brain Collaboration
20
+
21
+ Dual-brain is a multi-round conversation between Claude and GPT — not a single-shot dispatch.
22
+
23
+ **Think flow** (architecture decisions):
24
+ 1. Round 1: `node .claude/hooks/dual-brain-think.mjs --question "..."`
25
+ → GPT gives independent analysis
26
+ 2. You analyze the same question independently
27
+ 3. Round 2: `node .claude/hooks/dual-brain-think.mjs --question "..." --round 2 --claude-says "<your analysis>"`
28
+ → GPT responds to your points: agreements, pushback, refined recommendation
29
+ 4. You synthesize both rounds into a final decision
30
+
31
+ **Review flow** (code review):
32
+ 1. Round 1: `node .claude/hooks/dual-brain-review.mjs`
33
+ → GPT reviews the diff independently
34
+ 2. You review the same diff independently
35
+ 3. Round 2: `node .claude/hooks/dual-brain-review.mjs --round 2 --claude-review "<your findings>"`
36
+ → GPT confirms shared findings, acknowledges misses, disputes false positives
37
+ 4. You synthesize into a final review verdict
19
38
 
20
39
  ## Routing Rules
21
40
 
@@ -0,0 +1,534 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * control-panel.mjs — Session manager + control panel for Dual-Brain.
4
+ *
5
+ * Data-tools-style interactive menu: recent sessions, continue/resume/new,
6
+ * profile switching, budget editing. Loops until user exits to shell.
7
+ */
8
+
9
+ import readline from 'readline';
10
+ import { existsSync, readFileSync, readdirSync, statSync, renameSync, writeFileSync } from 'fs';
11
+ import { dirname, join } from 'path';
12
+ import { fileURLToPath } from 'url';
13
+ import { spawnSync } from 'child_process';
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const PROFILE_FILE = join(__dirname, '..', 'dual-brain.profile.json');
17
+ const VERSION = (() => {
18
+ try { return JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version; } catch {}
19
+ return '?';
20
+ })();
21
+
22
+ const IS_REPLIT = !!(process.env.REPL_ID || process.env.REPL_SLUG);
23
+ const HOME = process.env.HOME || process.env.USERPROFILE || '';
24
+ const CWD = process.cwd();
25
+
26
+ // ─── ANSI ──────────────────────────────────────────────────────────────────
27
+
28
+ const noColor = !!process.env.NO_COLOR;
29
+ const e = (code, s) => noColor ? s : `\x1b[${code}m${s}\x1b[0m`;
30
+ const bold = s => e('1', s);
31
+ const dim = s => e('2', s);
32
+ const cyan = s => e('36', s);
33
+ const green = s => e('32', s);
34
+ const yellow = s => e('33', s);
35
+ const magenta = s => e('95', s);
36
+ const orange = s => e('1;38;5;208', s);
37
+ const blue = s => e('1;38;5;33', s);
38
+
39
+ // ─── Profiles ──────────────────────────────────────────────────────────────
40
+
41
+ const PROFILES = {
42
+ balanced: { emoji: '⚖️', label: 'Balanced', desc: 'Best model per tier, normal budgets' },
43
+ 'cost-saver': { emoji: '💸', label: 'Cost-saver', desc: 'Prefer cheaper models, lower budgets' },
44
+ 'quality-first': { emoji: '💎', label: 'Quality-first', desc: 'Dual-brain for medium+, strict reviews' },
45
+ };
46
+
47
+ const PROFILE_BUDGETS = {
48
+ balanced: { session_warn_usd: 5, session_limit_usd: 10, daily_warn_usd: 20, daily_limit_usd: 50 },
49
+ 'cost-saver': { session_warn_usd: 2, session_limit_usd: 5, daily_warn_usd: 8, daily_limit_usd: 20 },
50
+ 'quality-first': { session_warn_usd: 15, session_limit_usd: 30, daily_warn_usd: 50, daily_limit_usd: 100 },
51
+ };
52
+
53
+ function loadProfile() {
54
+ try {
55
+ const data = JSON.parse(readFileSync(PROFILE_FILE, 'utf8'));
56
+ const name = data.active && PROFILES[data.active] ? data.active : 'balanced';
57
+ const custom = data.custom_overrides || {};
58
+ return { name, budgets: { ...PROFILE_BUDGETS[name], ...custom.budgets } };
59
+ } catch {
60
+ return { name: 'balanced', budgets: PROFILE_BUDGETS.balanced };
61
+ }
62
+ }
63
+
64
+ function saveProfile(name, customOverrides) {
65
+ const data = { active: name, switched_at: new Date().toISOString() };
66
+ if (customOverrides) data.custom_overrides = customOverrides;
67
+ const tmp = PROFILE_FILE + '.tmp.' + process.pid;
68
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
69
+ renameSync(tmp, PROFILE_FILE);
70
+ }
71
+
72
+ // ─── Provider Detection ───────────────────────────────────────────────────
73
+
74
+ function detectProviders() {
75
+ const claude = { installed: false, authed: false };
76
+ const codex = { installed: false, authed: false };
77
+
78
+ const claudeCheck = spawnSync('which', ['claude'], { encoding: 'utf8', stdio: 'pipe', timeout: 3000 });
79
+ claude.installed = claudeCheck.status === 0 && !!claudeCheck.stdout.trim();
80
+
81
+ const credPaths = [
82
+ join(HOME, '.claude', '.credentials.json'),
83
+ join(HOME, '.claude', 'credentials.json'),
84
+ join(CWD, '.replit-tools', '.claude-persistent', '.credentials.json'),
85
+ ];
86
+ for (const p of credPaths) {
87
+ try {
88
+ const cred = JSON.parse(readFileSync(p, 'utf8'));
89
+ if (cred.claudeAiOauth || cred.apiKey || cred.oauth_token) { claude.authed = true; break; }
90
+ } catch {}
91
+ }
92
+ if (!claude.authed && claude.installed) {
93
+ const r = spawnSync('claude', ['auth', 'status'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 });
94
+ const out = ((r.stdout || '') + (r.stderr || '')).toLowerCase();
95
+ if (out.includes('logged in') || out.includes('authenticated')) claude.authed = true;
96
+ }
97
+
98
+ const codexCheck = spawnSync('which', ['codex'], { encoding: 'utf8', stdio: 'pipe', timeout: 3000 });
99
+ if (codexCheck.status === 0 && codexCheck.stdout.trim()) {
100
+ codex.installed = true;
101
+ const login = spawnSync(codexCheck.stdout.trim(), ['login', 'status'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 });
102
+ const out = ((login.stdout || '') + (login.stderr || '')).toLowerCase();
103
+ if (login.status === 0 || out.includes('logged in') || out.includes('authenticated')) codex.authed = true;
104
+ }
105
+
106
+ return { claude, codex };
107
+ }
108
+
109
+ // ─── Session Discovery ────────────────────────────────────────────────────
110
+
111
+ function getRecentSessions() {
112
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
113
+ const sessions = new Map();
114
+
115
+ const isRealPrompt = (txt) => {
116
+ if (!txt) return false;
117
+ const t = txt.trim();
118
+ if (!t) return false;
119
+ if (/^[✅❌📦🔗⚠️🚀🎉🔧📝]/.test(t)) return false;
120
+ if (/Claude (history|binary|versions) symlink/.test(t)) return false;
121
+ if (t.startsWith('# AGENTS.md')) return false;
122
+ return true;
123
+ };
124
+
125
+ // Claude sessions
126
+ const historyFile = join(HOME, '.claude', 'history.jsonl');
127
+ if (existsSync(historyFile)) {
128
+ try {
129
+ const lines = readFileSync(historyFile, 'utf8').trim().split('\n');
130
+ const entries = [];
131
+ for (const line of lines) {
132
+ try {
133
+ const j = JSON.parse(line);
134
+ if (j.sessionId && j.timestamp) entries.push(j);
135
+ } catch {}
136
+ }
137
+ entries.sort((a, b) => a.timestamp - b.timestamp);
138
+ for (const j of entries) {
139
+ const key = 'claude:' + j.sessionId;
140
+ if (!sessions.has(key)) {
141
+ sessions.set(key, { tool: 'claude', id: j.sessionId, firstSeen: j.timestamp, lastSeen: j.timestamp, firstPrompt: '' });
142
+ }
143
+ const s = sessions.get(key);
144
+ if (j.timestamp < s.firstSeen) s.firstSeen = j.timestamp;
145
+ if (j.timestamp > s.lastSeen) s.lastSeen = j.timestamp;
146
+ if (!s.firstPrompt && isRealPrompt(j.display)) s.firstPrompt = j.display;
147
+ }
148
+ for (const [key, s] of sessions) {
149
+ if (s.tool === 'claude' && !s.firstPrompt) sessions.delete(key);
150
+ }
151
+ } catch {}
152
+ }
153
+
154
+ // Codex sessions
155
+ const codexDir = join(HOME, '.codex', 'sessions');
156
+ if (existsSync(codexDir)) {
157
+ const walk = (dir) => {
158
+ let results = [];
159
+ try {
160
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
161
+ const full = join(dir, entry.name);
162
+ if (entry.isDirectory()) results = results.concat(walk(full));
163
+ else if (entry.isFile() && entry.name.endsWith('.jsonl')) results.push(full);
164
+ }
165
+ } catch {}
166
+ return results;
167
+ };
168
+ for (const f of walk(codexDir)) {
169
+ try {
170
+ const stat = statSync(f);
171
+ if (stat.mtimeMs < cutoff) continue;
172
+ const content = readFileSync(f, 'utf8');
173
+ const lns = content.trim().split('\n');
174
+ if (!lns.length) continue;
175
+ const meta = JSON.parse(lns[0]);
176
+ if (meta.type !== 'session_meta' || !meta.payload) continue;
177
+ if (meta.payload.cwd !== CWD) continue;
178
+ const id = meta.payload.id;
179
+ const firstTs = Date.parse(meta.payload.timestamp || meta.timestamp);
180
+ let lastTs = firstTs;
181
+ let firstPrompt = '';
182
+ let realMsgCount = 0;
183
+ for (const ln of lns) {
184
+ try {
185
+ const j = JSON.parse(ln);
186
+ if (j.timestamp) lastTs = Math.max(lastTs, Date.parse(j.timestamp));
187
+ if (j.type === 'event_msg' && j.payload?.type === 'user_message') {
188
+ const text = (j.payload.message || '').trim();
189
+ if (text) { if (!firstPrompt) firstPrompt = text; realMsgCount++; }
190
+ }
191
+ } catch {}
192
+ }
193
+ if (realMsgCount === 0 || !firstPrompt) continue;
194
+ if (/^(you are |you're |\*\*role\*\*|<role>|## role)/i.test(firstPrompt)) continue;
195
+ if (realMsgCount === 1 && firstPrompt.length > 500) continue;
196
+ sessions.set('codex:' + id, { tool: 'codex', id, firstSeen: firstTs, lastSeen: lastTs, firstPrompt });
197
+ } catch {}
198
+ }
199
+ }
200
+
201
+ return Array.from(sessions.values())
202
+ .filter(s => (s.lastSeen || 0) >= cutoff)
203
+ .sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0))
204
+ .slice(0, 9);
205
+ }
206
+
207
+ function timeAgo(ts) {
208
+ const mins = Math.round((Date.now() - ts) / 60000);
209
+ if (mins < 1) return 'just now';
210
+ if (mins < 60) return mins + 'm ago';
211
+ const h = Math.round(mins / 60);
212
+ return h + 'h ago';
213
+ }
214
+
215
+ function snippet(s, n = 15) {
216
+ const clean = (s || '').replace(/\s+/g, ' ').trim();
217
+ return clean.length > n ? clean.slice(0, n - 1) + '…' : clean;
218
+ }
219
+
220
+ function countRunning() {
221
+ let claude = 0, codex = 0;
222
+ try {
223
+ const r = spawnSync('pgrep', ['-x', 'claude'], { encoding: 'utf8', stdio: 'pipe', timeout: 2000 });
224
+ claude = (r.stdout || '').trim().split('\n').filter(Boolean).length;
225
+ } catch {}
226
+ try {
227
+ const r = spawnSync('pgrep', ['-x', 'codex'], { encoding: 'utf8', stdio: 'pipe', timeout: 2000 });
228
+ codex = (r.stdout || '').trim().split('\n').filter(Boolean).length;
229
+ } catch {}
230
+ return { claude, codex };
231
+ }
232
+
233
+ // ─── Replit-Tools Check ───────────────────────────────────────────────────
234
+
235
+ function checkReplitTools() {
236
+ if (!IS_REPLIT) return true;
237
+ return existsSync(join(CWD, '.replit-tools'));
238
+ }
239
+
240
+ // ─── Menu Renderer ────────────────────────────────────────────────────────
241
+
242
+ function renderMenu() {
243
+ const providers = detectProviders();
244
+ const profile = loadProfile();
245
+ const sessions = getRecentSessions();
246
+ const running = countRunning();
247
+ const pf = PROFILES[profile.name];
248
+ const hasReplitTools = checkReplitTools();
249
+
250
+ const lines = [];
251
+
252
+ lines.push('');
253
+ lines.push(` 🧠 ${bold(`Dual-Brain v${VERSION}`)}`);
254
+ lines.push('');
255
+
256
+ // Quick reference box
257
+ lines.push(' ┌─────────────────────────────┐');
258
+ if (IS_REPLIT) {
259
+ lines.push(` │ ${magenta('At')} ${blue('~/workspace')}${magenta('$ prompt:')} │`);
260
+ lines.push(` │ ${cyan('! npx dual-brain')} = this menu│`);
261
+ } else {
262
+ lines.push(` │ ${magenta('At shell prompt:')} │`);
263
+ lines.push(` │ ${cyan('npx dual-brain')} = this menu │`);
264
+ }
265
+ lines.push(` │ ${cyan('j')} = login to Claude │`);
266
+ lines.push(` │ ${cyan('k')} = login to Codex │`);
267
+ lines.push(' ├─────────────────────────────┤');
268
+ lines.push(` │ ${orange('In Claude session:')} │`);
269
+ lines.push(` │ ${green('Ctrl+C x2')} = back to menu │`);
270
+ lines.push(` │ ${green('Ctrl+C x3')} = exit to shell │`);
271
+ lines.push(' └─────────────────────────────┘');
272
+ lines.push('');
273
+
274
+ // Provider status line
275
+ const cStat = providers.claude.authed ? '✅' : providers.claude.installed ? '⚠️' : '❌';
276
+ const xStat = providers.codex.authed ? '✅' : providers.codex.installed ? '⚠️' : '❌';
277
+ lines.push(` 🟠 Claude ${cStat} 🟢 Codex ${xStat} ${pf.emoji} ${bold(pf.label)} ${dim('$' + profile.budgets.session_limit_usd + '/session')}`);
278
+
279
+ // Missing provider nudge
280
+ if (!providers.claude.authed || !providers.codex.authed) {
281
+ lines.push('');
282
+ if (!providers.claude.installed) lines.push(` ${dim('└')} Install Claude: ${cyan('curl -fsSL https://claude.ai/install.sh | sh')}`);
283
+ else if (!providers.claude.authed) lines.push(` ${dim('└')} Auth Claude: press ${bold('j')} below`);
284
+ if (!providers.codex.installed) lines.push(` ${dim('└')} Install Codex: ${cyan('npm i -g @openai/codex')}`);
285
+ else if (!providers.codex.authed) lines.push(` ${dim('└')} Auth Codex: press ${bold('k')} below`);
286
+ }
287
+
288
+ // Replit-tools check
289
+ if (IS_REPLIT && !hasReplitTools) {
290
+ lines.push('');
291
+ lines.push(` ⚠️ ${yellow('replit-tools not found')} — recommended for Replit environments`);
292
+ lines.push(` ${dim('└')} Press ${bold('t')} to install replit-tools`);
293
+ }
294
+
295
+ // Recent sessions
296
+ if (sessions.length > 0) {
297
+ lines.push('');
298
+ lines.push(` ${bold('Recent (last 24h):')}`);
299
+ for (let i = 0; i < sessions.length; i++) {
300
+ const s = sessions[i];
301
+ const num = String(i + 1);
302
+ const toolLabel = s.tool === 'codex' ? orange('cdx') : blue('cld');
303
+ const ago = timeAgo(s.lastSeen).padEnd(9);
304
+ lines.push(` ${bold('[' + num + ']')} ${toolLabel} ${dim(ago)} ${snippet(s.firstPrompt)}`);
305
+ }
306
+ }
307
+
308
+ // Session manager box
309
+ lines.push('');
310
+ lines.push(' ┌─────────────────────────────┐');
311
+ lines.push(' │ 🧠 Dual-Brain Session Mgr │');
312
+ lines.push(' └─────────────────────────────┘');
313
+
314
+ const runParts = [];
315
+ if (running.claude > 0) runParts.push(`${running.claude} claude`);
316
+ if (running.codex > 0) runParts.push(`${running.codex} codex`);
317
+ if (runParts.length > 0) lines.push(` ${dim('(' + runParts.join(', ') + ' running)')}`);
318
+ lines.push('');
319
+
320
+ // Menu options
321
+ lines.push(` ${bold('[c]')} Continue last session`);
322
+ if (sessions.length > 0) lines.push(` ${bold('[1-9]')} Resume numbered above`);
323
+ lines.push(` ${bold('[n]')} New session`);
324
+ lines.push(` ${bold('[p]')} Profile ${dim('(' + pf.emoji + ' ' + profile.name + ')')}`);
325
+ lines.push(` ${bold('[b]')} Budget ${dim('($' + profile.budgets.session_limit_usd + ' session / $' + profile.budgets.daily_limit_usd + ' daily)')}`);
326
+ lines.push(` ${bold('[j]')} Login to Claude`);
327
+ lines.push(` ${bold('[k]')} Login to Codex`);
328
+ if (IS_REPLIT && !hasReplitTools) lines.push(` ${bold('[t]')} Install replit-tools`);
329
+ lines.push(` ${bold('[s]')} Skip — just shell`);
330
+ lines.push('');
331
+
332
+ return { lines, sessions, providers };
333
+ }
334
+
335
+ // ─── Profile Picker ───────────────────────────────────────────────────────
336
+
337
+ function showProfilePicker(rl) {
338
+ return new Promise((resolve) => {
339
+ const current = loadProfile();
340
+ console.log('');
341
+ console.log(` ${bold('🎛️ Switch Profile:')}`);
342
+ console.log('');
343
+ for (const [i, [name, pf]] of Object.entries(PROFILES).entries()) {
344
+ const active = name === current.name ? ' ✅' : '';
345
+ console.log(` ${bold('[' + (i + 1) + ']')} ${pf.emoji} ${name.padEnd(15)} ${dim(pf.desc)}${active}`);
346
+ }
347
+ console.log(` ${bold('[q]')} Cancel`);
348
+ console.log('');
349
+
350
+ rl.question(' Choice: ', (answer) => {
351
+ const names = Object.keys(PROFILES);
352
+ const idx = parseInt(answer, 10) - 1;
353
+ if (idx >= 0 && idx < names.length) {
354
+ let customOverrides = null;
355
+ try {
356
+ const existing = JSON.parse(readFileSync(PROFILE_FILE, 'utf8'));
357
+ if (existing.custom_overrides?.budgets) customOverrides = { budgets: existing.custom_overrides.budgets };
358
+ } catch {}
359
+ saveProfile(names[idx], customOverrides);
360
+ const pf = PROFILES[names[idx]];
361
+ console.log(` ✅ Switched to ${pf.emoji} ${pf.label}`);
362
+ }
363
+ resolve();
364
+ });
365
+ });
366
+ }
367
+
368
+ // ─── Budget Editor ────────────────────────────────────────────────────────
369
+
370
+ function showBudgetEditor(rl) {
371
+ return new Promise((resolve) => {
372
+ const profile = loadProfile();
373
+ console.log('');
374
+ console.log(` ${bold('💵 Edit Budget')}`);
375
+ console.log(` ${dim('Current: $' + profile.budgets.session_limit_usd + ' session / $' + profile.budgets.daily_limit_usd + ' daily')}`);
376
+ console.log('');
377
+
378
+ rl.question(' Session limit ($): ', (sessionStr) => {
379
+ const session = parseFloat(sessionStr);
380
+ if (isNaN(session) || session <= 0) {
381
+ console.log(' Cancelled.');
382
+ return resolve();
383
+ }
384
+ rl.question(' Daily limit ($, Enter = auto): ', (dailyStr) => {
385
+ const daily = parseFloat(dailyStr);
386
+ const finalDaily = (isNaN(daily) || daily <= 0) ? session * 3 : daily;
387
+
388
+ let existing = {};
389
+ try { existing = JSON.parse(readFileSync(PROFILE_FILE, 'utf8')); } catch {}
390
+ const custom = existing.custom_overrides || {};
391
+ custom.budgets = {
392
+ session_warn_usd: +(session * 0.6).toFixed(2),
393
+ session_limit_usd: session,
394
+ daily_warn_usd: +(finalDaily * 0.6).toFixed(2),
395
+ daily_limit_usd: finalDaily,
396
+ };
397
+ const data = { active: existing.active || 'balanced', switched_at: existing.switched_at || new Date().toISOString(), custom_overrides: custom };
398
+ const tmp = PROFILE_FILE + '.tmp.' + process.pid;
399
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
400
+ renameSync(tmp, PROFILE_FILE);
401
+
402
+ console.log(` ✅ Budget: $${session}/session · $${finalDaily}/daily`);
403
+ resolve();
404
+ });
405
+ });
406
+ });
407
+ }
408
+
409
+ // ─── Session Runner ───────────────────────────────────────────────────────
410
+
411
+ function runSession(cmd, args, label) {
412
+ console.log('');
413
+ console.log(` ${label}...`);
414
+ console.log('');
415
+ const result = spawnSync(cmd, args, { stdio: 'inherit' });
416
+ console.log('');
417
+ console.log(' Exited. Returning to menu...');
418
+ return result.status || 0;
419
+ }
420
+
421
+ // ─── Main Loop ────────────────────────────────────────────────────────────
422
+
423
+ async function mainLoop() {
424
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
425
+
426
+ const ask = () => new Promise(resolve => rl.question(' Choice: ', resolve));
427
+
428
+ while (true) {
429
+ const { lines, sessions } = renderMenu();
430
+ for (const l of lines) console.log(l);
431
+
432
+ const choice = (await ask()).trim().toLowerCase();
433
+
434
+ if (choice === 's' || choice === 'q') {
435
+ console.log('');
436
+ rl.close();
437
+ return;
438
+ }
439
+
440
+ if (choice === 'c' || choice === '') {
441
+ // Continue most recent session
442
+ if (sessions.length > 0) {
443
+ const s = sessions[0];
444
+ if (s.tool === 'codex') {
445
+ runSession('codex', ['--dangerously-bypass-approvals-and-sandbox', 'resume', s.id], `Resuming codex session ${s.id.slice(0, 8)}`);
446
+ } else {
447
+ runSession('claude', ['-r', s.id, '--dangerously-skip-permissions'], `Resuming session ${s.id.slice(0, 8)}`);
448
+ }
449
+ } else {
450
+ runSession('claude', ['--dangerously-skip-permissions'], 'Starting new session');
451
+ }
452
+ continue;
453
+ }
454
+
455
+ const num = parseInt(choice, 10);
456
+ if (num >= 1 && num <= 9 && sessions[num - 1]) {
457
+ const s = sessions[num - 1];
458
+ if (s.tool === 'codex') {
459
+ runSession('codex', ['--dangerously-bypass-approvals-and-sandbox', 'resume', s.id], `Resuming codex session ${s.id.slice(0, 8)}`);
460
+ } else {
461
+ runSession('claude', ['-r', s.id, '--dangerously-skip-permissions'], `Resuming session ${s.id.slice(0, 8)}`);
462
+ }
463
+ continue;
464
+ }
465
+
466
+ if (choice === 'n') {
467
+ runSession('claude', ['--dangerously-skip-permissions'], 'Starting new session');
468
+ continue;
469
+ }
470
+
471
+ if (choice === 'p') {
472
+ await showProfilePicker(rl);
473
+ continue;
474
+ }
475
+
476
+ if (choice === 'b') {
477
+ await showBudgetEditor(rl);
478
+ continue;
479
+ }
480
+
481
+ if (choice === 'j') {
482
+ console.log('');
483
+ console.log(' Starting Claude login...');
484
+ console.log('');
485
+ spawnSync('claude', ['login'], { stdio: 'inherit' });
486
+ continue;
487
+ }
488
+
489
+ if (choice === 'k') {
490
+ const codexPath = spawnSync('which', ['codex'], { encoding: 'utf8', stdio: 'pipe', timeout: 3000 });
491
+ if (codexPath.status !== 0) {
492
+ console.log('');
493
+ console.log(` Codex not installed. Run: ${cyan('npm i -g @openai/codex')}`);
494
+ console.log('');
495
+ await ask();
496
+ continue;
497
+ }
498
+ console.log('');
499
+ console.log(' Starting Codex login...');
500
+ console.log('');
501
+ spawnSync(codexPath.stdout.trim(), ['login'], { stdio: 'inherit' });
502
+ continue;
503
+ }
504
+
505
+ if (choice === 't' && IS_REPLIT) {
506
+ console.log('');
507
+ console.log(' Installing replit-tools...');
508
+ console.log('');
509
+ spawnSync('npx', ['-y', 'data-tools'], { stdio: 'inherit', cwd: CWD });
510
+ console.log('');
511
+ console.log(' ✅ replit-tools installed. You may need to restart your shell.');
512
+ console.log('');
513
+ await ask();
514
+ continue;
515
+ }
516
+
517
+ console.log(` Unknown option: ${choice}`);
518
+ }
519
+ }
520
+
521
+ // ─── Non-Interactive Fallback ─────────────────────────────────────────────
522
+
523
+ function renderStatic() {
524
+ const { lines } = renderMenu();
525
+ for (const l of lines) console.log(l);
526
+ }
527
+
528
+ // ─── Entry ────────────────────────────────────────────────────────────────
529
+
530
+ if (process.stdin.isTTY && process.stdout.isTTY && !process.env.CI) {
531
+ mainLoop().catch(err => { console.error(err); process.exit(1); });
532
+ } else {
533
+ renderStatic();
534
+ }