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 +20 -1
- package/hooks/control-panel.mjs +534 -0
- package/hooks/dual-brain-review.mjs +106 -17
- package/hooks/dual-brain-think.mjs +81 -17
- package/install.mjs +88 -194
- package/package.json +1 -1
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
|
-
|
|
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
|
+
}
|