dual-brain 0.1.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/AGENTS.md +97 -0
- package/CLAUDE.md +147 -0
- package/LICENSE +21 -0
- package/README.md +197 -0
- package/agents/implementer.md +22 -0
- package/agents/researcher.md +25 -0
- package/agents/verifier.md +30 -0
- package/bin/dual-brain.mjs +2868 -0
- package/hooks/auto-update-wrapper.mjs +102 -0
- package/hooks/auto-update.sh +67 -0
- package/hooks/budget-balancer.mjs +679 -0
- package/hooks/control-panel.mjs +1195 -0
- package/hooks/cost-logger.mjs +286 -0
- package/hooks/cost-report.mjs +351 -0
- package/hooks/decision-ledger.mjs +299 -0
- package/hooks/dual-brain-review.mjs +404 -0
- package/hooks/dual-brain-think.mjs +393 -0
- package/hooks/enforce-tier.mjs +469 -0
- package/hooks/failure-detector.mjs +138 -0
- package/hooks/gpt-work-dispatcher.mjs +512 -0
- package/hooks/head-guard.mjs +105 -0
- package/hooks/health-check.mjs +444 -0
- package/hooks/install-git-hooks.mjs +106 -0
- package/hooks/model-registry.mjs +859 -0
- package/hooks/plan-generator.mjs +544 -0
- package/hooks/profiles.mjs +254 -0
- package/hooks/quality-gate.mjs +355 -0
- package/hooks/risk-classifier.mjs +41 -0
- package/hooks/session-report.mjs +514 -0
- package/hooks/setup-wizard.mjs +130 -0
- package/hooks/summary-checkpoint.mjs +432 -0
- package/hooks/task-classifier.mjs +328 -0
- package/hooks/test-orchestrator.mjs +1077 -0
- package/hooks/vibe-memory.mjs +463 -0
- package/hooks/vibe-router.mjs +387 -0
- package/hooks/wave-orchestrator.mjs +1397 -0
- package/install.mjs +1541 -0
- package/mcp-server/README.md +81 -0
- package/mcp-server/index.mjs +388 -0
- package/orchestrator.json +215 -0
- package/package.json +108 -0
- package/playbooks/debug.json +49 -0
- package/playbooks/refactor.json +57 -0
- package/playbooks/security-audit.json +57 -0
- package/playbooks/security.json +38 -0
- package/playbooks/test-gen.json +48 -0
- package/plugin.json +22 -0
- package/review-rules.md +17 -0
- package/shell-hook.sh +26 -0
- package/skills/go.md +22 -0
- package/skills/review.md +19 -0
- package/skills/status.md +13 -0
- package/skills/think.md +22 -0
- package/src/brief.mjs +266 -0
- package/src/decide.mjs +635 -0
- package/src/decompose.mjs +331 -0
- package/src/detect.mjs +345 -0
- package/src/dispatch.mjs +942 -0
- package/src/health.mjs +253 -0
- package/src/index.mjs +44 -0
- package/src/install-hooks.mjs +100 -0
- package/src/playbook.mjs +257 -0
- package/src/profile.mjs +990 -0
- package/src/redact.mjs +192 -0
- package/src/repo.mjs +292 -0
- package/src/session.mjs +1036 -0
- package/src/tui.mjs +197 -0
- package/src/update-check.mjs +35 -0
|
@@ -0,0 +1,2868 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// dual-brain — CLI entry point. Commands: init, go, status, remember, forget
|
|
3
|
+
|
|
4
|
+
import { appendFileSync, existsSync, readFileSync, mkdirSync, writeFileSync, statSync, readdirSync, unlinkSync } from 'node:fs';
|
|
5
|
+
import { join, dirname } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { execSync, spawnSync as _spawnSyncTop } from 'node:child_process';
|
|
8
|
+
import { createInterface } from 'node:readline';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
ensureProfile, loadProfile, saveProfile, runOnboarding,
|
|
12
|
+
rememberPreference, forgetPreference, getActivePreferences,
|
|
13
|
+
getAvailableProviders, isSoloBrain, getHeadModel,
|
|
14
|
+
detectAuth, detectEnvironment, detectPlans,
|
|
15
|
+
saveSubscription, listSubscriptions,
|
|
16
|
+
autoSetup,
|
|
17
|
+
} from '../src/profile.mjs';
|
|
18
|
+
|
|
19
|
+
import { detectTask } from '../src/detect.mjs';
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
decideRoute, getAvailableModels,
|
|
23
|
+
} from '../src/decide.mjs';
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
getHealth, markHot, markHealthy, remainingCooldownMinutes, getSessionStats,
|
|
27
|
+
} from '../src/health.mjs';
|
|
28
|
+
|
|
29
|
+
import { dispatch, detectRuntime, dispatchDualBrain } from '../src/dispatch.mjs';
|
|
30
|
+
|
|
31
|
+
import { loadRepoCache } from '../src/repo.mjs';
|
|
32
|
+
import { loadSession, saveSession, formatSessionCard, importReplitSessions, renameSession, pinSession, unpinSession, categorizeSession, enrichSessions } from '../src/session.mjs';
|
|
33
|
+
|
|
34
|
+
import { box, bar, badge, menu, separator } from '../src/tui.mjs';
|
|
35
|
+
|
|
36
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
const PKG_PATH = join(__dirname, '..', 'package.json');
|
|
40
|
+
|
|
41
|
+
function readVersion() {
|
|
42
|
+
try { return JSON.parse(readFileSync(PKG_PATH, 'utf8')).version; } catch { return '0.0.0'; }
|
|
43
|
+
}
|
|
44
|
+
async function checkForUpdates(currentVersion) {
|
|
45
|
+
try {
|
|
46
|
+
const { execSync } = await import('node:child_process');
|
|
47
|
+
const latest = execSync('npm view dual-brain version 2>/dev/null', {
|
|
48
|
+
encoding: 'utf8',
|
|
49
|
+
timeout: 3000
|
|
50
|
+
}).trim();
|
|
51
|
+
if (latest && latest !== currentVersion) {
|
|
52
|
+
return latest;
|
|
53
|
+
}
|
|
54
|
+
} catch {}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
function flag(args, name) { const i = args.indexOf(name); return i !== -1 ? (args[i + 1] ?? true) : null; }
|
|
58
|
+
function err(msg) { process.stderr.write(`Error: ${msg}\n`); process.exit(1); }
|
|
59
|
+
function vtrace(msg) { process.stderr.write(`[verbose] ${msg}\n`); }
|
|
60
|
+
|
|
61
|
+
// ─── Loop-prevention markers ──────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
function checkLoopMarker(cwd) {
|
|
64
|
+
const markerPath = join(cwd, '.dualbrain', `.prompt-shown-${process.pid}`);
|
|
65
|
+
if (existsSync(markerPath)) {
|
|
66
|
+
try {
|
|
67
|
+
const age = Date.now() - statSync(markerPath).mtimeMs;
|
|
68
|
+
if (age < 3600000) return true; // Not stale, skip prompt
|
|
69
|
+
} catch {}
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function setLoopMarker(cwd) {
|
|
75
|
+
const dir = join(cwd, '.dualbrain');
|
|
76
|
+
try {
|
|
77
|
+
mkdirSync(dir, { recursive: true });
|
|
78
|
+
writeFileSync(join(dir, `.prompt-shown-${process.pid}`), String(Date.now()));
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function cleanStaleMarkers(cwd) {
|
|
83
|
+
const dir = join(cwd, '.dualbrain');
|
|
84
|
+
try {
|
|
85
|
+
for (const f of readdirSync(dir)) {
|
|
86
|
+
if (!f.startsWith('.prompt-shown-')) continue;
|
|
87
|
+
const pid = f.replace('.prompt-shown-', '');
|
|
88
|
+
try {
|
|
89
|
+
process.kill(parseInt(pid, 10), 0);
|
|
90
|
+
} catch {
|
|
91
|
+
// Process dead, remove marker
|
|
92
|
+
try { unlinkSync(join(dir, f)); } catch {}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildSparkline(cwd) {
|
|
99
|
+
const indexPath = join(cwd, '.dualbrain', 'session-index.json');
|
|
100
|
+
let index = {};
|
|
101
|
+
try { index = JSON.parse(readFileSync(indexPath, 'utf8')); } catch { return null; }
|
|
102
|
+
|
|
103
|
+
const sessions = Object.values(index);
|
|
104
|
+
if (sessions.length < 2) return null;
|
|
105
|
+
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
const days = 7;
|
|
108
|
+
const buckets = new Array(days).fill(0);
|
|
109
|
+
|
|
110
|
+
for (const sess of sessions) {
|
|
111
|
+
if (!sess.date) continue;
|
|
112
|
+
const age = (now - Date.parse(sess.date)) / 86400000;
|
|
113
|
+
const bucket = Math.floor(age);
|
|
114
|
+
if (bucket >= 0 && bucket < days) {
|
|
115
|
+
buckets[days - 1 - bucket]++;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const max = Math.max(...buckets, 1);
|
|
120
|
+
const blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
|
121
|
+
const spark = buckets.map(v => {
|
|
122
|
+
if (v === 0) return ' ';
|
|
123
|
+
const idx = Math.min(Math.floor((v / max) * (blocks.length - 1)), blocks.length - 1);
|
|
124
|
+
return blocks[idx];
|
|
125
|
+
}).join('');
|
|
126
|
+
|
|
127
|
+
const total = buckets.reduce((a, b) => a + b, 0);
|
|
128
|
+
if (total === 0) return null;
|
|
129
|
+
return `${spark} ${total} sessions (7d)`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function daysUntil(isoDate) {
|
|
133
|
+
if (!isoDate) return null;
|
|
134
|
+
const ms = Date.parse(isoDate) - Date.now();
|
|
135
|
+
return Math.ceil(ms / 86400000);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function askExpiry(ask, provLabel) {
|
|
139
|
+
console.log(` ${provLabel} — how long should this auth last?`);
|
|
140
|
+
console.log(' (1) 1 week (2) 2 weeks (3) 1 month (4) Custom date (Enter) No expiry');
|
|
141
|
+
const choice = (await ask(' > ')).trim();
|
|
142
|
+
const now = new Date();
|
|
143
|
+
if (choice === '1') { now.setDate(now.getDate() + 7); return now.toISOString(); }
|
|
144
|
+
if (choice === '2') { now.setDate(now.getDate() + 14); return now.toISOString(); }
|
|
145
|
+
if (choice === '3') { now.setMonth(now.getMonth() + 1); return now.toISOString(); }
|
|
146
|
+
if (choice === '4') {
|
|
147
|
+
const d = (await ask(' Date YYYY-MM-DD: ')).trim();
|
|
148
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(d)) return new Date(d).toISOString();
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function printHelp() {
|
|
154
|
+
console.log(`
|
|
155
|
+
dual-brain <command> [options]
|
|
156
|
+
|
|
157
|
+
Commands:
|
|
158
|
+
init First-time setup → flows into interactive REPL
|
|
159
|
+
auth Show subscription and login status
|
|
160
|
+
install Install Claude Code hooks into the current project
|
|
161
|
+
go "task description" Detect → decide → dispatch a task
|
|
162
|
+
--dry-run Show routing decision without executing
|
|
163
|
+
--files a.mjs,b.mjs Provide file context for risk classification
|
|
164
|
+
--verbose, -v Print routing trace (intent, risk, health, model selection)
|
|
165
|
+
status Provider health, session stats, available models
|
|
166
|
+
--verbose, -v Also print profile file path and raw profile object
|
|
167
|
+
hot <provider> Manually mark all model classes for provider as hot
|
|
168
|
+
cool <provider> Manually clear hot state for a provider
|
|
169
|
+
remember "preference" Save a project-scoped preference
|
|
170
|
+
forget "preference" Remove a preference by fuzzy match
|
|
171
|
+
search "keyword" Search across all sessions
|
|
172
|
+
specialists List available specialist agents with descriptions
|
|
173
|
+
python "task" Force Python specialist for the task
|
|
174
|
+
typescript "task" Force TypeScript specialist for the task
|
|
175
|
+
html "task" Force HTML/CSS specialist for the task
|
|
176
|
+
linux "task" Force Linux/DevOps specialist for the task
|
|
177
|
+
security "task" Force Security specialist for the task
|
|
178
|
+
--dry-run (specialist commands) Show routing without executing
|
|
179
|
+
--files a,b (specialist commands) Provide file context
|
|
180
|
+
shell-hook Output bash snippet to add dual-brain to your shell
|
|
181
|
+
Usage: dual-brain shell-hook >> ~/.bashrc
|
|
182
|
+
|
|
183
|
+
Interactive mode (entered with no args on a TTY):
|
|
184
|
+
Session manager with recent sessions and routing.
|
|
185
|
+
[n] New session, [c] Continue last, [1-9] Resume, [s] Settings, [q] Exit
|
|
186
|
+
|
|
187
|
+
Options:
|
|
188
|
+
--version Print version
|
|
189
|
+
--help Show this help
|
|
190
|
+
--verbose, -v Enable verbose routing trace output (stderr)
|
|
191
|
+
`.trim());
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ─── replit-tools detection ───────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
function detectReplitTools(cwd) {
|
|
197
|
+
const replitToolsDir = join(cwd, '.replit-tools');
|
|
198
|
+
const hasDir = existsSync(replitToolsDir);
|
|
199
|
+
const hasConfig = existsSync(join(replitToolsDir, 'config.json'));
|
|
200
|
+
const hasScripts = existsSync(join(replitToolsDir, 'scripts', 'setup-claude-code.sh'));
|
|
201
|
+
const hasArchive = existsSync(join(replitToolsDir, '.session-archive'));
|
|
202
|
+
|
|
203
|
+
let version = null;
|
|
204
|
+
try {
|
|
205
|
+
version = readFileSync(join(replitToolsDir, '.version'), 'utf8').trim();
|
|
206
|
+
} catch {}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
installed: hasDir,
|
|
210
|
+
version,
|
|
211
|
+
hasConfig,
|
|
212
|
+
hasScripts,
|
|
213
|
+
hasArchive,
|
|
214
|
+
dir: replitToolsDir,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ─── Subscription status table ────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Print a subscription status table to stdout.
|
|
222
|
+
*/
|
|
223
|
+
function printSubscriptionTable(auth, profile) {
|
|
224
|
+
const W = 55;
|
|
225
|
+
const hbar = '═'.repeat(W);
|
|
226
|
+
const pad = (s) => {
|
|
227
|
+
const visible = s.replace(/[̀-ͯ]/g, '');
|
|
228
|
+
return s + ' '.repeat(Math.max(0, W - visible.length));
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const claudeSub = profile?.providers?.claude;
|
|
232
|
+
const openaiSub = profile?.providers?.openai;
|
|
233
|
+
|
|
234
|
+
const claudePlanLabel = claudeSub?.enabled
|
|
235
|
+
? ({ pro: 'Pro ($20/mo)', max5: 'Max x5 ($100/mo)', max20: 'Max x20 ($200/mo)', '$20': 'Pro ($20/mo)', '$100': 'Max x5 ($100/mo)', '$200': 'Max x20 ($200/mo)' }[claudeSub.plan] ?? claudeSub.plan)
|
|
236
|
+
: 'disabled';
|
|
237
|
+
const openaiPlanLabel = openaiSub?.enabled
|
|
238
|
+
? ({ plus: 'Plus ($20/mo)', pro: 'Pro ($100/mo)', pro100: 'Pro ($100/mo)', pro200: 'Pro ($200/mo)', '$20': 'Plus ($20/mo)', '$100': 'Pro ($100/mo)', '$200': 'Pro ($200/mo)' }[openaiSub.plan] ?? openaiSub.plan)
|
|
239
|
+
: 'disabled';
|
|
240
|
+
|
|
241
|
+
const claudeLabel = claudeSub?.label ? ` [${claudeSub.label}]` : '';
|
|
242
|
+
const openaiLabel = openaiSub?.label ? ` [${openaiSub.label}]` : '';
|
|
243
|
+
|
|
244
|
+
const claudeLine1 = auth.claude.found
|
|
245
|
+
? ` Claude: logged in (${auth.claude.source})`
|
|
246
|
+
: ` Claude: not logged in — run: claude auth login`;
|
|
247
|
+
const claudeLine2 = ` plan: ${claudePlanLabel}${claudeLabel}`;
|
|
248
|
+
|
|
249
|
+
const openaiLine1 = auth.openai.found
|
|
250
|
+
? ` OpenAI: logged in (${auth.openai.source})`
|
|
251
|
+
: ` OpenAI: not logged in — run: codex login`;
|
|
252
|
+
const openaiLine2 = ` plan: ${openaiPlanLabel}${openaiLabel}`;
|
|
253
|
+
|
|
254
|
+
console.log(`╔${hbar}╗`);
|
|
255
|
+
console.log(`║${pad(' Subscription Status')}║`);
|
|
256
|
+
console.log(`╠${hbar}╣`);
|
|
257
|
+
console.log(`║${pad(claudeLine1)}║`);
|
|
258
|
+
console.log(`║${pad(claudeLine2)}║`);
|
|
259
|
+
console.log(`║${pad(openaiLine1)}║`);
|
|
260
|
+
console.log(`║${pad(openaiLine2)}║`);
|
|
261
|
+
console.log(`╚${hbar}╝`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ─── Commands ─────────────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
async function cmdInit(rl) {
|
|
267
|
+
const cwd = process.cwd();
|
|
268
|
+
|
|
269
|
+
// --- Step 1: Detect auth ---
|
|
270
|
+
const auth = await detectAuth();
|
|
271
|
+
printSubscriptionTable(auth, loadProfile(cwd));
|
|
272
|
+
|
|
273
|
+
const noneFound = !auth.claude.found && !auth.openai.found;
|
|
274
|
+
if (noneFound) {
|
|
275
|
+
console.log('\nNo AI provider found. Log in first:');
|
|
276
|
+
console.log(' Claude: claude auth login');
|
|
277
|
+
console.log(' OpenAI: codex login\n');
|
|
278
|
+
console.log('Then re-run: dual-brain init');
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// --- Step 2: Run onboarding wizard ---
|
|
283
|
+
const profile = await runOnboarding({ interactive: true, detectedAuth: auth, rl });
|
|
284
|
+
saveProfile(profile, { cwd });
|
|
285
|
+
|
|
286
|
+
// --- Step 2b: Install hooks ---
|
|
287
|
+
await cmdInstall(cwd);
|
|
288
|
+
|
|
289
|
+
// --- Step 3: Show dashboard ---
|
|
290
|
+
console.log('');
|
|
291
|
+
const repo = loadRepoCache(cwd);
|
|
292
|
+
const session = loadSession(cwd);
|
|
293
|
+
const health = getHealth(cwd);
|
|
294
|
+
const card = formatSessionCard(session, repo, health);
|
|
295
|
+
console.log(card);
|
|
296
|
+
console.log('\nReady! Type a task below, or "help" for commands.\n');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Show subscription status (replaces old API key auth display).
|
|
301
|
+
*/
|
|
302
|
+
async function cmdAuth(subArgs = []) {
|
|
303
|
+
const auth = await detectAuth();
|
|
304
|
+
const profile = loadProfile(process.cwd());
|
|
305
|
+
printSubscriptionTable(auth, profile);
|
|
306
|
+
|
|
307
|
+
if (!auth.claude.found || !auth.openai.found) {
|
|
308
|
+
console.log('');
|
|
309
|
+
if (!auth.claude.found) console.log(' Claude not logged in. Run: claude auth login');
|
|
310
|
+
if (!auth.openai.found) console.log(' OpenAI not logged in. Run: codex login');
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function cmdGo(args) {
|
|
315
|
+
const dryRun = args.includes('--dry-run');
|
|
316
|
+
const verbose = args.includes('--verbose') || args.includes('-v');
|
|
317
|
+
const filesRaw = flag(args, '--files');
|
|
318
|
+
const files = filesRaw && typeof filesRaw === 'string'
|
|
319
|
+
? filesRaw.split(',').map(f => f.trim()).filter(Boolean)
|
|
320
|
+
: [];
|
|
321
|
+
|
|
322
|
+
// prompt is the first non-flag argument (or value after --dry-run which is boolean)
|
|
323
|
+
const prompt = args.find(a => !a.startsWith('--') && !a.startsWith('-') && a !== (filesRaw ?? ''));
|
|
324
|
+
if (!prompt) err('Usage: dual-brain go "task description" [--dry-run] [--files a,b] [--verbose]');
|
|
325
|
+
|
|
326
|
+
const cwd = process.cwd();
|
|
327
|
+
const profile = await ensureProfile(cwd);
|
|
328
|
+
const detection = detectTask({ prompt, files });
|
|
329
|
+
|
|
330
|
+
// Print the one-sentence classification
|
|
331
|
+
console.log(detection.explanation);
|
|
332
|
+
|
|
333
|
+
// Verbose: emit detection trace before routing decision
|
|
334
|
+
if (verbose) {
|
|
335
|
+
vtrace(`Intent: ${detection.intent} | Risk: ${detection.risk} | Complexity: ${detection.complexity} | Effort: ${detection.effort ?? 'n/a'}`);
|
|
336
|
+
vtrace(`Tier: ${detection.tier} | Files: ${detection.fileCount ?? files.length} | Requires write: ${detection.requiresWrite}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Verbose: emit provider health scores before dispatch
|
|
340
|
+
if (verbose) {
|
|
341
|
+
const providers = getAvailableProviders(profile);
|
|
342
|
+
const { states } = getHealth(cwd);
|
|
343
|
+
const providerScores = ['claude', 'openai'].map(name => {
|
|
344
|
+
const enabled = providers.some(p => p.name === name);
|
|
345
|
+
if (!enabled) return `${name}=unavailable`;
|
|
346
|
+
// Find any state entry for this provider
|
|
347
|
+
const statuses = Object.entries(states)
|
|
348
|
+
.filter(([k]) => k.startsWith(`${name}:`))
|
|
349
|
+
.map(([, v]) => v.status);
|
|
350
|
+
const worst = statuses.includes('hot') ? 'hot'
|
|
351
|
+
: statuses.includes('probing') ? 'probing'
|
|
352
|
+
: statuses.includes('degraded') ? 'degraded'
|
|
353
|
+
: 'healthy';
|
|
354
|
+
return `${name}=${worst}`;
|
|
355
|
+
}).join(' ');
|
|
356
|
+
vtrace(`Provider health: ${providerScores}`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const decision = decideRoute({ profile, detection, cwd });
|
|
360
|
+
|
|
361
|
+
// Verbose: emit model selection and dual-brain rationale
|
|
362
|
+
if (verbose) {
|
|
363
|
+
const modelLabel = decision.effort ? `${decision.model} (${decision.effort})` : decision.model;
|
|
364
|
+
const modelStatus = getAvailableModels(profile)[decision.provider]?.includes(decision.model)
|
|
365
|
+
? 'available, matches tier'
|
|
366
|
+
: 'selected';
|
|
367
|
+
vtrace(`Model selection: ${modelLabel} (${modelStatus})`);
|
|
368
|
+
vtrace(`Dual-brain: ${decision.dualBrain ? 'yes' : 'no'} (${isSoloBrain(profile) ? 'solo provider' : 'dual provider'}, ${detection.risk} risk)`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Print routing table
|
|
372
|
+
console.log(` provider : ${decision.provider}`);
|
|
373
|
+
console.log(` model : ${decision.model}${decision.effort ? ' (' + decision.effort + ')' : ''}`);
|
|
374
|
+
console.log(` tier : ${decision.tier}`);
|
|
375
|
+
console.log(` dual-brain : ${decision.dualBrain ? 'yes' : 'no'}`);
|
|
376
|
+
console.log(` reason : ${decision.explanation}`);
|
|
377
|
+
|
|
378
|
+
if (dryRun) {
|
|
379
|
+
console.log('\n(dry-run — not executing)');
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
console.log('\nDispatching...');
|
|
384
|
+
let result;
|
|
385
|
+
if (decision.dualBrain) {
|
|
386
|
+
result = await dispatchDualBrain({ decision, prompt, files, cwd });
|
|
387
|
+
console.log(`\nConsensus: ${result.consensus}`);
|
|
388
|
+
if (result.claude?.summary) console.log(`Claude : ${result.claude.summary}`);
|
|
389
|
+
if (result.openai?.summary) console.log(`OpenAI : ${result.openai.summary}`);
|
|
390
|
+
// Save session state
|
|
391
|
+
saveSession({
|
|
392
|
+
objective: prompt,
|
|
393
|
+
branch: null,
|
|
394
|
+
filesChanged: files,
|
|
395
|
+
commandsRun: [`dual-brain go "${prompt}"`],
|
|
396
|
+
lastResult: { status: 'success', summary: result.consensus || 'dual-brain complete' },
|
|
397
|
+
provider: decision.provider,
|
|
398
|
+
nextAction: null,
|
|
399
|
+
}, cwd);
|
|
400
|
+
} else {
|
|
401
|
+
result = await dispatch({ decision, prompt, files, cwd });
|
|
402
|
+
const statusLine = result.status === 'completed' ? 'Done' : `Failed (exit ${result.exitCode})`;
|
|
403
|
+
console.log(`\n${statusLine} in ${(result.durationMs / 1000).toFixed(1)}s`);
|
|
404
|
+
if (result.summary) console.log(result.summary);
|
|
405
|
+
if (result.error) process.stderr.write(`${result.error}\n`);
|
|
406
|
+
// Save session state regardless of success/failure
|
|
407
|
+
saveSession({
|
|
408
|
+
objective: prompt,
|
|
409
|
+
branch: null,
|
|
410
|
+
filesChanged: files,
|
|
411
|
+
commandsRun: [`dual-brain go "${prompt}"`],
|
|
412
|
+
lastResult: {
|
|
413
|
+
status: result.status === 'completed' ? 'success' : 'failure',
|
|
414
|
+
summary: result.summary || (result.status === 'completed' ? 'completed' : `exit ${result.exitCode}`),
|
|
415
|
+
},
|
|
416
|
+
provider: decision.provider,
|
|
417
|
+
nextAction: null,
|
|
418
|
+
}, cwd);
|
|
419
|
+
if (result.status !== 'completed') process.exit(1);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function cmdStatus(args = []) {
|
|
424
|
+
const verbose = args.includes('--verbose') || args.includes('-v');
|
|
425
|
+
const cwd = process.cwd();
|
|
426
|
+
const profile = loadProfile(cwd);
|
|
427
|
+
const rt = await detectRuntime();
|
|
428
|
+
const providers = getAvailableProviders(profile);
|
|
429
|
+
const available = getAvailableModels(profile);
|
|
430
|
+
const prefs = getActivePreferences(cwd);
|
|
431
|
+
const { states } = getHealth(cwd);
|
|
432
|
+
const sessionStats = getSessionStats(cwd);
|
|
433
|
+
|
|
434
|
+
console.log('=== Dual-Brain Status ===\n');
|
|
435
|
+
|
|
436
|
+
// Providers + health
|
|
437
|
+
console.log('Providers:');
|
|
438
|
+
if (providers.length === 0) {
|
|
439
|
+
console.log(' (none configured — run: dual-brain init)');
|
|
440
|
+
} else {
|
|
441
|
+
for (const p of providers) {
|
|
442
|
+
const label = p.name === 'claude' ? 'Claude' : 'OpenAI';
|
|
443
|
+
// Collect all model-class states for this provider
|
|
444
|
+
const provStates = Object.entries(states)
|
|
445
|
+
.filter(([k]) => k.startsWith(`${p.name}:`));
|
|
446
|
+
const sess = sessionStats[p.name] ?? { calls: 0, tokens: 0 };
|
|
447
|
+
|
|
448
|
+
if (provStates.length === 0) {
|
|
449
|
+
console.log(` ${label} plan=${p.plan} status=healthy calls=${sess.calls} tokens=${sess.tokens}`);
|
|
450
|
+
} else {
|
|
451
|
+
for (const [k, st] of provStates) {
|
|
452
|
+
const modelClass = k.split(':').slice(1).join(':');
|
|
453
|
+
let statusStr = st.status;
|
|
454
|
+
if (st.status === 'hot') {
|
|
455
|
+
const remaining = remainingCooldownMinutes(p.name, modelClass, cwd);
|
|
456
|
+
statusStr = remaining > 0 ? `hot (retry in ${remaining}m)` : 'hot (cooling)';
|
|
457
|
+
}
|
|
458
|
+
console.log(` ${label} plan=${p.plan} model=${modelClass} status=${statusStr} calls=${sess.calls} tokens=${sess.tokens}`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Session totals
|
|
465
|
+
const totalCalls = Object.values(sessionStats).reduce((s, v) => s + v.calls, 0);
|
|
466
|
+
const totalTokens = Object.values(sessionStats).reduce((s, v) => s + v.tokens, 0);
|
|
467
|
+
console.log(`\nSession: ${totalCalls} dispatch${totalCalls !== 1 ? 'es' : ''}, ${totalTokens} tokens observed`);
|
|
468
|
+
|
|
469
|
+
// Models — only list enabled providers
|
|
470
|
+
console.log('\nAvailable models:');
|
|
471
|
+
const claudeEnabled = profile?.providers?.claude?.enabled !== false;
|
|
472
|
+
const openaiEnabled = profile?.providers?.openai?.enabled !== false;
|
|
473
|
+
if (claudeEnabled && available.claude.length) {
|
|
474
|
+
console.log(` Claude : ${available.claude.join(', ')}`);
|
|
475
|
+
} else if (!claudeEnabled) {
|
|
476
|
+
console.log(` Claude : (disabled — run "dual-brain init" to enable)`);
|
|
477
|
+
}
|
|
478
|
+
if (openaiEnabled && available.openai.length) {
|
|
479
|
+
console.log(` OpenAI : ${available.openai.join(', ')}`);
|
|
480
|
+
} else if (!openaiEnabled) {
|
|
481
|
+
console.log(` OpenAI : (disabled — run "dual-brain init" to enable)`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Head model
|
|
485
|
+
console.log(`\nHead model : ${getHeadModel(profile)}`);
|
|
486
|
+
console.log(`Mode : ${profile.mode}`);
|
|
487
|
+
console.log(`Solo brain : ${isSoloBrain(profile) ? 'yes' : 'no'}`);
|
|
488
|
+
|
|
489
|
+
// Runtime
|
|
490
|
+
console.log('\nRuntime:');
|
|
491
|
+
console.log(` claude CLI : ${rt.claudeAvailable ? 'available' : 'not found'}`);
|
|
492
|
+
console.log(` codex CLI : ${rt.codexAvailable ? 'available' : 'not found'}`);
|
|
493
|
+
console.log(` detected : ${rt.runtime}`);
|
|
494
|
+
|
|
495
|
+
// Preferences
|
|
496
|
+
console.log(`\nPreferences: ${prefs.length ? '' : '(none)'}`);
|
|
497
|
+
for (const p of prefs) console.log(` [${p.scope}] ${p.text}`);
|
|
498
|
+
|
|
499
|
+
// Verbose: profile file path and raw object
|
|
500
|
+
if (verbose) {
|
|
501
|
+
const { homedir } = await import('node:os');
|
|
502
|
+
const globalPath = join(homedir(), '.config', 'dual-brain', 'profile.json');
|
|
503
|
+
const projectPath = join(cwd, '.dualbrain', 'profile.json');
|
|
504
|
+
const { existsSync } = await import('node:fs');
|
|
505
|
+
const loadedFrom = existsSync(projectPath) ? projectPath : existsSync(globalPath) ? globalPath : '(defaults)';
|
|
506
|
+
vtrace(`Profile file: ${loadedFrom}`);
|
|
507
|
+
vtrace(`Raw profile:\n${JSON.stringify(profile, null, 2)}`);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Enforcement health check
|
|
511
|
+
console.log('\nEnforcement:');
|
|
512
|
+
try {
|
|
513
|
+
const { readFileSync: rfs, existsSync: exs } = await import('node:fs');
|
|
514
|
+
const settingsFile = join(cwd, '.claude', 'settings.json');
|
|
515
|
+
if (!exs(settingsFile)) {
|
|
516
|
+
console.log(' NOT INSTALLED — run: dual-brain install');
|
|
517
|
+
} else {
|
|
518
|
+
const settings = JSON.parse(rfs(settingsFile, 'utf8'));
|
|
519
|
+
const preToolUse = settings?.hooks?.PreToolUse ?? [];
|
|
520
|
+
const guardCmd = 'node .claude/hooks/head-guard.mjs';
|
|
521
|
+
const tierCmd = 'node .claude/hooks/enforce-tier.mjs';
|
|
522
|
+
const hasEdit = preToolUse.some(e => e.matcher === 'Edit' && e.hooks?.some(h => h.command === guardCmd));
|
|
523
|
+
const hasWrite = preToolUse.some(e => e.matcher === 'Write' && e.hooks?.some(h => h.command === guardCmd));
|
|
524
|
+
const hasBash = preToolUse.some(e => e.matcher === 'Bash' && e.hooks?.some(h => h.command === guardCmd));
|
|
525
|
+
const hasAgent = preToolUse.some(e => e.matcher === 'Agent' && e.hooks?.some(h => h.command === tierCmd));
|
|
526
|
+
const activeCount = [hasEdit, hasWrite, hasBash, hasAgent].filter(Boolean).length;
|
|
527
|
+
if (activeCount === 4) {
|
|
528
|
+
console.log(` active (${activeCount} guards: Edit, Write, Bash, Agent)`);
|
|
529
|
+
} else {
|
|
530
|
+
const missing = [
|
|
531
|
+
!hasEdit && 'Edit',
|
|
532
|
+
!hasWrite && 'Write',
|
|
533
|
+
!hasBash && 'Bash',
|
|
534
|
+
!hasAgent && 'Agent',
|
|
535
|
+
].filter(Boolean);
|
|
536
|
+
console.log(` PARTIAL — missing guards: ${missing.join(', ')} — run: dual-brain install`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
} catch {
|
|
540
|
+
console.log(' unknown (could not read .claude/settings.json)');
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Update check
|
|
544
|
+
try {
|
|
545
|
+
const localVer = readVersion();
|
|
546
|
+
const remoteVer = execSync('npm view dual-brain version 2>/dev/null', { timeout: 5000 }).toString().trim();
|
|
547
|
+
if (remoteVer) {
|
|
548
|
+
const localParts = localVer.split('.').map(Number);
|
|
549
|
+
const remoteParts = remoteVer.split('.').map(Number);
|
|
550
|
+
const updateAvailable =
|
|
551
|
+
remoteParts[0] > localParts[0]
|
|
552
|
+
|| (remoteParts[0] === localParts[0] && remoteParts[1] > localParts[1])
|
|
553
|
+
|| (remoteParts[0] === localParts[0] && remoteParts[1] === localParts[1] && remoteParts[2] > localParts[2]);
|
|
554
|
+
if (updateAvailable) {
|
|
555
|
+
console.log(`\nUpdate available: npm i -g dual-brain@latest (${localVer} → ${remoteVer})`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
} catch { /* network unavailable — skip */ }
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ─── cmdHot / cmdCool ─────────────────────────────────────────────────────────
|
|
562
|
+
|
|
563
|
+
const PROVIDER_MODEL_CLASSES = {
|
|
564
|
+
claude: ['haiku', 'sonnet', 'opus'],
|
|
565
|
+
openai: ['gpt-4.1-mini', 'gpt-4.1', 'gpt-5.2', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.4', 'gpt-5.5'],
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
function cmdHot(providerArg) {
|
|
569
|
+
if (!providerArg) err('Usage: dual-brain hot <provider> (claude | openai)');
|
|
570
|
+
const provider = providerArg.toLowerCase();
|
|
571
|
+
const classes = PROVIDER_MODEL_CLASSES[provider];
|
|
572
|
+
if (!classes) err(`Unknown provider: ${provider}. Use "claude" or "openai".`);
|
|
573
|
+
const cwd = process.cwd();
|
|
574
|
+
for (const mc of classes) markHot(provider, mc, cwd);
|
|
575
|
+
console.log(`Marked ${classes.length} model classes as hot for ${provider}.`);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function cmdCool(providerArg) {
|
|
579
|
+
if (!providerArg) err('Usage: dual-brain cool <provider> (claude | openai)');
|
|
580
|
+
const provider = providerArg.toLowerCase();
|
|
581
|
+
const classes = PROVIDER_MODEL_CLASSES[provider];
|
|
582
|
+
if (!classes) err(`Unknown provider: ${provider}. Use "claude" or "openai".`);
|
|
583
|
+
const cwd = process.cwd();
|
|
584
|
+
for (const mc of classes) markHealthy(provider, mc, cwd);
|
|
585
|
+
console.log(`Cleared hot state for all ${provider} model classes.`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function cmdInstall(cwd) {
|
|
589
|
+
if (!cwd) cwd = process.cwd();
|
|
590
|
+
|
|
591
|
+
// Run the main install.mjs (orchestrator config, all hooks, CLAUDE.md, etc.)
|
|
592
|
+
const { spawnSync } = await import('child_process');
|
|
593
|
+
const result = spawnSync('node', [join(__dirname, '..', 'install.mjs')], { stdio: 'inherit', cwd });
|
|
594
|
+
if (result.status !== 0) { process.exit(result.status || 1); }
|
|
595
|
+
|
|
596
|
+
// Additionally merge enforcement hooks into .claude/settings.json
|
|
597
|
+
const { installHooks } = await import('../src/install-hooks.mjs');
|
|
598
|
+
const { installed, skipped } = installHooks(cwd);
|
|
599
|
+
|
|
600
|
+
if (installed.length > 0) {
|
|
601
|
+
console.log(`\nEnforcement hooks installed (${installed.length}):`);
|
|
602
|
+
for (const item of installed) console.log(` + ${item}`);
|
|
603
|
+
}
|
|
604
|
+
if (skipped.length > 0) {
|
|
605
|
+
console.log(`Enforcement hooks already present (${skipped.length}):`);
|
|
606
|
+
for (const item of skipped) console.log(` = ${item}`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function cmdRemember(text) {
|
|
611
|
+
if (!text) err('Usage: dual-brain remember "preference text"');
|
|
612
|
+
const profile = rememberPreference(text, { scope: 'project', cwd: process.cwd() });
|
|
613
|
+
console.log(`Preference saved. Total active: ${profile.preferences.filter(p => p.enabled).length}`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function cmdForget(text) {
|
|
617
|
+
if (!text) err('Usage: dual-brain forget "preference text"');
|
|
618
|
+
forgetPreference(text, process.cwd());
|
|
619
|
+
console.log('Preference removed (if matched).');
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function cmdBreakGlass(reason) {
|
|
623
|
+
if (!reason) err('Usage: dual-brain break-glass "reason"');
|
|
624
|
+
const cwd = process.cwd();
|
|
625
|
+
const dualbrain = join(cwd, '.dualbrain');
|
|
626
|
+
const tokenPath = join(dualbrain, 'break-glass.json');
|
|
627
|
+
const auditDir = join(dualbrain, 'audit');
|
|
628
|
+
const auditFile = join(auditDir, 'head-audit.jsonl');
|
|
629
|
+
const TTL_MINUTES = 5;
|
|
630
|
+
|
|
631
|
+
mkdirSync(dualbrain, { recursive: true });
|
|
632
|
+
mkdirSync(auditDir, { recursive: true });
|
|
633
|
+
|
|
634
|
+
const token = {
|
|
635
|
+
createdAt: Date.now(),
|
|
636
|
+
ttlMinutes: TTL_MINUTES,
|
|
637
|
+
reason,
|
|
638
|
+
};
|
|
639
|
+
writeFileSync(tokenPath, JSON.stringify(token, null, 2));
|
|
640
|
+
|
|
641
|
+
// Audit entry
|
|
642
|
+
const auditEntry = {
|
|
643
|
+
ts: new Date().toISOString(),
|
|
644
|
+
event: 'break-glass-activated',
|
|
645
|
+
reason,
|
|
646
|
+
ttlMinutes: TTL_MINUTES,
|
|
647
|
+
expiresAt: new Date(token.createdAt + TTL_MINUTES * 60 * 1000).toISOString(),
|
|
648
|
+
};
|
|
649
|
+
try {
|
|
650
|
+
appendFileSync(auditFile, JSON.stringify(auditEntry) + '\n');
|
|
651
|
+
} catch { /* non-fatal */ }
|
|
652
|
+
|
|
653
|
+
const width = 51;
|
|
654
|
+
const inner = width - 2;
|
|
655
|
+
const pad = (s) => ' ' + s + ' '.repeat(inner - 1 - s.length);
|
|
656
|
+
const reasonLine = `Reason: ${reason}`;
|
|
657
|
+
const expiresLine = `Expires: ${TTL_MINUTES} minutes`;
|
|
658
|
+
const auditLine = 'All tool calls logged to audit.';
|
|
659
|
+
|
|
660
|
+
console.log('┌' + '─'.repeat(inner) + '┐');
|
|
661
|
+
console.log('│' + pad('🔓 Break-Glass Activated') + '│');
|
|
662
|
+
console.log('├' + '─'.repeat(inner) + '┤');
|
|
663
|
+
console.log('│' + pad(reasonLine) + '│');
|
|
664
|
+
console.log('│' + pad(expiresLine) + '│');
|
|
665
|
+
console.log('│' + pad(auditLine) + '│');
|
|
666
|
+
console.log('└' + '─'.repeat(inner) + '┘');
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ─── Screen helpers ───────────────────────────────────────────────────────────
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Render the data-tools-style rounded header box for the main screen.
|
|
673
|
+
* Inner width is 39 chars. Lines are padded with spaces to fill the box.
|
|
674
|
+
*/
|
|
675
|
+
function renderHeader(version, providerLines, dtVersion) {
|
|
676
|
+
const W = 39; // inner width
|
|
677
|
+
const pad = (s) => {
|
|
678
|
+
// Strip ANSI codes for length calculation
|
|
679
|
+
const visible = s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
680
|
+
return s + ' '.repeat(Math.max(0, W - visible.length));
|
|
681
|
+
};
|
|
682
|
+
const top = ` ┌${'─'.repeat(W)}┐`;
|
|
683
|
+
const sep = ` ├${'─'.repeat(W)}┤`;
|
|
684
|
+
const bottom = ` └${'─'.repeat(W)}┘`;
|
|
685
|
+
|
|
686
|
+
const title = dtVersion ? `DATA Tools v${dtVersion}` : `DATA Tools`;
|
|
687
|
+
const subTitle = `🧠 Dual Brain v${version}`;
|
|
688
|
+
const credit = `by Steve Moraco + dual-brain`;
|
|
689
|
+
|
|
690
|
+
const lines = [top];
|
|
691
|
+
lines.push(` │ ${pad(title)}│`);
|
|
692
|
+
lines.push(` │ ${pad(subTitle)}│`);
|
|
693
|
+
lines.push(` │ ${pad(credit)}│`);
|
|
694
|
+
lines.push(sep);
|
|
695
|
+
for (const pl of providerLines) {
|
|
696
|
+
lines.push(` │ ${pad(pl)}│`);
|
|
697
|
+
}
|
|
698
|
+
lines.push(bottom);
|
|
699
|
+
return lines.join('\n');
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function profileExists(cwd) {
|
|
703
|
+
const dir = cwd || process.cwd();
|
|
704
|
+
const globalPath = join(process.env.HOME || '/root', '.config', 'dual-brain', 'profile.json');
|
|
705
|
+
const projectPath = join(dir, '.dualbrain', 'profile.json');
|
|
706
|
+
return existsSync(projectPath) || existsSync(globalPath);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ─── Plan label helpers ───────────────────────────────────────────────────────
|
|
710
|
+
|
|
711
|
+
const CLAUDE_PLAN_LABELS = {
|
|
712
|
+
pro: 'Pro ($20/mo)',
|
|
713
|
+
max5: 'Max x5 ($100/mo)',
|
|
714
|
+
max20: 'Max x20 ($200/mo)',
|
|
715
|
+
'$20': 'Pro ($20/mo)',
|
|
716
|
+
'$100': 'Max x5 ($100/mo)',
|
|
717
|
+
'$200': 'Max x20 ($200/mo)',
|
|
718
|
+
};
|
|
719
|
+
const OPENAI_PLAN_LABELS = {
|
|
720
|
+
plus: 'Plus ($20/mo)',
|
|
721
|
+
pro: 'Pro ($100/mo)',
|
|
722
|
+
pro100: 'Pro ($100/mo)',
|
|
723
|
+
pro200: 'Pro ($200/mo)',
|
|
724
|
+
'$20': 'Plus ($20/mo)',
|
|
725
|
+
'$100': 'Pro ($100/mo)',
|
|
726
|
+
'$200': 'Pro ($200/mo)',
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
// ─── Screen: welcomeScreen ────────────────────────────────────────────────────
|
|
730
|
+
|
|
731
|
+
async function welcomeScreen(rl, ask) {
|
|
732
|
+
const version = readVersion();
|
|
733
|
+
const cwd = process.cwd();
|
|
734
|
+
|
|
735
|
+
// --- Detect CLI login status ---
|
|
736
|
+
process.stdout.write(`\ndual-brain v${version} — Setup\n\nDetecting your setup...\n`);
|
|
737
|
+
|
|
738
|
+
const auth = await detectAuth();
|
|
739
|
+
const plans = detectPlans();
|
|
740
|
+
|
|
741
|
+
const claudeReady = auth.claude.found;
|
|
742
|
+
const openaiReady = auth.openai.found;
|
|
743
|
+
|
|
744
|
+
// Plan labels are inferred from auth config (rate-limit tier / JWT),
|
|
745
|
+
// not reported directly by the CLI. Suffix shows configured tier, not plan name.
|
|
746
|
+
const claudePlanSuffix = claudeReady && plans.claude
|
|
747
|
+
? ` · ${plans.claude} configured`
|
|
748
|
+
: '';
|
|
749
|
+
const openaiPlanSuffix = openaiReady && plans.openai
|
|
750
|
+
? ` · ${plans.openai} configured`
|
|
751
|
+
: '';
|
|
752
|
+
|
|
753
|
+
const detectedLines = [];
|
|
754
|
+
if (claudeReady) detectedLines.push(` Claude: authenticated${claudePlanSuffix}`);
|
|
755
|
+
else detectedLines.push(` Claude: not connected`);
|
|
756
|
+
if (openaiReady) detectedLines.push(` Codex: authenticated${openaiPlanSuffix}`);
|
|
757
|
+
else detectedLines.push(` Codex: not connected`);
|
|
758
|
+
|
|
759
|
+
console.log('');
|
|
760
|
+
console.log('Detected:');
|
|
761
|
+
for (const line of detectedLines) {
|
|
762
|
+
const ok = !line.includes('not logged');
|
|
763
|
+
console.log(` ${ok ? '' : ''}${line.trim()}`);
|
|
764
|
+
}
|
|
765
|
+
console.log('');
|
|
766
|
+
|
|
767
|
+
// --- Detect data-tools / replit-tools sessions ---
|
|
768
|
+
const env = detectEnvironment();
|
|
769
|
+
const existingSessions = importReplitSessions(cwd);
|
|
770
|
+
if (env.hasReplitTools) {
|
|
771
|
+
detectedLines.push(` data-tools detected`);
|
|
772
|
+
}
|
|
773
|
+
if (existingSessions.length > 0) {
|
|
774
|
+
detectedLines.push(` ${existingSessions.length} session${existingSessions.length !== 1 ? 's' : ''} found from data-tools`);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// --- Detect replit-tools ---
|
|
778
|
+
const rt = detectReplitTools(cwd);
|
|
779
|
+
if (rt.installed) {
|
|
780
|
+
detectedLines.push(` replit-tools v${rt.version || '?'} detected`);
|
|
781
|
+
if (rt.hasArchive) {
|
|
782
|
+
try {
|
|
783
|
+
const archiveDir = join(rt.dir, '.session-archive', 'claude', 'projects', '-home-runner-workspace');
|
|
784
|
+
if (existsSync(archiveDir)) {
|
|
785
|
+
const count = readdirSync(archiveDir).filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-')).length;
|
|
786
|
+
if (count > 0) detectedLines.push(` ${count} archived sessions available`);
|
|
787
|
+
}
|
|
788
|
+
} catch {}
|
|
789
|
+
}
|
|
790
|
+
} else {
|
|
791
|
+
detectedLines.push(` replit-tools not found — install with: npx replit-tools`);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Show detection results in a box
|
|
795
|
+
const detectedFormatted = detectedLines.map(line => {
|
|
796
|
+
const ok = !line.includes('not logged') && !line.includes('not found');
|
|
797
|
+
return `${ok ? '✅' : '⚠️ '} ${line.trim()}`;
|
|
798
|
+
});
|
|
799
|
+
console.log('');
|
|
800
|
+
console.log(box(`🧠 Dual-Brain v${version} — Setup`, detectedFormatted));
|
|
801
|
+
console.log('');
|
|
802
|
+
|
|
803
|
+
if (!claudeReady && !openaiReady) {
|
|
804
|
+
console.log('No CLI login found. Log in first:');
|
|
805
|
+
console.log(' claude auth login — for Claude');
|
|
806
|
+
console.log(' codex login — for OpenAI/Codex\n');
|
|
807
|
+
console.log('Then re-run: dual-brain init');
|
|
808
|
+
return { next: 'exit' };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
console.log(' [Enter] Save and go');
|
|
812
|
+
console.log(' [c] Customize plan tier');
|
|
813
|
+
if (existingSessions.length > 0) {
|
|
814
|
+
console.log(` [i] Import ${existingSessions.length} session${existingSessions.length !== 1 ? 's' : ''} from data-tools`);
|
|
815
|
+
}
|
|
816
|
+
if (!rt.installed) {
|
|
817
|
+
console.log('');
|
|
818
|
+
console.log(' 💡 Tip: Install replit-tools for session persistence:');
|
|
819
|
+
console.log(' npx replit-tools');
|
|
820
|
+
}
|
|
821
|
+
console.log('');
|
|
822
|
+
|
|
823
|
+
const choice = (await ask(' Choice: ')).trim().toLowerCase();
|
|
824
|
+
|
|
825
|
+
if (choice === 'i' && existingSessions.length > 0) {
|
|
826
|
+
console.log(`\n Importing ${existingSessions.length} sessions from data-tools...\n`);
|
|
827
|
+
const recent = existingSessions.slice(0, 5);
|
|
828
|
+
for (const sess of recent) {
|
|
829
|
+
console.log(` ${sess.age.padEnd(6)} ${sess.name}`);
|
|
830
|
+
}
|
|
831
|
+
if (existingSessions.length > 5) {
|
|
832
|
+
console.log(` ... and ${existingSessions.length - 5} more`);
|
|
833
|
+
}
|
|
834
|
+
console.log('\n Sessions imported! They\'ll appear in your Recent list.\n');
|
|
835
|
+
await ask(' Press Enter to continue...');
|
|
836
|
+
// Fall through to auto-save
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (choice !== 'c') {
|
|
840
|
+
// Auto-save detected plans and proceed
|
|
841
|
+
const setup = await autoSetup(cwd);
|
|
842
|
+
if (setup.confident && setup.profile) {
|
|
843
|
+
saveProfile(setup.profile, { cwd });
|
|
844
|
+
} else {
|
|
845
|
+
// Build profile from what we know
|
|
846
|
+
const existing = loadProfile(cwd);
|
|
847
|
+
if (claudeReady) {
|
|
848
|
+
existing.providers.claude = { enabled: true, plan: plans.claude || 'pro' };
|
|
849
|
+
}
|
|
850
|
+
if (openaiReady) {
|
|
851
|
+
existing.providers.openai = { enabled: true, plan: plans.openai || 'plus' };
|
|
852
|
+
}
|
|
853
|
+
const enabledCount = [claudeReady, openaiReady].filter(Boolean).length;
|
|
854
|
+
existing.mode = enabledCount >= 2 ? 'dual' : claudeReady ? 'solo-claude' : 'solo-openai';
|
|
855
|
+
saveProfile(existing, { cwd });
|
|
856
|
+
}
|
|
857
|
+
try {
|
|
858
|
+
const { ensurePersistence } = await import('../src/session.mjs');
|
|
859
|
+
const persisted = ensurePersistence(cwd);
|
|
860
|
+
if (persisted.length > 0) {
|
|
861
|
+
persisted.forEach(msg => console.log(` ✅ ${msg}`));
|
|
862
|
+
}
|
|
863
|
+
} catch {}
|
|
864
|
+
await cmdInstall(cwd);
|
|
865
|
+
return { next: 'main' };
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// ── [c] Customize: plan picker ───────────────────────────────────────────
|
|
869
|
+
|
|
870
|
+
const existingProfile = loadProfile(cwd);
|
|
871
|
+
|
|
872
|
+
// Claude plan picker
|
|
873
|
+
if (claudeReady) {
|
|
874
|
+
console.log('');
|
|
875
|
+
console.log(separator('Claude subscription'));
|
|
876
|
+
console.log(' (1) Pro ($20/mo)');
|
|
877
|
+
console.log(' (2) Max x5 ($100/mo)');
|
|
878
|
+
console.log(' (3) Max x20 ($200/mo)');
|
|
879
|
+
console.log(' (4) Skip');
|
|
880
|
+
const claudeChoice = (await ask('> ')).trim();
|
|
881
|
+
const claudePlanMap = { '1': 'pro', '2': 'max5', '3': 'max20' };
|
|
882
|
+
if (claudeChoice !== '4') {
|
|
883
|
+
existingProfile.providers.claude = {
|
|
884
|
+
enabled: true,
|
|
885
|
+
plan: claudePlanMap[claudeChoice] || plans.claude || 'pro',
|
|
886
|
+
};
|
|
887
|
+
} else {
|
|
888
|
+
existingProfile.providers.claude = { enabled: false, plan: plans.claude || 'pro' };
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// OpenAI plan picker
|
|
893
|
+
if (openaiReady) {
|
|
894
|
+
console.log('');
|
|
895
|
+
console.log(separator('OpenAI subscription'));
|
|
896
|
+
console.log(' (1) Plus ($20/mo)');
|
|
897
|
+
console.log(' (2) Pro ($100/mo)');
|
|
898
|
+
console.log(' (3) Pro ($200/mo higher limits)');
|
|
899
|
+
console.log(' (4) Skip');
|
|
900
|
+
const openaiChoice = (await ask('> ')).trim();
|
|
901
|
+
const openaiPlanMap = { '1': 'plus', '2': 'pro', '3': 'pro200' };
|
|
902
|
+
if (openaiChoice !== '4') {
|
|
903
|
+
existingProfile.providers.openai = {
|
|
904
|
+
enabled: true,
|
|
905
|
+
plan: openaiPlanMap[openaiChoice] || plans.openai || 'plus',
|
|
906
|
+
};
|
|
907
|
+
} else {
|
|
908
|
+
existingProfile.providers.openai = { enabled: false, plan: plans.openai || 'plus' };
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Mode picker
|
|
913
|
+
console.log('');
|
|
914
|
+
console.log(separator('Optimization'));
|
|
915
|
+
console.log(' (1) Save usage — prefer cheaper models');
|
|
916
|
+
console.log(' (2) Balanced — best model per tier (recommended)');
|
|
917
|
+
console.log(' (3) Quality first — always use best available');
|
|
918
|
+
const modeChoice = (await ask('> ')).trim();
|
|
919
|
+
existingProfile.mode = ({ '1': 'cost-saver', '3': 'quality-first' })[modeChoice] || 'balanced';
|
|
920
|
+
|
|
921
|
+
// Team setup
|
|
922
|
+
console.log('');
|
|
923
|
+
console.log(' Team auth: label subscriptions and set expiry for auto-refresh.');
|
|
924
|
+
console.log(' When a subscription expires, dual-brain will prompt re-login automatically.');
|
|
925
|
+
console.log('');
|
|
926
|
+
console.log(' [Enter] Skip [t] Set up team auth');
|
|
927
|
+
const teamChoice = (await ask(' Choice: ')).trim().toLowerCase();
|
|
928
|
+
if (teamChoice === 't') {
|
|
929
|
+
for (const provider of ['claude', 'openai']) {
|
|
930
|
+
if (!existingProfile.providers[provider]?.enabled) continue;
|
|
931
|
+
const provLabel = provider === 'claude' ? 'Claude' : 'OpenAI';
|
|
932
|
+
const label = (await ask(` ${provLabel} label (e.g. "Josh's $100 sub"): `)).trim();
|
|
933
|
+
if (label) existingProfile.providers[provider].label = label;
|
|
934
|
+
const expiry = await askExpiry(ask, provLabel);
|
|
935
|
+
if (expiry) existingProfile.providers[provider].expiresAt = expiry;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const enabledCount = Object.values(existingProfile.providers).filter(p => p.enabled).length;
|
|
940
|
+
existingProfile.mode = enabledCount >= 2 ? existingProfile.mode || 'auto' : claudeReady ? 'solo-claude' : 'solo-openai';
|
|
941
|
+
|
|
942
|
+
saveProfile(existingProfile, { cwd });
|
|
943
|
+
|
|
944
|
+
// Summary
|
|
945
|
+
const summaryLines = [];
|
|
946
|
+
for (const [key, prov] of Object.entries(existingProfile.providers)) {
|
|
947
|
+
const planLabel = key === 'claude'
|
|
948
|
+
? (CLAUDE_PLAN_LABELS[prov.plan] ?? prov.plan)
|
|
949
|
+
: (OPENAI_PLAN_LABELS[prov.plan] ?? prov.plan);
|
|
950
|
+
summaryLines.push(`${key === 'claude' ? 'Claude' : 'OpenAI'}: ${prov.enabled ? planLabel : 'disabled'}${prov.label ? ` [${prov.label}]` : ''}`);
|
|
951
|
+
}
|
|
952
|
+
summaryLines.push(`Mode: ${existingProfile.mode}`);
|
|
953
|
+
|
|
954
|
+
console.log('');
|
|
955
|
+
console.log(box('Setup Complete', summaryLines));
|
|
956
|
+
console.log('');
|
|
957
|
+
|
|
958
|
+
await cmdInstall(cwd);
|
|
959
|
+
return { next: 'main' };
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// ─── Running-instance + terminal helpers ─────────────────────────────────────
|
|
963
|
+
|
|
964
|
+
function countRunningInstances() {
|
|
965
|
+
try {
|
|
966
|
+
const claude = parseInt(execSync('pgrep -x claude 2>/dev/null | wc -l', { encoding: 'utf8' }).trim(), 10) || 0;
|
|
967
|
+
const codex = parseInt(execSync('pgrep -x codex 2>/dev/null | wc -l', { encoding: 'utf8' }).trim(), 10) || 0;
|
|
968
|
+
return { claude, codex };
|
|
969
|
+
} catch { return { claude: 0, codex: 0 }; }
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function getTerminalId() {
|
|
973
|
+
try {
|
|
974
|
+
const tty = execSync('tty 2>/dev/null', { encoding: 'utf8' }).trim();
|
|
975
|
+
if (tty && tty !== 'not a tty') {
|
|
976
|
+
return tty.replace('/dev/', '').replace(/\//g, '-');
|
|
977
|
+
}
|
|
978
|
+
} catch {}
|
|
979
|
+
return `shell-${process.pid}`;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function saveTerminalState(cwd, terminalId, sessionId, tool) {
|
|
983
|
+
const dir = join(cwd, '.dualbrain');
|
|
984
|
+
try {
|
|
985
|
+
mkdirSync(dir, { recursive: true });
|
|
986
|
+
writeFileSync(join(dir, `terminal-${terminalId}.json`), JSON.stringify({
|
|
987
|
+
sessionId, tool, terminalId, timestamp: Math.floor(Date.now() / 1000),
|
|
988
|
+
}));
|
|
989
|
+
} catch {}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function loadTerminalState(cwd, terminalId) {
|
|
993
|
+
try {
|
|
994
|
+
return JSON.parse(readFileSync(join(cwd, '.dualbrain', `terminal-${terminalId}.json`), 'utf8'));
|
|
995
|
+
} catch { return null; }
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// ─── Screen: mainScreen ───────────────────────────────────────────────────────
|
|
999
|
+
|
|
1000
|
+
async function mainScreen(rl, ask) {
|
|
1001
|
+
const cwd = process.cwd();
|
|
1002
|
+
const version = readVersion();
|
|
1003
|
+
const profile = loadProfile(cwd);
|
|
1004
|
+
const auth = await detectAuth();
|
|
1005
|
+
|
|
1006
|
+
const claudeSub = profile?.providers?.claude;
|
|
1007
|
+
const openaiSub = profile?.providers?.openai;
|
|
1008
|
+
const claudePlan = claudeSub?.plan ?? 'Pro';
|
|
1009
|
+
const openaiPlan = openaiSub?.plan ?? 'Plus';
|
|
1010
|
+
|
|
1011
|
+
// Check subscription expiry
|
|
1012
|
+
const now = Date.now();
|
|
1013
|
+
const claudeExpired = claudeSub?.expiresAt && Date.parse(claudeSub.expiresAt) < now;
|
|
1014
|
+
const openaiExpired = openaiSub?.expiresAt && Date.parse(openaiSub.expiresAt) < now;
|
|
1015
|
+
|
|
1016
|
+
const claudeDays = daysUntil(claudeSub?.expiresAt);
|
|
1017
|
+
const openaiDays = daysUntil(openaiSub?.expiresAt);
|
|
1018
|
+
|
|
1019
|
+
function subLine(name, plan, found, expired, days, sub) {
|
|
1020
|
+
const label = sub?.label ? ` [${sub.label}]` : '';
|
|
1021
|
+
if (!found) return `⚠️ ${name}: not logged in — run: ${name === 'Claude' ? 'claude auth login' : 'codex login'}`;
|
|
1022
|
+
// Multi-sub: show aggregated counts when more than one sub exists
|
|
1023
|
+
const subs = sub?.subs;
|
|
1024
|
+
if (subs && subs.length > 1) {
|
|
1025
|
+
const aggregate = aggregatePlans(subs);
|
|
1026
|
+
return `✅ ${name}: ${aggregate} [${subs.length} subs]`;
|
|
1027
|
+
}
|
|
1028
|
+
if (expired) return `🔴 ${name}: ${plan} expired${label} — will re-auth`;
|
|
1029
|
+
const daysNote = (days !== null && days <= 7) ? ` (${days}d left)` : '';
|
|
1030
|
+
return `✅ ${name}: ${plan}${label}${daysNote}`;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const headerLines = [
|
|
1034
|
+
subLine('Claude', claudePlan, auth.claude.found, claudeExpired, claudeDays, claudeSub),
|
|
1035
|
+
subLine('OpenAI', openaiPlan, auth.openai.found, openaiExpired, openaiDays, openaiSub),
|
|
1036
|
+
];
|
|
1037
|
+
|
|
1038
|
+
const rtMain = detectReplitTools(cwd);
|
|
1039
|
+
const dtVersion = (rtMain.installed && rtMain.version) ? rtMain.version : null;
|
|
1040
|
+
if (dtVersion) {
|
|
1041
|
+
console.log(`📦 DATA Tools v${dtVersion}`);
|
|
1042
|
+
console.log(`🧠 Dual Brain v${version}`);
|
|
1043
|
+
} else {
|
|
1044
|
+
console.log(`📦 DATA Tools`);
|
|
1045
|
+
console.log(`🧠 Dual Brain v${version}`);
|
|
1046
|
+
}
|
|
1047
|
+
const latestVersion = await checkForUpdates(version);
|
|
1048
|
+
if (latestVersion) {
|
|
1049
|
+
console.log(` ⬆️ Update available: v${version} → v${latestVersion}`);
|
|
1050
|
+
console.log(` Run: npx -y dual-brain@latest`);
|
|
1051
|
+
}
|
|
1052
|
+
console.log('');
|
|
1053
|
+
|
|
1054
|
+
// Provider status (outside the box)
|
|
1055
|
+
for (const line of headerLines) {
|
|
1056
|
+
console.log(` ${line}`);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const sparkline = buildSparkline(cwd);
|
|
1060
|
+
if (sparkline) {
|
|
1061
|
+
console.log(` Activity: ${sparkline}`);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Silent OAuth token auto-refresh (like data-tools)
|
|
1065
|
+
try {
|
|
1066
|
+
const { autoRefreshToken } = await import('../src/profile.mjs');
|
|
1067
|
+
const refreshResult = await autoRefreshToken(cwd);
|
|
1068
|
+
if (refreshResult.status === 'refreshed') {
|
|
1069
|
+
console.log(` 🔄 Token auto-refreshed (${refreshResult.hoursRemaining}h remaining)`);
|
|
1070
|
+
}
|
|
1071
|
+
} catch {}
|
|
1072
|
+
|
|
1073
|
+
// Append-only session archive sync (like data-tools)
|
|
1074
|
+
try {
|
|
1075
|
+
const { syncSessionMirror } = await import('../src/session.mjs');
|
|
1076
|
+
const mirror = syncSessionMirror(cwd);
|
|
1077
|
+
if (mirror.copied > 0 || mirror.grew > 0) {
|
|
1078
|
+
console.log(` ✅ Archive mirror: +${mirror.copied} new, ${mirror.grew} updated`);
|
|
1079
|
+
}
|
|
1080
|
+
} catch {}
|
|
1081
|
+
|
|
1082
|
+
// Auto-refresh expired subscriptions
|
|
1083
|
+
if (claudeExpired || openaiExpired) {
|
|
1084
|
+
const { spawnSync } = await import('node:child_process');
|
|
1085
|
+
const expired = [];
|
|
1086
|
+
if (claudeExpired) expired.push('Claude');
|
|
1087
|
+
if (openaiExpired) expired.push('OpenAI');
|
|
1088
|
+
console.log(`\n ${expired.join(' & ')} subscription expired. Re-authenticating...`);
|
|
1089
|
+
if (claudeExpired) {
|
|
1090
|
+
const r = spawnSync('claude', ['auth', 'login'], { stdio: 'inherit', timeout: 30000 });
|
|
1091
|
+
if (r.status === 0) {
|
|
1092
|
+
claudeSub.expiresAt = null;
|
|
1093
|
+
saveProfile(profile, { cwd });
|
|
1094
|
+
console.log(' ✓ Claude re-authenticated');
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
if (openaiExpired) {
|
|
1098
|
+
const r = spawnSync('codex', ['login'], { stdio: 'inherit', timeout: 30000 });
|
|
1099
|
+
if (r.status === 0) {
|
|
1100
|
+
openaiSub.expiresAt = null;
|
|
1101
|
+
saveProfile(profile, { cwd });
|
|
1102
|
+
console.log(' ✓ OpenAI re-authenticated');
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
console.log('');
|
|
1107
|
+
|
|
1108
|
+
// Build session index in background (powers search + smart resume)
|
|
1109
|
+
try {
|
|
1110
|
+
const { buildSessionIndex } = await import('../src/session.mjs');
|
|
1111
|
+
buildSessionIndex(cwd);
|
|
1112
|
+
} catch {}
|
|
1113
|
+
|
|
1114
|
+
const recentSessions = enrichSessions(importReplitSessions(cwd), cwd).slice(0, 7);
|
|
1115
|
+
|
|
1116
|
+
if (recentSessions.length > 0) {
|
|
1117
|
+
console.log(' Recent Sessions:');
|
|
1118
|
+
recentSessions.forEach((sess, i) => {
|
|
1119
|
+
const pin = sess.pinned ? '📌 ' : ' ';
|
|
1120
|
+
const active = sess.isActive ? ' ●' : '';
|
|
1121
|
+
const cat = sess.category ? ` [${sess.category}]` : '';
|
|
1122
|
+
const tool = (sess.tool === 'codex') ? 'cdx' : 'cld';
|
|
1123
|
+
// If the name is still the "Session XXXXXXXX" fallback, try the project path instead
|
|
1124
|
+
let rawName = sess.name || '';
|
|
1125
|
+
if (/^Session [0-9a-f]{8,}$/i.test(rawName)) {
|
|
1126
|
+
rawName = sess.project ? sess.project.replace(/^-/, '/').replace(/-/g, '/') : sess.id.slice(0, 8);
|
|
1127
|
+
}
|
|
1128
|
+
const displayName = rawName.length > 40 ? rawName.slice(0, 37) + '...' : (rawName || sess.id.slice(0, 8));
|
|
1129
|
+
console.log(` [${i + 1}] ${pin}${tool} ${sess.age.padEnd(8)} ${displayName}${active}${cat}`);
|
|
1130
|
+
});
|
|
1131
|
+
console.log('');
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const brandW = 37;
|
|
1135
|
+
const brandTop = ` ┌${'─'.repeat(brandW)}┐`;
|
|
1136
|
+
const brandBottom = ` └${'─'.repeat(brandW)}┘`;
|
|
1137
|
+
const brandPad = (s) => {
|
|
1138
|
+
const leftPad = Math.floor((brandW - s.length) / 2);
|
|
1139
|
+
const rightPad = brandW - s.length - leftPad;
|
|
1140
|
+
return ' '.repeat(leftPad) + s + ' '.repeat(rightPad);
|
|
1141
|
+
};
|
|
1142
|
+
console.log(brandTop);
|
|
1143
|
+
console.log(` │ ${brandPad('Dual Brain Session Manager')}│`);
|
|
1144
|
+
console.log(` │ ${brandPad('Built on data-tools by Steve Moraco')}│`);
|
|
1145
|
+
console.log(brandBottom);
|
|
1146
|
+
console.log('');
|
|
1147
|
+
|
|
1148
|
+
const running = countRunningInstances();
|
|
1149
|
+
const runningParts = [];
|
|
1150
|
+
if (running.claude > 0) runningParts.push(`${running.claude} claude`);
|
|
1151
|
+
if (running.codex > 0) runningParts.push(`${running.codex} codex`);
|
|
1152
|
+
if (runningParts.length > 0) {
|
|
1153
|
+
console.log(` (${runningParts.join(', ')} running)`);
|
|
1154
|
+
console.log('');
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
console.log(' [c] Continue last session');
|
|
1158
|
+
console.log(' [n] New session');
|
|
1159
|
+
console.log('');
|
|
1160
|
+
if (recentSessions.length > 0) {
|
|
1161
|
+
console.log(' [1-9] Resume numbered above');
|
|
1162
|
+
}
|
|
1163
|
+
console.log(' [r] Resume (full list)');
|
|
1164
|
+
console.log(' [/] Search sessions');
|
|
1165
|
+
console.log(' [e] Manage sessions');
|
|
1166
|
+
console.log(' [m] Manage subscriptions');
|
|
1167
|
+
console.log(' [s] Settings');
|
|
1168
|
+
console.log(' [?] Help & shortcuts');
|
|
1169
|
+
console.log('');
|
|
1170
|
+
console.log(' \x1b[2mreplit-tools:\x1b[0m');
|
|
1171
|
+
console.log(' [i] Import sessions');
|
|
1172
|
+
console.log(' [d] Switch to data-tools');
|
|
1173
|
+
console.log('');
|
|
1174
|
+
console.log(' [q] Exit');
|
|
1175
|
+
console.log('');
|
|
1176
|
+
|
|
1177
|
+
const choice = (await ask(' Choice: ')).trim().toLowerCase();
|
|
1178
|
+
|
|
1179
|
+
if (choice === '?') {
|
|
1180
|
+
const W = 37;
|
|
1181
|
+
const helpTop = ` ┌${'─'.repeat(W)}┐`;
|
|
1182
|
+
const helpSep = ` ├${'─'.repeat(W)}┤`;
|
|
1183
|
+
const helpBottom = ` └${'─'.repeat(W)}┘`;
|
|
1184
|
+
const helpPad = (s) => s + ' '.repeat(Math.max(0, W - s.length));
|
|
1185
|
+
console.log('');
|
|
1186
|
+
console.log(helpTop);
|
|
1187
|
+
console.log(` │ ${helpPad('At ~/workspace$ prompt:')}│`);
|
|
1188
|
+
console.log(` │ ${helpPad('db = show this menu')}│`);
|
|
1189
|
+
console.log(` │ ${helpPad('j = login to claude')}│`);
|
|
1190
|
+
console.log(` │ ${helpPad('k = login to codex')}│`);
|
|
1191
|
+
console.log(helpSep);
|
|
1192
|
+
console.log(` │ ${helpPad('In Claude:')}│`);
|
|
1193
|
+
console.log(` │ ${helpPad('Ctrl+C x2 = back to menu')}│`);
|
|
1194
|
+
console.log(` │ ${helpPad('Ctrl+C x3 = exit to shell')}│`);
|
|
1195
|
+
console.log(helpBottom);
|
|
1196
|
+
console.log('');
|
|
1197
|
+
await ask(' Press Enter to continue...');
|
|
1198
|
+
return { next: 'main' };
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
if (choice === 'n') { return { next: 'new-session' }; }
|
|
1202
|
+
|
|
1203
|
+
if (choice === 'c') {
|
|
1204
|
+
const termId = getTerminalId();
|
|
1205
|
+
const termState = loadTerminalState(cwd, termId);
|
|
1206
|
+
const sessions = importReplitSessions(cwd);
|
|
1207
|
+
|
|
1208
|
+
// Priority: terminal-specific last session, then global last session
|
|
1209
|
+
const targetId = termState?.sessionId || (sessions.length > 0 ? sessions[0].id : null);
|
|
1210
|
+
|
|
1211
|
+
if (!targetId) {
|
|
1212
|
+
console.log('\n No recent sessions found.\n');
|
|
1213
|
+
await ask(' Press Enter to continue...');
|
|
1214
|
+
return { next: 'main' };
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// Smart resume preview
|
|
1218
|
+
try {
|
|
1219
|
+
const { getSessionContext } = await import('../src/session.mjs');
|
|
1220
|
+
const ctx = getSessionContext(targetId, cwd);
|
|
1221
|
+
if (ctx) {
|
|
1222
|
+
console.log('');
|
|
1223
|
+
if (ctx.lastPrompt) console.log(` Last working on: ${ctx.lastPrompt}`);
|
|
1224
|
+
if (ctx.filesTouched.length > 0) console.log(` Files touched: ${ctx.filesTouched.join(', ')}`);
|
|
1225
|
+
}
|
|
1226
|
+
} catch {}
|
|
1227
|
+
|
|
1228
|
+
const { spawnSync } = await import('node:child_process');
|
|
1229
|
+
const tool = termState?.tool || 'claude';
|
|
1230
|
+
console.log(`\n Resuming: ${tool} --resume ${targetId}\n`);
|
|
1231
|
+
spawnSync(tool === 'codex' ? 'codex' : 'claude', ['--resume', targetId], { stdio: 'inherit' });
|
|
1232
|
+
saveTerminalState(cwd, termId, targetId, tool);
|
|
1233
|
+
return { next: 'main' };
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
const numChoice = parseInt(choice, 10);
|
|
1237
|
+
if (!isNaN(numChoice) && numChoice >= 1 && numChoice <= recentSessions.length) {
|
|
1238
|
+
const sess = recentSessions[numChoice - 1];
|
|
1239
|
+
|
|
1240
|
+
// Smart resume preview
|
|
1241
|
+
try {
|
|
1242
|
+
const { getSessionContext } = await import('../src/session.mjs');
|
|
1243
|
+
const ctx = getSessionContext(sess.id, cwd);
|
|
1244
|
+
if (ctx) {
|
|
1245
|
+
console.log('');
|
|
1246
|
+
if (ctx.lastPrompt) console.log(` Last working on: ${ctx.lastPrompt}`);
|
|
1247
|
+
if (ctx.filesTouched.length > 0) console.log(` Files touched: ${ctx.filesTouched.join(', ')}`);
|
|
1248
|
+
}
|
|
1249
|
+
} catch {}
|
|
1250
|
+
|
|
1251
|
+
const { spawnSync } = await import('node:child_process');
|
|
1252
|
+
console.log(`\n Launching: claude --resume ${sess.id}\n`);
|
|
1253
|
+
spawnSync('claude', ['--resume', sess.id], { stdio: 'inherit' });
|
|
1254
|
+
saveTerminalState(cwd, getTerminalId(), sess.id, sess.tool || 'claude');
|
|
1255
|
+
return { next: 'main' };
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
if (choice === 'r') {
|
|
1259
|
+
const allSessions = enrichSessions(importReplitSessions(cwd), cwd);
|
|
1260
|
+
if (allSessions.length === 0) {
|
|
1261
|
+
console.log('\n No sessions found.\n');
|
|
1262
|
+
await ask(' Press Enter to continue...');
|
|
1263
|
+
return { next: 'main' };
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
console.log('\n All Sessions:');
|
|
1267
|
+
allSessions.forEach((sess, i) => {
|
|
1268
|
+
const pin = sess.pinned ? '📌 ' : ' ';
|
|
1269
|
+
const active = sess.isActive ? ' ●' : '';
|
|
1270
|
+
const cat = sess.category ? ` [${sess.category}]` : '';
|
|
1271
|
+
const tool = (sess.tool === 'codex') ? 'cdx' : 'cld';
|
|
1272
|
+
console.log(` [${String(i + 1).padStart(2)}] ${pin}${tool} ${sess.age.padEnd(8)} ${sess.name}${active}${cat}`);
|
|
1273
|
+
});
|
|
1274
|
+
console.log('');
|
|
1275
|
+
|
|
1276
|
+
const pick = (await ask(' Enter number to resume (or Enter to cancel): ')).trim();
|
|
1277
|
+
const num = parseInt(pick, 10);
|
|
1278
|
+
if (!isNaN(num) && num >= 1 && num <= allSessions.length) {
|
|
1279
|
+
const sess = allSessions[num - 1];
|
|
1280
|
+
const { spawnSync } = await import('node:child_process');
|
|
1281
|
+
const tool = sess.tool === 'codex' ? 'codex' : 'claude';
|
|
1282
|
+
console.log(`\n Launching: ${tool} --resume ${sess.id}\n`);
|
|
1283
|
+
spawnSync(tool, ['--resume', sess.id], { stdio: 'inherit' });
|
|
1284
|
+
}
|
|
1285
|
+
return { next: 'main' };
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
if (choice === '/') {
|
|
1289
|
+
const query = (await ask(' Search: ')).trim();
|
|
1290
|
+
if (!query) return { next: 'main' };
|
|
1291
|
+
|
|
1292
|
+
const { searchSessions, buildSessionIndex } = await import('../src/session.mjs');
|
|
1293
|
+
// Build index if needed (silent)
|
|
1294
|
+
try { buildSessionIndex(cwd); } catch {}
|
|
1295
|
+
|
|
1296
|
+
const results = searchSessions(query, cwd);
|
|
1297
|
+
if (results.length === 0) {
|
|
1298
|
+
console.log(`\n No sessions matching "${query}"\n`);
|
|
1299
|
+
await ask(' Press Enter to continue...');
|
|
1300
|
+
return { next: 'main' };
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
console.log(`\n Found ${results.length} session${results.length === 1 ? '' : 's'}:`);
|
|
1304
|
+
results.slice(0, 9).forEach((sess, i) => {
|
|
1305
|
+
const tool = sess.tool === 'codex' ? 'cdx' : 'cld';
|
|
1306
|
+
const date = sess.date ? new Date(sess.date).toLocaleDateString() : '?';
|
|
1307
|
+
const topics = sess.topics.slice(0, 3).join(', ');
|
|
1308
|
+
console.log(` [${i + 1}] ${tool} ${date} ${sess.prompts.first || sess.id.slice(0, 8)}`);
|
|
1309
|
+
if (topics) console.log(` topics: ${topics}`);
|
|
1310
|
+
});
|
|
1311
|
+
console.log('');
|
|
1312
|
+
|
|
1313
|
+
const pick = (await ask(' Enter number to resume (or Enter to cancel): ')).trim();
|
|
1314
|
+
const num = parseInt(pick, 10);
|
|
1315
|
+
if (!isNaN(num) && num >= 1 && num <= Math.min(results.length, 9)) {
|
|
1316
|
+
const sess = results[num - 1];
|
|
1317
|
+
const { spawnSync } = await import('node:child_process');
|
|
1318
|
+
const tool = sess.tool === 'codex' ? 'codex' : 'claude';
|
|
1319
|
+
console.log(`\n Launching: ${tool} --resume ${sess.id}\n`);
|
|
1320
|
+
spawnSync(tool, ['--resume', sess.id], { stdio: 'inherit' });
|
|
1321
|
+
}
|
|
1322
|
+
return { next: 'main' };
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
if (choice === 'e') { return { next: 'sessions' }; }
|
|
1326
|
+
|
|
1327
|
+
if (choice === 'i') {
|
|
1328
|
+
const sessions = importReplitSessions(cwd);
|
|
1329
|
+
if (sessions.length === 0) {
|
|
1330
|
+
console.log('\n No replit-tools sessions found to import.\n');
|
|
1331
|
+
} else {
|
|
1332
|
+
console.log(`\n ✅ Found ${sessions.length} sessions from replit-tools.`);
|
|
1333
|
+
console.log(' Sessions are automatically available in the list above.\n');
|
|
1334
|
+
}
|
|
1335
|
+
await ask(' Press Enter to continue...');
|
|
1336
|
+
return { next: 'main' };
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
if (choice === 'd') {
|
|
1340
|
+
const { spawnSync } = await import('node:child_process');
|
|
1341
|
+
const which = spawnSync('which', ['claude-menu'], { encoding: 'utf8' });
|
|
1342
|
+
if (which.status === 0) {
|
|
1343
|
+
spawnSync('claude-menu', { stdio: 'inherit' });
|
|
1344
|
+
} else {
|
|
1345
|
+
console.log('\n data-tools not found — install with: npm i -g replit-tools\n');
|
|
1346
|
+
await ask(' Press Enter to continue...');
|
|
1347
|
+
}
|
|
1348
|
+
return { next: 'main' };
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
if (choice === 'm') { return { next: 'subscriptions' }; }
|
|
1352
|
+
|
|
1353
|
+
if (choice === 's') { return { next: 'settings' }; }
|
|
1354
|
+
if (choice === 'q' || choice === 'exit') { return { next: 'exit' }; }
|
|
1355
|
+
|
|
1356
|
+
return { next: 'main' };
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// ─── Screen: newSessionScreen ─────────────────────────────────────────────────
|
|
1360
|
+
|
|
1361
|
+
async function newSessionScreen(rl, ask) {
|
|
1362
|
+
const cwd = process.cwd();
|
|
1363
|
+
const input = (await ask('\n What do you want to do? ')).trim();
|
|
1364
|
+
if (!input) { return { next: 'main' }; }
|
|
1365
|
+
|
|
1366
|
+
const profile = loadProfile(cwd);
|
|
1367
|
+
const detection = detectTask({ prompt: input });
|
|
1368
|
+
const decision = decideRoute({ profile, detection, cwd });
|
|
1369
|
+
|
|
1370
|
+
console.log(`\n Routing: ${decision.provider}/${decision.model} (${decision.tier})`);
|
|
1371
|
+
console.log(` Reason: ${decision.explanation}\n`);
|
|
1372
|
+
|
|
1373
|
+
const { spawnSync } = await import('node:child_process');
|
|
1374
|
+
const launchTool = decision.provider === 'openai' ? 'codex' : 'claude';
|
|
1375
|
+
if (launchTool === 'codex') {
|
|
1376
|
+
spawnSync('codex', [input], { stdio: 'inherit' });
|
|
1377
|
+
} else {
|
|
1378
|
+
spawnSync('claude', ['-p', input], { stdio: 'inherit' });
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// After session ends, capture the most-recent session ID so [c] can resume it
|
|
1382
|
+
const freshSessions = importReplitSessions(cwd);
|
|
1383
|
+
if (freshSessions.length > 0) {
|
|
1384
|
+
saveTerminalState(cwd, getTerminalId(), freshSessions[0].id, launchTool);
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
return { next: 'main' };
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// ─── Screen: settingsScreen ───────────────────────────────────────────────────
|
|
1391
|
+
|
|
1392
|
+
async function settingsScreen(rl, ask) {
|
|
1393
|
+
const cwd = process.cwd();
|
|
1394
|
+
const profile = loadProfile(cwd);
|
|
1395
|
+
const auth = await detectAuth();
|
|
1396
|
+
|
|
1397
|
+
let guardCount = 0;
|
|
1398
|
+
try {
|
|
1399
|
+
const settingsFile = join(cwd, '.claude', 'settings.json');
|
|
1400
|
+
if (existsSync(settingsFile)) {
|
|
1401
|
+
const settings = JSON.parse(readFileSync(settingsFile, 'utf8'));
|
|
1402
|
+
const preToolUse = settings?.hooks?.PreToolUse ?? [];
|
|
1403
|
+
const guardCmd = 'node .claude/hooks/head-guard.mjs';
|
|
1404
|
+
const tierCmd = 'node .claude/hooks/enforce-tier.mjs';
|
|
1405
|
+
const hasEdit = preToolUse.some(e => e.matcher === 'Edit' && e.hooks?.some(h => h.command === guardCmd));
|
|
1406
|
+
const hasWrite = preToolUse.some(e => e.matcher === 'Write' && e.hooks?.some(h => h.command === guardCmd));
|
|
1407
|
+
const hasBash = preToolUse.some(e => e.matcher === 'Bash' && e.hooks?.some(h => h.command === guardCmd));
|
|
1408
|
+
const hasAgent = preToolUse.some(e => e.matcher === 'Agent' && e.hooks?.some(h => h.command === tierCmd));
|
|
1409
|
+
guardCount = [hasEdit, hasWrite, hasBash, hasAgent].filter(Boolean).length;
|
|
1410
|
+
}
|
|
1411
|
+
} catch { /* ignore */ }
|
|
1412
|
+
|
|
1413
|
+
const modeLabel = (m) => m === profile.mode ? `${m} (active)` : m;
|
|
1414
|
+
|
|
1415
|
+
const claudeSub = profile?.providers?.claude;
|
|
1416
|
+
const openaiSub = profile?.providers?.openai;
|
|
1417
|
+
const claudePlanLabel = claudeSub?.enabled
|
|
1418
|
+
? (CLAUDE_PLAN_LABELS[claudeSub.plan] ?? claudeSub.plan ?? 'n/a')
|
|
1419
|
+
: 'disabled';
|
|
1420
|
+
const openaiPlanLabel = openaiSub?.enabled
|
|
1421
|
+
? (OPENAI_PLAN_LABELS[openaiSub.plan] ?? openaiSub.plan ?? 'n/a')
|
|
1422
|
+
: 'disabled';
|
|
1423
|
+
|
|
1424
|
+
const settingsLines = [
|
|
1425
|
+
`Mode:`,
|
|
1426
|
+
` [1] ${modeLabel('cost-saver')}`,
|
|
1427
|
+
` [2] ${modeLabel('balanced')}`,
|
|
1428
|
+
` [3] ${modeLabel('quality-first')}`,
|
|
1429
|
+
'',
|
|
1430
|
+
`Subscriptions:`,
|
|
1431
|
+
` Claude: ${auth.claude.found ? 'logged in' : 'not logged in'} — ${claudePlanLabel}${claudeSub?.label ? ` [${claudeSub.label}]` : ''}`,
|
|
1432
|
+
` OpenAI: ${auth.openai.found ? 'logged in' : 'not logged in'} — ${openaiPlanLabel}${openaiSub?.label ? ` [${openaiSub.label}]` : ''}`,
|
|
1433
|
+
'',
|
|
1434
|
+
`Enforcement: ${guardCount}/4 guards active`,
|
|
1435
|
+
];
|
|
1436
|
+
|
|
1437
|
+
console.log('');
|
|
1438
|
+
console.log(box('Settings', settingsLines));
|
|
1439
|
+
console.log('');
|
|
1440
|
+
console.log(menu([
|
|
1441
|
+
{ key: '1', label: 'Switch to cost-saver', section: 'Mode' },
|
|
1442
|
+
{ key: '2', label: 'Switch to balanced', section: 'Mode' },
|
|
1443
|
+
{ key: '3', label: 'Switch to quality-first', section: 'Mode' },
|
|
1444
|
+
{ key: 'a', label: 'Manage subscriptions', section: 'Subscriptions' },
|
|
1445
|
+
{ key: 'i', label: 'Reinstall hooks', section: 'Enforcement' },
|
|
1446
|
+
{ key: 'b', label: 'Back', section: '' },
|
|
1447
|
+
]));
|
|
1448
|
+
console.log('');
|
|
1449
|
+
|
|
1450
|
+
const choice = (await ask(' Choice: ')).trim().toLowerCase();
|
|
1451
|
+
|
|
1452
|
+
if (choice === '1' || choice === '2' || choice === '3') {
|
|
1453
|
+
const modeMap = { '1': 'cost-saver', '2': 'balanced', '3': 'quality-first' };
|
|
1454
|
+
profile.mode = modeMap[choice];
|
|
1455
|
+
saveProfile(profile, { cwd });
|
|
1456
|
+
console.log(` Mode set to: ${profile.mode}`);
|
|
1457
|
+
return { next: 'settings' };
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
if (choice === 'a') {
|
|
1461
|
+
return { next: 'subscriptions' };
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
if (choice === 'i') {
|
|
1465
|
+
await cmdInstall();
|
|
1466
|
+
return { next: 'settings' };
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
if (choice === 'b' || choice === 'back') { return { next: 'main' }; }
|
|
1470
|
+
|
|
1471
|
+
return { next: 'settings' };
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// ─── Helper: aggregatePlans ───────────────────────────────────────────────────
|
|
1475
|
+
|
|
1476
|
+
const PLAN_PRICES = {
|
|
1477
|
+
pro: '$20', max5: '$100', max20: '$200',
|
|
1478
|
+
plus: '$20', pro100: '$100', pro200: '$200',
|
|
1479
|
+
};
|
|
1480
|
+
|
|
1481
|
+
function aggregatePlans(subs) {
|
|
1482
|
+
if (!subs || subs.length === 0) return '';
|
|
1483
|
+
const counts = {};
|
|
1484
|
+
for (const s of subs) {
|
|
1485
|
+
const price = PLAN_PRICES[s.plan] || s.plan;
|
|
1486
|
+
counts[price] = (counts[price] || 0) + 1;
|
|
1487
|
+
}
|
|
1488
|
+
return Object.entries(counts)
|
|
1489
|
+
.sort((a, b) => parseInt(b[0].slice(1)) - parseInt(a[0].slice(1)))
|
|
1490
|
+
.map(([price, count]) => `${price}×${count}`)
|
|
1491
|
+
.join(' ');
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
// ─── Screen: subscriptionsScreen ─────────────────────────────────────────────
|
|
1495
|
+
|
|
1496
|
+
async function subscriptionsScreen(rl, ask) {
|
|
1497
|
+
console.clear();
|
|
1498
|
+
const cwd = process.cwd();
|
|
1499
|
+
const profile = loadProfile(cwd);
|
|
1500
|
+
const auth = await detectAuth();
|
|
1501
|
+
|
|
1502
|
+
// Backward compat: migrate old single-sub format to subs array
|
|
1503
|
+
for (const prov of ['claude', 'openai']) {
|
|
1504
|
+
const p = profile?.providers?.[prov];
|
|
1505
|
+
if (p && !p.subs && p.plan) {
|
|
1506
|
+
p.subs = [{ plan: p.plan, label: p.label || null, expiresAt: p.expiresAt || null }];
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// Build status lines — roster format
|
|
1511
|
+
const lines = [];
|
|
1512
|
+
|
|
1513
|
+
function buildProviderLines(provKey, displayName, authFound) {
|
|
1514
|
+
const sub = profile?.providers?.[provKey];
|
|
1515
|
+
const subs = sub?.subs || [];
|
|
1516
|
+
if (!authFound && subs.length === 0) {
|
|
1517
|
+
lines.push(` ⚠️ ${displayName}: not linked`);
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
const aggregate = aggregatePlans(subs);
|
|
1521
|
+
const prefix = authFound ? '✅' : '⚠️ ';
|
|
1522
|
+
lines.push(` ${prefix} ${displayName}:${aggregate ? ' ' + aggregate : ' (no subs)'}`);
|
|
1523
|
+
subs.forEach((s, i) => {
|
|
1524
|
+
const planLabels = provKey === 'claude' ? CLAUDE_PLAN_LABELS : OPENAI_PLAN_LABELS;
|
|
1525
|
+
const planLabel = planLabels[s.plan] ?? s.plan ?? 'unknown';
|
|
1526
|
+
const nameStr = (s.label || '(no label)').padEnd(22);
|
|
1527
|
+
const d = s.expiresAt ? daysUntil(s.expiresAt) : null;
|
|
1528
|
+
const expiry = d === null ? '' : d < 0 ? ' (expired)' : d === 0 ? ' (today)' : ` (${d}d left)`;
|
|
1529
|
+
lines.push(` ${i + 1}. ${nameStr} ${planLabel}${expiry}`);
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
buildProviderLines('claude', 'Claude', auth.claude.found);
|
|
1534
|
+
lines.push('');
|
|
1535
|
+
buildProviderLines('openai', 'OpenAI', auth.openai.found);
|
|
1536
|
+
|
|
1537
|
+
console.log(box('Subscriptions', lines));
|
|
1538
|
+
console.log('');
|
|
1539
|
+
|
|
1540
|
+
const menuOpts = [
|
|
1541
|
+
{ key: '1', label: 'Add Claude sub', section: 'Link' },
|
|
1542
|
+
{ key: '2', label: 'Add Codex sub', section: 'Link' },
|
|
1543
|
+
{ key: 'r', label: 'Remove a sub', section: 'Link' },
|
|
1544
|
+
{ key: 'b', label: 'Back to home', section: '' },
|
|
1545
|
+
];
|
|
1546
|
+
console.log(menu(menuOpts));
|
|
1547
|
+
console.log('');
|
|
1548
|
+
|
|
1549
|
+
const choice = (await ask(' Choice: ')).trim().toLowerCase();
|
|
1550
|
+
|
|
1551
|
+
if (choice === '1') {
|
|
1552
|
+
console.log('\n Linking Claude subscription...');
|
|
1553
|
+
console.log(' A browser window will open — paste the code below when prompted.\n');
|
|
1554
|
+
const { spawnSync } = await import('node:child_process');
|
|
1555
|
+
const r = spawnSync('claude', ['auth', 'login'], { stdio: 'inherit', timeout: 60000 });
|
|
1556
|
+
if (r.status === 0) {
|
|
1557
|
+
console.log('\n ✅ Claude linked successfully!\n');
|
|
1558
|
+
const label = (await ask(" Label (e.g. \"Josh's $100 sub\", or Enter to skip): ")).trim();
|
|
1559
|
+
const expiry = await askExpiry(ask, 'Claude');
|
|
1560
|
+
const newPlans = detectPlans();
|
|
1561
|
+
const plan = newPlans.claude?.plan || 'pro';
|
|
1562
|
+
if (!profile.providers) profile.providers = {};
|
|
1563
|
+
if (!profile.providers.claude) profile.providers.claude = { enabled: true };
|
|
1564
|
+
profile.providers.claude.plan = plan;
|
|
1565
|
+
profile.providers.claude.enabled = true;
|
|
1566
|
+
// Push to subs array instead of overwriting
|
|
1567
|
+
if (!profile.providers.claude.subs) profile.providers.claude.subs = [];
|
|
1568
|
+
profile.providers.claude.subs.push({ plan, label: label || null, expiresAt: expiry || null });
|
|
1569
|
+
saveProfile(profile, { cwd });
|
|
1570
|
+
console.log(' ✓ Saved\n');
|
|
1571
|
+
await ask(' Press Enter to continue...');
|
|
1572
|
+
} else {
|
|
1573
|
+
console.log('\n ❌ Claude login failed or was cancelled.\n');
|
|
1574
|
+
await ask(' Press Enter to continue...');
|
|
1575
|
+
}
|
|
1576
|
+
return { next: 'subscriptions' };
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
if (choice === '2') {
|
|
1580
|
+
console.log('\n Linking Codex subscription...');
|
|
1581
|
+
console.log(' A browser window will open — paste the code below when prompted.\n');
|
|
1582
|
+
const { spawnSync } = await import('node:child_process');
|
|
1583
|
+
const r = spawnSync('codex', ['login'], { stdio: 'inherit', timeout: 60000 });
|
|
1584
|
+
if (r.status === 0) {
|
|
1585
|
+
console.log('\n ✅ Codex linked successfully!\n');
|
|
1586
|
+
const label = (await ask(' Label (e.g. "Team Codex Pro", or Enter to skip): ')).trim();
|
|
1587
|
+
const expiry = await askExpiry(ask, 'Codex');
|
|
1588
|
+
const newPlans = detectPlans();
|
|
1589
|
+
const plan = newPlans.openai?.plan || 'plus';
|
|
1590
|
+
if (!profile.providers) profile.providers = {};
|
|
1591
|
+
if (!profile.providers.openai) profile.providers.openai = { enabled: true };
|
|
1592
|
+
profile.providers.openai.plan = plan;
|
|
1593
|
+
profile.providers.openai.enabled = true;
|
|
1594
|
+
// Push to subs array instead of overwriting
|
|
1595
|
+
if (!profile.providers.openai.subs) profile.providers.openai.subs = [];
|
|
1596
|
+
profile.providers.openai.subs.push({ plan, label: label || null, expiresAt: expiry || null });
|
|
1597
|
+
saveProfile(profile, { cwd });
|
|
1598
|
+
console.log(' ✓ Saved\n');
|
|
1599
|
+
await ask(' Press Enter to continue...');
|
|
1600
|
+
} else {
|
|
1601
|
+
console.log('\n ❌ Codex login failed or was cancelled.\n');
|
|
1602
|
+
await ask(' Press Enter to continue...');
|
|
1603
|
+
}
|
|
1604
|
+
return { next: 'subscriptions' };
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
if (choice === 'r') {
|
|
1608
|
+
// Build a flat numbered list of all subs across both providers
|
|
1609
|
+
const allSubs = [];
|
|
1610
|
+
for (const [provKey, displayName] of [['claude', 'Claude'], ['openai', 'OpenAI']]) {
|
|
1611
|
+
const subs = profile?.providers?.[provKey]?.subs || [];
|
|
1612
|
+
for (const s of subs) {
|
|
1613
|
+
allSubs.push({ provKey, displayName, sub: s });
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
if (allSubs.length === 0) {
|
|
1618
|
+
console.log('\n No subscriptions to remove.\n');
|
|
1619
|
+
await ask(' Press Enter to continue...');
|
|
1620
|
+
return { next: 'subscriptions' };
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
console.log('\n Remove a subscription:\n');
|
|
1624
|
+
allSubs.forEach(({ displayName, sub }, i) => {
|
|
1625
|
+
const planLabels = displayName === 'Claude' ? CLAUDE_PLAN_LABELS : OPENAI_PLAN_LABELS;
|
|
1626
|
+
const planLabel = planLabels[sub.plan] ?? sub.plan ?? 'unknown';
|
|
1627
|
+
const labelStr = sub.label ? ` [${sub.label}]` : '';
|
|
1628
|
+
console.log(` (${i + 1}) ${displayName}: ${planLabel}${labelStr}`);
|
|
1629
|
+
});
|
|
1630
|
+
console.log(' (Enter) Cancel\n');
|
|
1631
|
+
|
|
1632
|
+
const numStr = (await ask(' Remove #: ')).trim();
|
|
1633
|
+
const numChoice = parseInt(numStr, 10);
|
|
1634
|
+
if (!isNaN(numChoice) && numChoice >= 1 && numChoice <= allSubs.length) {
|
|
1635
|
+
const { provKey, sub } = allSubs[numChoice - 1];
|
|
1636
|
+
const confirm = (await ask(` Remove "${sub.label || sub.plan}" from ${provKey}? (y/N): `)).trim().toLowerCase();
|
|
1637
|
+
if (confirm === 'y') {
|
|
1638
|
+
const subs = profile.providers[provKey].subs;
|
|
1639
|
+
const idx = subs.indexOf(sub);
|
|
1640
|
+
if (idx !== -1) subs.splice(idx, 1);
|
|
1641
|
+
// Update top-level plan to first remaining sub (or keep as-is)
|
|
1642
|
+
if (subs.length > 0) {
|
|
1643
|
+
profile.providers[provKey].plan = subs[0].plan;
|
|
1644
|
+
}
|
|
1645
|
+
saveProfile(profile, { cwd });
|
|
1646
|
+
console.log(' ✓ Removed\n');
|
|
1647
|
+
} else {
|
|
1648
|
+
console.log(' Cancelled.\n');
|
|
1649
|
+
}
|
|
1650
|
+
} else {
|
|
1651
|
+
console.log(' Cancelled.\n');
|
|
1652
|
+
}
|
|
1653
|
+
await ask(' Press Enter to continue...');
|
|
1654
|
+
return { next: 'subscriptions' };
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
return { next: 'main' };
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// ─── Onboarding Wizard ───────────────────────────────────────────────────────
|
|
1661
|
+
|
|
1662
|
+
/**
|
|
1663
|
+
* 5-step onboarding wizard shown on first run (no .dualbrain/profile.json).
|
|
1664
|
+
* Matches the rounded ┌─┐ box style used in mainScreen / renderHeader.
|
|
1665
|
+
* @param {{ auth, plans, existingSessions }} detection
|
|
1666
|
+
* @param {string} cwd
|
|
1667
|
+
* @param {object} rl readline interface
|
|
1668
|
+
* @returns {object|null} profile object to save, or null if cancelled/skipped
|
|
1669
|
+
*/
|
|
1670
|
+
async function runOnboardingWizard(detection, cwd, rl) {
|
|
1671
|
+
const ask = (q) => new Promise(res => rl.question(q, res));
|
|
1672
|
+
const version = readVersion();
|
|
1673
|
+
|
|
1674
|
+
// ── Rounded box helpers (matching mainScreen style) ────────────────────────
|
|
1675
|
+
const W = 51;
|
|
1676
|
+
const wTop = ` ┌${'─'.repeat(W)}┐`;
|
|
1677
|
+
const wSep = ` ├${'─'.repeat(W)}┤`;
|
|
1678
|
+
const wBottom = ` └${'─'.repeat(W)}┘`;
|
|
1679
|
+
const wPad = (s) => {
|
|
1680
|
+
const plain = s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
1681
|
+
let vlen = 0;
|
|
1682
|
+
for (const ch of plain) {
|
|
1683
|
+
const cp = ch.codePointAt(0);
|
|
1684
|
+
if (
|
|
1685
|
+
(cp >= 0x1f300 && cp <= 0x1faff) ||
|
|
1686
|
+
(cp >= 0x2600 && cp <= 0x27bf) ||
|
|
1687
|
+
cp === 0xfe0f || cp === 0x20e3
|
|
1688
|
+
) { vlen += 2; } else { vlen += 1; }
|
|
1689
|
+
}
|
|
1690
|
+
return s + ' '.repeat(Math.max(0, W - vlen));
|
|
1691
|
+
};
|
|
1692
|
+
const wRow = (s) => ` │ ${wPad(s)}│`;
|
|
1693
|
+
|
|
1694
|
+
// ── Collected wizard state ─────────────────────────────────────────────────
|
|
1695
|
+
const state = {
|
|
1696
|
+
claudePlan: null,
|
|
1697
|
+
openaiPlan: null,
|
|
1698
|
+
headModel: null,
|
|
1699
|
+
importSessions: false,
|
|
1700
|
+
profile: 'auto',
|
|
1701
|
+
};
|
|
1702
|
+
|
|
1703
|
+
const { auth, plans, existingSessions } = detection;
|
|
1704
|
+
const claudeReady = auth.claude.found;
|
|
1705
|
+
const openaiReady = auth.openai.found;
|
|
1706
|
+
|
|
1707
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1708
|
+
// Step 1 — Welcome & provider detection
|
|
1709
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1710
|
+
console.log('');
|
|
1711
|
+
console.log(wTop);
|
|
1712
|
+
console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
|
|
1713
|
+
console.log(wSep);
|
|
1714
|
+
console.log(wRow(`Step 1 of 5: Detected providers`));
|
|
1715
|
+
console.log(wSep);
|
|
1716
|
+
|
|
1717
|
+
// Plan tier is inferred from auth config signals — not the actual plan name.
|
|
1718
|
+
// Show the tier ($20/$100/$200) with "configured" suffix to be honest.
|
|
1719
|
+
const claudePlanSuffix = claudeReady && plans.claude ? ` · ${plans.claude} configured` : '';
|
|
1720
|
+
const openaiPlanSuffix = openaiReady && plans.openai ? ` · ${plans.openai} configured` : '';
|
|
1721
|
+
|
|
1722
|
+
console.log(wRow(claudeReady
|
|
1723
|
+
? `✓ Claude CLI${claudePlanSuffix}`
|
|
1724
|
+
: `✗ Claude CLI not logged in`));
|
|
1725
|
+
console.log(wRow(openaiReady
|
|
1726
|
+
? `✓ Codex CLI${openaiPlanSuffix}`
|
|
1727
|
+
: `✗ Codex CLI not logged in`));
|
|
1728
|
+
if (existingSessions.length > 0) {
|
|
1729
|
+
console.log(wRow(`✓ ${existingSessions.length} data-tools session${existingSessions.length !== 1 ? 's' : ''} found`));
|
|
1730
|
+
}
|
|
1731
|
+
console.log(wSep);
|
|
1732
|
+
console.log(wRow(`[Enter] Continue setup [s] Skip wizard`));
|
|
1733
|
+
console.log(wBottom);
|
|
1734
|
+
console.log('');
|
|
1735
|
+
|
|
1736
|
+
if (!claudeReady && !openaiReady) {
|
|
1737
|
+
console.log(' No AI provider found. Log in first:');
|
|
1738
|
+
console.log(' claude auth login — for Claude');
|
|
1739
|
+
console.log(' codex login — for OpenAI/Codex');
|
|
1740
|
+
console.log(' Then re-run: dual-brain init\n');
|
|
1741
|
+
return null;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
const step1 = (await ask(' > ')).trim().toLowerCase();
|
|
1745
|
+
if (step1 === 's') {
|
|
1746
|
+
// Skip: auto-save detected plans and proceed directly
|
|
1747
|
+
const skippedProfile = loadProfile(cwd);
|
|
1748
|
+
if (claudeReady) skippedProfile.providers.claude = { enabled: true, plan: plans.claude || 'pro' };
|
|
1749
|
+
if (openaiReady) skippedProfile.providers.openai = { enabled: true, plan: plans.openai || 'plus' };
|
|
1750
|
+
const enabledCount = [claudeReady, openaiReady].filter(Boolean).length;
|
|
1751
|
+
skippedProfile.mode = enabledCount >= 2 ? 'auto' : claudeReady ? 'solo-claude' : 'solo-openai';
|
|
1752
|
+
return skippedProfile;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1756
|
+
// Step 2 — Budget / plan selection
|
|
1757
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1758
|
+
console.log('');
|
|
1759
|
+
console.log(wTop);
|
|
1760
|
+
console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
|
|
1761
|
+
console.log(wSep);
|
|
1762
|
+
console.log(wRow(`Step 2 of 5: Subscription plans`));
|
|
1763
|
+
console.log(wSep);
|
|
1764
|
+
|
|
1765
|
+
if (claudeReady) {
|
|
1766
|
+
// Plan tier is inferred from auth config (rate-limit signal), not the actual plan name.
|
|
1767
|
+
const configuredClaudePlan = plans.claude || '$20';
|
|
1768
|
+
const configuredClaudeDesc = configuredClaudePlan + ' configured';
|
|
1769
|
+
console.log(wRow(`Claude — ${configuredClaudeDesc}`));
|
|
1770
|
+
console.log(wRow(` [1] Pro ($20/mo)`));
|
|
1771
|
+
console.log(wRow(` [2] Max x5 ($100/mo)`));
|
|
1772
|
+
console.log(wRow(` [3] Max x20 ($200/mo)`));
|
|
1773
|
+
console.log(wRow(` [Enter] Keep configured (${configuredClaudePlan})`));
|
|
1774
|
+
console.log(wSep);
|
|
1775
|
+
const claudeChoice = (await ask(' Claude plan [1/2/3/Enter]: ')).trim();
|
|
1776
|
+
const claudePlanMap = { '1': 'pro', '2': 'max5', '3': 'max20' };
|
|
1777
|
+
state.claudePlan = claudePlanMap[claudeChoice] || configuredClaudePlan;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
if (openaiReady) {
|
|
1781
|
+
// Plan tier is inferred from JWT claim in auth config, not the actual plan name.
|
|
1782
|
+
const configuredOpenaiPlan = plans.openai || '$20';
|
|
1783
|
+
const configuredOpenaiDesc = configuredOpenaiPlan + ' configured';
|
|
1784
|
+
console.log(wRow(`OpenAI — ${configuredOpenaiDesc}`));
|
|
1785
|
+
console.log(wRow(` [1] Plus ($20/mo)`));
|
|
1786
|
+
console.log(wRow(` [2] Pro ($100/mo)`));
|
|
1787
|
+
console.log(wRow(` [3] Pro ($200/mo higher limits)`));
|
|
1788
|
+
console.log(wRow(` [Enter] Keep configured (${configuredOpenaiPlan})`));
|
|
1789
|
+
console.log(wSep);
|
|
1790
|
+
const openaiChoice = (await ask(' OpenAI plan [1/2/3/Enter]: ')).trim();
|
|
1791
|
+
const openaiPlanMap = { '1': 'plus', '2': 'pro', '3': 'pro200' };
|
|
1792
|
+
state.openaiPlan = openaiPlanMap[openaiChoice] || configuredOpenaiPlan;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
console.log(wBottom);
|
|
1796
|
+
|
|
1797
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1798
|
+
// Step 3 — HEAD model selection
|
|
1799
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1800
|
+
const hasBigPlan = state.claudePlan === 'max5' || state.claudePlan === 'max20';
|
|
1801
|
+
const recommendedModel = hasBigPlan ? 'claude-opus-4-5' : 'claude-sonnet-4-5';
|
|
1802
|
+
const recommendedLabel = hasBigPlan
|
|
1803
|
+
? 'Opus (Max plan — best quality)'
|
|
1804
|
+
: 'Sonnet (Pro plan — balanced speed/quality)';
|
|
1805
|
+
|
|
1806
|
+
console.log('');
|
|
1807
|
+
console.log(wTop);
|
|
1808
|
+
console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
|
|
1809
|
+
console.log(wSep);
|
|
1810
|
+
console.log(wRow(`Step 3 of 5: HEAD model (think-tier)`));
|
|
1811
|
+
console.log(wSep);
|
|
1812
|
+
console.log(wRow(`Recommended: ${recommendedLabel}`));
|
|
1813
|
+
console.log(wSep);
|
|
1814
|
+
console.log(wRow(` [1] Haiku — fastest, lowest cost`));
|
|
1815
|
+
console.log(wRow(` [2] Sonnet — balanced (recommended for Pro)`));
|
|
1816
|
+
console.log(wRow(` [3] Opus — best quality (recommended for Max)`));
|
|
1817
|
+
console.log(wRow(` [Enter] Use recommended`));
|
|
1818
|
+
console.log(wBottom);
|
|
1819
|
+
console.log('');
|
|
1820
|
+
|
|
1821
|
+
const step3 = (await ask(' HEAD model [1/2/3/Enter]: ')).trim();
|
|
1822
|
+
const modelMap = {
|
|
1823
|
+
'1': 'claude-haiku-4-5',
|
|
1824
|
+
'2': 'claude-sonnet-4-5',
|
|
1825
|
+
'3': 'claude-opus-4-5',
|
|
1826
|
+
};
|
|
1827
|
+
state.headModel = modelMap[step3] || recommendedModel;
|
|
1828
|
+
|
|
1829
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1830
|
+
// Step 4 — Import sessions + profile selection
|
|
1831
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1832
|
+
console.log('');
|
|
1833
|
+
console.log(wTop);
|
|
1834
|
+
console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
|
|
1835
|
+
console.log(wSep);
|
|
1836
|
+
console.log(wRow(`Step 4 of 5: Sessions & routing profile`));
|
|
1837
|
+
console.log(wSep);
|
|
1838
|
+
|
|
1839
|
+
if (existingSessions.length > 0) {
|
|
1840
|
+
console.log(wRow(`Import ${existingSessions.length} data-tools session${existingSessions.length !== 1 ? 's' : ''}?`));
|
|
1841
|
+
console.log(wRow(` [y] Yes [Enter/n] Skip`));
|
|
1842
|
+
console.log(wSep);
|
|
1843
|
+
const importChoice = (await ask(' Import sessions [y/Enter]: ')).trim().toLowerCase();
|
|
1844
|
+
state.importSessions = importChoice === 'y';
|
|
1845
|
+
if (state.importSessions) {
|
|
1846
|
+
console.log('');
|
|
1847
|
+
console.log(` Importing ${existingSessions.length} sessions...`);
|
|
1848
|
+
const recent = existingSessions.slice(0, 5);
|
|
1849
|
+
for (const sess of recent) {
|
|
1850
|
+
console.log(` ${sess.age.padEnd(6)} ${sess.name}`);
|
|
1851
|
+
}
|
|
1852
|
+
if (existingSessions.length > 5) {
|
|
1853
|
+
console.log(` ... and ${existingSessions.length - 5} more`);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
console.log(wSep);
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
console.log(wRow(`Routing profile:`));
|
|
1860
|
+
console.log(wRow(` [1] auto — adapts based on task risk & outcomes`));
|
|
1861
|
+
console.log(wRow(` [2] balanced — best model per tier, normal budgets`));
|
|
1862
|
+
console.log(wRow(` [3] cost-saver — prefer cheaper models, skip GPT`));
|
|
1863
|
+
console.log(wRow(` [4] quality-first — dual-brain for medium+ risk`));
|
|
1864
|
+
console.log(wRow(` [Enter] auto (recommended)`));
|
|
1865
|
+
console.log(wBottom);
|
|
1866
|
+
console.log('');
|
|
1867
|
+
|
|
1868
|
+
const step4 = (await ask(' Profile [1/2/3/4/Enter]: ')).trim();
|
|
1869
|
+
const profileMap = { '1': 'auto', '2': 'balanced', '3': 'cost-saver', '4': 'quality-first' };
|
|
1870
|
+
state.profile = profileMap[step4] || 'auto';
|
|
1871
|
+
|
|
1872
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1873
|
+
// Step 5 — Summary & confirm
|
|
1874
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1875
|
+
const claudeSummary = state.claudePlan
|
|
1876
|
+
? `Claude: ${CLAUDE_PLAN_LABELS[state.claudePlan] ?? state.claudePlan}`
|
|
1877
|
+
: `Claude: not configured`;
|
|
1878
|
+
const openaiSummary = state.openaiPlan
|
|
1879
|
+
? `OpenAI: ${OPENAI_PLAN_LABELS[state.openaiPlan] ?? state.openaiPlan}`
|
|
1880
|
+
: `OpenAI: not configured`;
|
|
1881
|
+
const modelSummary = `HEAD model: ${state.headModel}`;
|
|
1882
|
+
const profileSummary = `Profile: ${state.profile}`;
|
|
1883
|
+
const sessionSummary = existingSessions.length > 0
|
|
1884
|
+
? `Sessions: ${state.importSessions ? `${existingSessions.length} imported` : 'skipped'}`
|
|
1885
|
+
: null;
|
|
1886
|
+
|
|
1887
|
+
console.log('');
|
|
1888
|
+
console.log(wTop);
|
|
1889
|
+
console.log(wRow(`🧠 Dual-Brain v${version} — First-time Setup`));
|
|
1890
|
+
console.log(wSep);
|
|
1891
|
+
console.log(wRow(`Step 5 of 5: Summary`));
|
|
1892
|
+
console.log(wSep);
|
|
1893
|
+
console.log(wRow(claudeSummary));
|
|
1894
|
+
console.log(wRow(openaiSummary));
|
|
1895
|
+
console.log(wRow(modelSummary));
|
|
1896
|
+
console.log(wRow(profileSummary));
|
|
1897
|
+
if (sessionSummary) console.log(wRow(sessionSummary));
|
|
1898
|
+
console.log(wSep);
|
|
1899
|
+
console.log(wRow(`[Enter] Save and start [q] Quit without saving`));
|
|
1900
|
+
console.log(wBottom);
|
|
1901
|
+
console.log('');
|
|
1902
|
+
|
|
1903
|
+
const step5 = (await ask(' > ')).trim().toLowerCase();
|
|
1904
|
+
if (step5 === 'q') {
|
|
1905
|
+
console.log('\n Setup cancelled.\n');
|
|
1906
|
+
return null;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// ── Build and return the profile object ────────────────────────────────────
|
|
1910
|
+
const finalProfile = loadProfile(cwd);
|
|
1911
|
+
|
|
1912
|
+
if (state.claudePlan) {
|
|
1913
|
+
finalProfile.providers.claude = { enabled: true, plan: state.claudePlan };
|
|
1914
|
+
} else if (claudeReady) {
|
|
1915
|
+
finalProfile.providers.claude = { enabled: true, plan: plans.claude || 'pro' };
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
if (state.openaiPlan) {
|
|
1919
|
+
finalProfile.providers.openai = { enabled: true, plan: state.openaiPlan };
|
|
1920
|
+
} else if (openaiReady) {
|
|
1921
|
+
finalProfile.providers.openai = { enabled: true, plan: plans.openai || 'plus' };
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
const enabledCount = [
|
|
1925
|
+
finalProfile.providers?.claude?.enabled,
|
|
1926
|
+
finalProfile.providers?.openai?.enabled,
|
|
1927
|
+
].filter(Boolean).length;
|
|
1928
|
+
|
|
1929
|
+
finalProfile.mode = enabledCount >= 2 ? state.profile : claudeReady ? 'solo-claude' : 'solo-openai';
|
|
1930
|
+
finalProfile.headModel = state.headModel;
|
|
1931
|
+
finalProfile.bias = state.profile;
|
|
1932
|
+
|
|
1933
|
+
return finalProfile;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
// ─── Screen: dashboardScreen (kept for internal reference, unreachable) ───────
|
|
1937
|
+
|
|
1938
|
+
async function dashboardScreen(rl, ask) {
|
|
1939
|
+
return { next: 'main' };
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// ─── Screen: authScreen — subscription status view ───────────────────────────
|
|
1943
|
+
|
|
1944
|
+
async function authScreen(rl, ask) {
|
|
1945
|
+
const cwd = process.cwd();
|
|
1946
|
+
const auth = await detectAuth();
|
|
1947
|
+
const profile = loadProfile(cwd);
|
|
1948
|
+
|
|
1949
|
+
const claudeSub = profile?.providers?.claude;
|
|
1950
|
+
const openaiSub = profile?.providers?.openai;
|
|
1951
|
+
const claudePlanLabel = claudeSub?.enabled
|
|
1952
|
+
? (CLAUDE_PLAN_LABELS[claudeSub.plan] ?? claudeSub.plan ?? 'n/a')
|
|
1953
|
+
: 'disabled';
|
|
1954
|
+
const openaiPlanLabel = openaiSub?.enabled
|
|
1955
|
+
? (OPENAI_PLAN_LABELS[openaiSub.plan] ?? openaiSub.plan ?? 'n/a')
|
|
1956
|
+
: 'disabled';
|
|
1957
|
+
|
|
1958
|
+
const authLines = [
|
|
1959
|
+
'Claude:',
|
|
1960
|
+
auth.claude.found
|
|
1961
|
+
? ` logged in via ${auth.claude.source}`
|
|
1962
|
+
: ` not logged in — run: claude auth login`,
|
|
1963
|
+
` plan: ${claudePlanLabel}${claudeSub?.label ? ` [${claudeSub.label}]` : ''}`,
|
|
1964
|
+
'',
|
|
1965
|
+
'OpenAI:',
|
|
1966
|
+
auth.openai.found
|
|
1967
|
+
? ` logged in via ${auth.openai.source}`
|
|
1968
|
+
: ` not logged in — run: codex login`,
|
|
1969
|
+
` plan: ${openaiPlanLabel}${openaiSub?.label ? ` [${openaiSub.label}]` : ''}`,
|
|
1970
|
+
];
|
|
1971
|
+
|
|
1972
|
+
console.log(box('Subscription Status', authLines));
|
|
1973
|
+
console.log('');
|
|
1974
|
+
console.log(menu([
|
|
1975
|
+
{ key: 'a', label: 'Manage subscriptions', section: '' },
|
|
1976
|
+
{ key: 'b', label: 'Back to dashboard', section: '' },
|
|
1977
|
+
]));
|
|
1978
|
+
console.log('');
|
|
1979
|
+
|
|
1980
|
+
const choice = (await ask(' Choice: ')).trim().toLowerCase();
|
|
1981
|
+
|
|
1982
|
+
if (choice === 'a') { return { next: 'subscriptions' }; }
|
|
1983
|
+
if (choice === 'b' || choice === 'back') { return { next: 'dashboard' }; }
|
|
1984
|
+
|
|
1985
|
+
return { next: 'auth' };
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
// ─── Screen: profileScreen ────────────────────────────────────────────────────
|
|
1989
|
+
|
|
1990
|
+
async function profileScreen(rl, ask) {
|
|
1991
|
+
const cwd = process.cwd();
|
|
1992
|
+
const profile = loadProfile(cwd);
|
|
1993
|
+
const prefs = getActivePreferences(cwd);
|
|
1994
|
+
|
|
1995
|
+
const profileLines = [
|
|
1996
|
+
`Mode: ${profile.mode}`,
|
|
1997
|
+
`Claude plan: ${profile.providers?.claude?.enabled ? (profile.providers?.claude?.plan || 'n/a') : 'disabled'}`,
|
|
1998
|
+
`OpenAI plan: ${profile.providers?.openai?.enabled ? (profile.providers?.openai?.plan || 'n/a') : 'disabled'}`,
|
|
1999
|
+
`Solo brain: ${isSoloBrain(profile) ? 'yes' : 'no'}`,
|
|
2000
|
+
`Head model: ${getHeadModel(profile)}`,
|
|
2001
|
+
'',
|
|
2002
|
+
`Preferences (${prefs.length}):`,
|
|
2003
|
+
...prefs.map(p => ` [${p.scope}] ${p.text}`),
|
|
2004
|
+
...(prefs.length === 0 ? [' (none)'] : []),
|
|
2005
|
+
];
|
|
2006
|
+
|
|
2007
|
+
console.log(box('Profile & Preferences', profileLines));
|
|
2008
|
+
console.log('');
|
|
2009
|
+
console.log(menu([
|
|
2010
|
+
{ key: '1', label: 'Switch to cost-saver mode', section: 'Mode' },
|
|
2011
|
+
{ key: '2', label: 'Switch to balanced mode', section: 'Mode' },
|
|
2012
|
+
{ key: '3', label: 'Switch to quality-first mode',section: 'Mode' },
|
|
2013
|
+
{ key: 'r', label: 'Add preference', section: 'Preferences' },
|
|
2014
|
+
{ key: 'f', label: 'Remove preference', section: 'Preferences' },
|
|
2015
|
+
{ key: 'b', label: 'Back to dashboard', section: '' },
|
|
2016
|
+
]));
|
|
2017
|
+
console.log('');
|
|
2018
|
+
|
|
2019
|
+
const choice = (await ask(' Choice: ')).trim().toLowerCase();
|
|
2020
|
+
|
|
2021
|
+
if (choice === '1' || choice === '2' || choice === '3') {
|
|
2022
|
+
const modeMap = { '1': 'cost-saver', '2': 'balanced', '3': 'quality-first' };
|
|
2023
|
+
profile.mode = modeMap[choice];
|
|
2024
|
+
saveProfile(profile, { cwd });
|
|
2025
|
+
console.log(` Mode set to: ${profile.mode}`);
|
|
2026
|
+
return { next: 'profile' };
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
if (choice === 'r') {
|
|
2030
|
+
const text = (await ask(' Preference text: ')).trim();
|
|
2031
|
+
if (text) cmdRemember(text);
|
|
2032
|
+
return { next: 'profile' };
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
if (choice === 'f') {
|
|
2036
|
+
const text = (await ask(' Preference to remove (fuzzy): ')).trim();
|
|
2037
|
+
if (text) cmdForget(text);
|
|
2038
|
+
return { next: 'profile' };
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
if (choice === 'b' || choice === 'back') { return { next: 'dashboard' }; }
|
|
2042
|
+
|
|
2043
|
+
return { next: 'profile' };
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
// ─── Screen: diagnosticsScreen ────────────────────────────────────────────────
|
|
2047
|
+
|
|
2048
|
+
async function diagnosticsScreen(rl, ask) {
|
|
2049
|
+
const cwd = process.cwd();
|
|
2050
|
+
const { spawnSync: _spawnSync } = await import('child_process');
|
|
2051
|
+
const { readdirSync } = await import('node:fs');
|
|
2052
|
+
|
|
2053
|
+
// ── Version info ──────────────────────────────────────────────────────────
|
|
2054
|
+
const version = readVersion();
|
|
2055
|
+
const nodeVersion = process.version;
|
|
2056
|
+
|
|
2057
|
+
// ── Provider health ───────────────────────────────────────────────────────
|
|
2058
|
+
const auth = await detectAuth();
|
|
2059
|
+
const plans = detectPlans();
|
|
2060
|
+
const { states: healthStates } = getHealth(cwd);
|
|
2061
|
+
|
|
2062
|
+
function _providerBadge(name) {
|
|
2063
|
+
const entries = Object.entries(healthStates).filter(([k]) => k.startsWith(`${name}:`));
|
|
2064
|
+
if (entries.length === 0) return 'healthy';
|
|
2065
|
+
const statuses = entries.map(([, v]) => v.status);
|
|
2066
|
+
if (statuses.includes('hot')) return 'hot';
|
|
2067
|
+
if (statuses.includes('degraded')) return 'degraded';
|
|
2068
|
+
if (statuses.includes('probing')) return 'probing';
|
|
2069
|
+
return 'healthy';
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
const claudeHealthBadge = auth.claude.found ? _providerBadge('claude') : 'not logged in';
|
|
2073
|
+
const openaiHealthBadge = auth.openai.found ? _providerBadge('openai') : 'not logged in';
|
|
2074
|
+
// Plan tier is inferred from auth config signals — show tier with "configured" to be honest.
|
|
2075
|
+
const claudePlanStr = plans.claude ? `${plans.claude} configured` : 'unknown';
|
|
2076
|
+
const openaiPlanStr = plans.openai ? `${plans.openai} configured` : 'unknown';
|
|
2077
|
+
|
|
2078
|
+
// ── Enforcement checks ────────────────────────────────────────────────────
|
|
2079
|
+
const hooksDir = join(cwd, '.claude', 'hooks');
|
|
2080
|
+
const headGuardExists = existsSync(join(hooksDir, 'head-guard.mjs'));
|
|
2081
|
+
const enforceTierExists = existsSync(join(hooksDir, 'enforce-tier.mjs'));
|
|
2082
|
+
|
|
2083
|
+
let guardCount = 0;
|
|
2084
|
+
try {
|
|
2085
|
+
const settingsFile = join(cwd, '.claude', 'settings.json');
|
|
2086
|
+
if (existsSync(settingsFile)) {
|
|
2087
|
+
const settings = JSON.parse(readFileSync(settingsFile, 'utf8'));
|
|
2088
|
+
const preToolUse = settings?.hooks?.PreToolUse ?? [];
|
|
2089
|
+
const guardCmd = 'node .claude/hooks/head-guard.mjs';
|
|
2090
|
+
const tierCmd = 'node .claude/hooks/enforce-tier.mjs';
|
|
2091
|
+
const hasEdit = preToolUse.some(e => e.matcher === 'Edit' && e.hooks?.some(h => h.command === guardCmd));
|
|
2092
|
+
const hasWrite = preToolUse.some(e => e.matcher === 'Write' && e.hooks?.some(h => h.command === guardCmd));
|
|
2093
|
+
const hasBash = preToolUse.some(e => e.matcher === 'Bash' && e.hooks?.some(h => h.command === guardCmd));
|
|
2094
|
+
const hasAgent = preToolUse.some(e => e.matcher === 'Agent' && e.hooks?.some(h => h.command === tierCmd));
|
|
2095
|
+
guardCount = [hasEdit, hasWrite, hasBash, hasAgent].filter(Boolean).length;
|
|
2096
|
+
}
|
|
2097
|
+
} catch { /* ignore */ }
|
|
2098
|
+
|
|
2099
|
+
let hookifyCount = 0;
|
|
2100
|
+
try {
|
|
2101
|
+
const claudeDir = join(cwd, '.claude');
|
|
2102
|
+
if (existsSync(claudeDir)) {
|
|
2103
|
+
hookifyCount = readdirSync(claudeDir).filter(f => f.startsWith('hookify.') && f.endsWith('.md')).length;
|
|
2104
|
+
}
|
|
2105
|
+
} catch { /* ignore */ }
|
|
2106
|
+
|
|
2107
|
+
// ── Replit-tools integration ──────────────────────────────────────────────
|
|
2108
|
+
const replitToolsDir = join(cwd, '.replit-tools');
|
|
2109
|
+
const hasReplitTools = existsSync(replitToolsDir);
|
|
2110
|
+
const persistentDir = join(replitToolsDir, '.claude-persistent');
|
|
2111
|
+
const sessionManagerExists = existsSync(join(replitToolsDir, 'scripts', 'claude-session-manager.sh'));
|
|
2112
|
+
const authRefreshScript = join(replitToolsDir, 'scripts', 'claude-auth-refresh.sh');
|
|
2113
|
+
|
|
2114
|
+
let credsFresh = null;
|
|
2115
|
+
let credsExpiry = null;
|
|
2116
|
+
let historyCount = 0;
|
|
2117
|
+
|
|
2118
|
+
if (hasReplitTools) {
|
|
2119
|
+
try {
|
|
2120
|
+
const credsFile = join(persistentDir, '.credentials.json');
|
|
2121
|
+
const creds = JSON.parse(readFileSync(credsFile, 'utf8'));
|
|
2122
|
+
const expiresAt = creds?.claudeAiOauth?.expiresAt;
|
|
2123
|
+
if (expiresAt) {
|
|
2124
|
+
const expiresMs = typeof expiresAt === 'number' ? expiresAt : Date.parse(expiresAt);
|
|
2125
|
+
credsFresh = Date.now() < expiresMs;
|
|
2126
|
+
credsExpiry = new Date(expiresMs).toISOString().slice(0, 10);
|
|
2127
|
+
}
|
|
2128
|
+
} catch { /* credentials missing or unreadable */ }
|
|
2129
|
+
|
|
2130
|
+
try {
|
|
2131
|
+
const histFile = join(persistentDir, 'history.jsonl');
|
|
2132
|
+
if (existsSync(histFile)) {
|
|
2133
|
+
historyCount = readFileSync(histFile, 'utf8').split('\n').filter(Boolean).length;
|
|
2134
|
+
}
|
|
2135
|
+
} catch { /* ignore */ }
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// ── Quality checks ────────────────────────────────────────────────────────
|
|
2139
|
+
let testPass = null; let testTotal = null; let testError = null;
|
|
2140
|
+
try {
|
|
2141
|
+
const r = _spawnSync('node', ['--test', 'src/test.mjs'], { cwd, encoding: 'utf8', timeout: 30000 });
|
|
2142
|
+
const out = (r.stdout ?? '') + (r.stderr ?? '');
|
|
2143
|
+
const pm = out.match(/# pass (\d+)/);
|
|
2144
|
+
const tm = out.match(/# tests (\d+)/);
|
|
2145
|
+
if (pm && tm) { testPass = parseInt(pm[1], 10); testTotal = parseInt(tm[1], 10); }
|
|
2146
|
+
else { testError = 'could not parse output'; }
|
|
2147
|
+
} catch (e) { testError = e.message; }
|
|
2148
|
+
|
|
2149
|
+
let healthPass = null; let healthTotal = null; let healthError = null;
|
|
2150
|
+
try {
|
|
2151
|
+
const healthScript = join(hooksDir, 'health-check.mjs');
|
|
2152
|
+
if (existsSync(healthScript)) {
|
|
2153
|
+
const r = _spawnSync('node', [healthScript], { cwd, encoding: 'utf8', timeout: 15000 });
|
|
2154
|
+
const out = (r.stdout ?? '') + (r.stderr ?? '');
|
|
2155
|
+
// Try summary line first: "8 pass, 0 warn, 0 fail"
|
|
2156
|
+
const sm = out.match(/(\d+) pass,\s*(\d+) warn,\s*(\d+) fail/);
|
|
2157
|
+
if (sm) {
|
|
2158
|
+
healthPass = parseInt(sm[1], 10);
|
|
2159
|
+
healthTotal = parseInt(sm[1], 10) + parseInt(sm[2], 10) + parseInt(sm[3], 10);
|
|
2160
|
+
} else {
|
|
2161
|
+
// Fall back to JSON block
|
|
2162
|
+
const jm = out.match(/\{[\s\S]*?"healthy"[\s\S]*?\}/);
|
|
2163
|
+
if (jm) {
|
|
2164
|
+
try {
|
|
2165
|
+
const p = JSON.parse(jm[0]);
|
|
2166
|
+
healthPass = p.pass ?? 0;
|
|
2167
|
+
healthTotal = (p.pass ?? 0) + (p.warn ?? 0) + (p.fail ?? 0);
|
|
2168
|
+
} catch { healthError = 'could not parse output'; }
|
|
2169
|
+
} else { healthError = 'could not parse output'; }
|
|
2170
|
+
}
|
|
2171
|
+
} else { healthError = 'health-check.mjs not found'; }
|
|
2172
|
+
} catch (e) { healthError = e.message; }
|
|
2173
|
+
|
|
2174
|
+
// ── Render ────────────────────────────────────────────────────────────────
|
|
2175
|
+
const W = 56;
|
|
2176
|
+
const hbar = '═'.repeat(W);
|
|
2177
|
+
const padRow = (s) => {
|
|
2178
|
+
const plain = s.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
|
|
2179
|
+
let vlen = 0;
|
|
2180
|
+
for (const ch of plain) {
|
|
2181
|
+
const cp = ch.codePointAt(0);
|
|
2182
|
+
if ((cp >= 0x1f300 && cp <= 0x1faff) || (cp >= 0x2600 && cp <= 0x27bf) || cp === 0xfe0f || cp === 0x20e3) vlen += 2;
|
|
2183
|
+
else vlen += 1;
|
|
2184
|
+
}
|
|
2185
|
+
return s + ' '.repeat(Math.max(0, W - vlen));
|
|
2186
|
+
};
|
|
2187
|
+
const hrow = (s) => `║${padRow(' ' + s)}║`;
|
|
2188
|
+
|
|
2189
|
+
const output = [
|
|
2190
|
+
`╔${hbar}╗`,
|
|
2191
|
+
hrow('Diagnostics'),
|
|
2192
|
+
`╠${hbar}╣`,
|
|
2193
|
+
hrow(`dual-brain v${version}`),
|
|
2194
|
+
hrow(`Node.js ${nodeVersion}`),
|
|
2195
|
+
`╚${hbar}╝`,
|
|
2196
|
+
'',
|
|
2197
|
+
separator('Provider Status'),
|
|
2198
|
+
` Claude: ${claudeHealthBadge.padEnd(14)} ${claudePlanStr}`,
|
|
2199
|
+
` OpenAI: ${openaiHealthBadge.padEnd(14)} ${openaiPlanStr}`,
|
|
2200
|
+
'',
|
|
2201
|
+
separator('Enforcement'),
|
|
2202
|
+
` ${headGuardExists ? 'ok' : 'MISSING'} head-guard.mjs ${headGuardExists ? 'installed' : 'run: dual-brain install'}`,
|
|
2203
|
+
` ${enforceTierExists ? 'ok' : 'MISSING'} enforce-tier.mjs ${enforceTierExists ? 'installed' : 'run: dual-brain install'}`,
|
|
2204
|
+
` ${guardCount === 4 ? 'ok' : 'PARTIAL'} settings.json ${guardCount}/4 guards registered${guardCount < 4 ? ' — run: dual-brain install' : ''}`,
|
|
2205
|
+
` ${hookifyCount > 0 ? 'ok' : 'WARN '} hookify rules ${hookifyCount} rules${hookifyCount > 0 ? '' : ' — none found'}`,
|
|
2206
|
+
'',
|
|
2207
|
+
separator('Replit Tools'),
|
|
2208
|
+
` ${hasReplitTools ? 'ok' : 'n/a'} replit-tools ${hasReplitTools ? 'detected' : 'not detected'}`,
|
|
2209
|
+
];
|
|
2210
|
+
|
|
2211
|
+
if (hasReplitTools) {
|
|
2212
|
+
if (credsFresh === null) {
|
|
2213
|
+
output.push(' WARN Claude auth credentials file missing');
|
|
2214
|
+
} else if (credsFresh) {
|
|
2215
|
+
output.push(` ok Claude auth fresh (expires: ${credsExpiry})`);
|
|
2216
|
+
} else {
|
|
2217
|
+
output.push(` ERROR Claude auth expired (${credsExpiry}) — run [r] Refresh auth`);
|
|
2218
|
+
}
|
|
2219
|
+
output.push(` ok Session archive ${historyCount} entries`);
|
|
2220
|
+
output.push(` ${sessionManagerExists ? 'ok' : 'WARN '} Session manager ${sessionManagerExists ? 'available' : 'not found'}`);
|
|
2221
|
+
} else {
|
|
2222
|
+
output.push(' ─── (not available)');
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
output.push('');
|
|
2226
|
+
output.push(separator('Quality'));
|
|
2227
|
+
if (testError) {
|
|
2228
|
+
output.push(` ERROR Tests error: ${testError}`);
|
|
2229
|
+
} else if (testPass !== null) {
|
|
2230
|
+
output.push(` ${testPass === testTotal ? 'ok ' : 'FAIL '} Tests ${testPass}/${testTotal} passing`);
|
|
2231
|
+
}
|
|
2232
|
+
if (healthError) {
|
|
2233
|
+
output.push(` ERROR Health check error: ${healthError}`);
|
|
2234
|
+
} else if (healthPass !== null) {
|
|
2235
|
+
output.push(` ${healthPass === healthTotal ? 'ok ' : 'WARN '} Health check ${healthPass}/${healthTotal} passing`);
|
|
2236
|
+
}
|
|
2237
|
+
output.push('');
|
|
2238
|
+
|
|
2239
|
+
console.log(output.join('\n'));
|
|
2240
|
+
|
|
2241
|
+
// Actions menu
|
|
2242
|
+
const menuOpts = [
|
|
2243
|
+
{ key: 'h', label: 'Run health check', section: 'Actions' },
|
|
2244
|
+
{ key: 't', label: 'Run test suite', section: 'Actions' },
|
|
2245
|
+
];
|
|
2246
|
+
if (hasReplitTools && existsSync(authRefreshScript)) {
|
|
2247
|
+
menuOpts.push({ key: 'r', label: 'Refresh auth (replit-tools)', section: 'Actions' });
|
|
2248
|
+
}
|
|
2249
|
+
menuOpts.push({ key: 'i', label: 'Reinstall hooks', section: 'Actions' });
|
|
2250
|
+
menuOpts.push({ key: 'b', label: 'Back to dashboard', section: 'Actions' });
|
|
2251
|
+
console.log(menu(menuOpts));
|
|
2252
|
+
console.log('');
|
|
2253
|
+
|
|
2254
|
+
const choice = (await ask(' Choice: ')).trim().toLowerCase();
|
|
2255
|
+
|
|
2256
|
+
if (choice === 'h') {
|
|
2257
|
+
const hookScript = join(hooksDir, 'health-check.mjs');
|
|
2258
|
+
console.log('');
|
|
2259
|
+
if (existsSync(hookScript)) {
|
|
2260
|
+
try {
|
|
2261
|
+
const r = _spawnSync('node', [hookScript], { stdio: 'inherit', cwd });
|
|
2262
|
+
if (r.error) console.log(` Error: ${r.error.message}`);
|
|
2263
|
+
} catch (e) { console.log(` Error: ${e.message}`); }
|
|
2264
|
+
} else {
|
|
2265
|
+
console.log(' health-check.mjs not found — run: dual-brain install');
|
|
2266
|
+
}
|
|
2267
|
+
await ask('\n Press Enter to continue...');
|
|
2268
|
+
return { next: 'diagnostics' };
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
if (choice === 't') {
|
|
2272
|
+
console.log('\n Running test suite...\n');
|
|
2273
|
+
try {
|
|
2274
|
+
const r = _spawnSync('node', ['--test', 'src/test.mjs'], { stdio: 'inherit', cwd, timeout: 60000 });
|
|
2275
|
+
if (r.error) console.log(` Error: ${r.error.message}`);
|
|
2276
|
+
} catch (e) { console.log(` Error: ${e.message}`); }
|
|
2277
|
+
await ask('\n Press Enter to continue...');
|
|
2278
|
+
return { next: 'diagnostics' };
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
if (choice === 'r') {
|
|
2282
|
+
if (existsSync(authRefreshScript)) {
|
|
2283
|
+
console.log('\n Refreshing Claude auth...\n');
|
|
2284
|
+
try {
|
|
2285
|
+
const r = _spawnSync('bash', [authRefreshScript], { stdio: 'inherit', cwd, timeout: 30000 });
|
|
2286
|
+
if (r.error) console.log(` Error: ${r.error.message}`);
|
|
2287
|
+
else if (r.status === 0) console.log('\n Auth refresh complete.');
|
|
2288
|
+
else console.log(`\n Auth refresh exited with code ${r.status}.`);
|
|
2289
|
+
} catch (e) { console.log(` Error: ${e.message}`); }
|
|
2290
|
+
} else {
|
|
2291
|
+
console.log(' claude-auth-refresh.sh not found.');
|
|
2292
|
+
}
|
|
2293
|
+
await ask('\n Press Enter to continue...');
|
|
2294
|
+
return { next: 'diagnostics' };
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
if (choice === 'i') {
|
|
2298
|
+
await cmdInstall();
|
|
2299
|
+
return { next: 'diagnostics' };
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
if (choice === 'b' || choice === 'back') { return { next: 'dashboard' }; }
|
|
2303
|
+
|
|
2304
|
+
return { next: 'diagnostics' };
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
// ─── Screen: replScreen ───────────────────────────────────────────────────────
|
|
2308
|
+
|
|
2309
|
+
async function replScreen(rl, ask) {
|
|
2310
|
+
console.log('\nCommand mode. Type a task or command. "help" for commands, "back" to return.\n');
|
|
2311
|
+
|
|
2312
|
+
while (true) {
|
|
2313
|
+
const input = (await ask('dual-brain> ')).trim();
|
|
2314
|
+
const line = input;
|
|
2315
|
+
|
|
2316
|
+
if (!line) continue;
|
|
2317
|
+
|
|
2318
|
+
if (line === 'back' || line === 'exit' || line === 'quit' || line === 'q') {
|
|
2319
|
+
return { next: 'dashboard' };
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
try {
|
|
2323
|
+
if (line === 'help') {
|
|
2324
|
+
printHelp();
|
|
2325
|
+
} else if (line === 'status') {
|
|
2326
|
+
await cmdStatus([]);
|
|
2327
|
+
} else if (line === 'auth') {
|
|
2328
|
+
await cmdAuth([]);
|
|
2329
|
+
} else if (line.startsWith('go ')) {
|
|
2330
|
+
await cmdGo(line.slice(3).trim().split(/\s+/));
|
|
2331
|
+
} else if (line.startsWith('remember ')) {
|
|
2332
|
+
cmdRemember(line.slice(9).trim());
|
|
2333
|
+
} else if (line.startsWith('forget ')) {
|
|
2334
|
+
cmdForget(line.slice(7).trim());
|
|
2335
|
+
} else if (line.startsWith('hot ')) {
|
|
2336
|
+
cmdHot(line.slice(4).trim());
|
|
2337
|
+
} else if (line.startsWith('cool ')) {
|
|
2338
|
+
cmdCool(line.slice(5).trim());
|
|
2339
|
+
} else if (line === 'init') {
|
|
2340
|
+
await cmdInit(rl);
|
|
2341
|
+
} else if (line === 'dashboard') {
|
|
2342
|
+
return { next: 'dashboard' };
|
|
2343
|
+
} else {
|
|
2344
|
+
// Treat as a task description → go
|
|
2345
|
+
await cmdGo([line]);
|
|
2346
|
+
}
|
|
2347
|
+
} catch (e) {
|
|
2348
|
+
process.stderr.write(`Error: ${e.message}\n`);
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
// ─── Screen: sessionDetailScreen ─────────────────────────────────────────────
|
|
2354
|
+
|
|
2355
|
+
async function sessionDetailScreen(rl, ask, ctx = {}) {
|
|
2356
|
+
const sess = ctx.session;
|
|
2357
|
+
if (!sess) return { next: 'dashboard' };
|
|
2358
|
+
|
|
2359
|
+
const W = 56;
|
|
2360
|
+
const hbar = '═'.repeat(W + 2);
|
|
2361
|
+
const pad = (s) => {
|
|
2362
|
+
const plain = s.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
|
|
2363
|
+
return s + ' '.repeat(Math.max(0, W - plain.length));
|
|
2364
|
+
};
|
|
2365
|
+
|
|
2366
|
+
const statusLine = sess.isActive
|
|
2367
|
+
? `active`
|
|
2368
|
+
: `inactive`;
|
|
2369
|
+
|
|
2370
|
+
const detailLines = [
|
|
2371
|
+
` Session: ${sess.name}`,
|
|
2372
|
+
`╠${hbar}╣`,
|
|
2373
|
+
` ID: ${sess.id.slice(0, 8)}...`,
|
|
2374
|
+
` Status: ${statusLine}`,
|
|
2375
|
+
` Prompts: ${sess.promptCount}`,
|
|
2376
|
+
` Last active: ${sess.age}`,
|
|
2377
|
+
` Project: ${sess.project || process.cwd()}`,
|
|
2378
|
+
];
|
|
2379
|
+
|
|
2380
|
+
console.log(`╔${hbar}╗`);
|
|
2381
|
+
for (const line of detailLines) {
|
|
2382
|
+
console.log(`║ ${pad(line)}║`);
|
|
2383
|
+
}
|
|
2384
|
+
console.log(`╚${hbar}╝`);
|
|
2385
|
+
console.log('');
|
|
2386
|
+
|
|
2387
|
+
if (sess.isActive) {
|
|
2388
|
+
console.log(' [c] Continue this session (claude --continue)');
|
|
2389
|
+
} else {
|
|
2390
|
+
console.log(' [r] Resume this session (claude --resume)');
|
|
2391
|
+
}
|
|
2392
|
+
console.log(' [b] Back to dashboard');
|
|
2393
|
+
console.log('');
|
|
2394
|
+
|
|
2395
|
+
const choice = (await ask(' Choice: ')).trim().toLowerCase();
|
|
2396
|
+
|
|
2397
|
+
if (choice === 'c' || choice === 'r') {
|
|
2398
|
+
console.log(`\n Launching: claude --resume ${sess.id}\n`);
|
|
2399
|
+
try {
|
|
2400
|
+
const { spawnSync } = await import('node:child_process');
|
|
2401
|
+
spawnSync('claude', ['--resume', sess.id], { stdio: 'inherit' });
|
|
2402
|
+
} catch {
|
|
2403
|
+
console.log(' Could not launch claude CLI. Run manually:');
|
|
2404
|
+
console.log(` claude --resume ${sess.id}`);
|
|
2405
|
+
}
|
|
2406
|
+
return { next: 'dashboard' };
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
return { next: 'dashboard' };
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
// ─── Screen: sessionsScreen ───────────────────────────────────────────────────
|
|
2413
|
+
|
|
2414
|
+
const CATEGORIES = ['security', 'ui', 'refactor', 'bugfix', 'testing', 'devops', 'planning'];
|
|
2415
|
+
|
|
2416
|
+
async function sessionsScreen(rl, ask) {
|
|
2417
|
+
const cwd = process.cwd();
|
|
2418
|
+
const sessions = enrichSessions(importReplitSessions(cwd), cwd).slice(0, 9);
|
|
2419
|
+
|
|
2420
|
+
console.log('');
|
|
2421
|
+
console.log(separator('Session Manager'));
|
|
2422
|
+
console.log('');
|
|
2423
|
+
|
|
2424
|
+
if (sessions.length === 0) {
|
|
2425
|
+
console.log(' No sessions found.\n');
|
|
2426
|
+
console.log(' [b] Back\n');
|
|
2427
|
+
const choice = (await ask(' Choice: ')).trim().toLowerCase();
|
|
2428
|
+
if (choice === 'b' || choice === 'back') return { next: 'main' };
|
|
2429
|
+
return { next: 'sessions' };
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
sessions.forEach((sess, i) => {
|
|
2433
|
+
const pin = sess.pinned ? '📌 ' : ' ';
|
|
2434
|
+
const active = sess.isActive ? ' ●' : '';
|
|
2435
|
+
const cat = sess.category ? ` [${sess.category}]` : '';
|
|
2436
|
+
console.log(` [${i + 1}] ${pin}${sess.age.padEnd(6)} ${sess.name}${active}${cat}`);
|
|
2437
|
+
});
|
|
2438
|
+
|
|
2439
|
+
console.log('');
|
|
2440
|
+
console.log(' [1-9] Select a session to manage');
|
|
2441
|
+
console.log(' [b] Back');
|
|
2442
|
+
console.log('');
|
|
2443
|
+
|
|
2444
|
+
const choice = (await ask(' Choice: ')).trim().toLowerCase();
|
|
2445
|
+
|
|
2446
|
+
if (choice === 'b' || choice === 'back') return { next: 'main' };
|
|
2447
|
+
|
|
2448
|
+
const numChoice = parseInt(choice, 10);
|
|
2449
|
+
if (!isNaN(numChoice) && numChoice >= 1 && numChoice <= sessions.length) {
|
|
2450
|
+
return { next: 'session-manage', session: sessions[numChoice - 1] };
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
return { next: 'sessions' };
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
async function sessionManageScreen(rl, ask, ctx = {}) {
|
|
2457
|
+
const sess = ctx.session;
|
|
2458
|
+
if (!sess) return { next: 'sessions' };
|
|
2459
|
+
|
|
2460
|
+
const cwd = process.cwd();
|
|
2461
|
+
const pinLabel = sess.pinned ? 'Unpin' : 'Pin';
|
|
2462
|
+
const catLabel = sess.category ? `[${sess.category}]` : '(none)';
|
|
2463
|
+
|
|
2464
|
+
console.log('');
|
|
2465
|
+
console.log(separator(`Session: ${sess.name}`));
|
|
2466
|
+
console.log('');
|
|
2467
|
+
console.log(` Age: ${sess.age}`);
|
|
2468
|
+
console.log(` Category: ${catLabel}`);
|
|
2469
|
+
console.log(` Pinned: ${sess.pinned ? 'yes' : 'no'}`);
|
|
2470
|
+
console.log('');
|
|
2471
|
+
console.log(menu([
|
|
2472
|
+
{ key: 'r', label: 'Rename', section: '' },
|
|
2473
|
+
{ key: 'p', label: pinLabel, section: '' },
|
|
2474
|
+
{ key: 'c', label: 'Set category', section: '' },
|
|
2475
|
+
{ key: 'o', label: 'Open (resume)', section: '' },
|
|
2476
|
+
{ key: 'b', label: 'Back', section: '' },
|
|
2477
|
+
]));
|
|
2478
|
+
console.log('');
|
|
2479
|
+
|
|
2480
|
+
const choice = (await ask(' Choice: ')).trim().toLowerCase();
|
|
2481
|
+
|
|
2482
|
+
if (choice === 'r') {
|
|
2483
|
+
const name = (await ask(' New name: ')).trim();
|
|
2484
|
+
if (name) {
|
|
2485
|
+
renameSession(sess.id, name, cwd);
|
|
2486
|
+
console.log(` Renamed to: ${name}`);
|
|
2487
|
+
}
|
|
2488
|
+
return { next: 'session-manage', session: { ...sess, name: name || sess.name } };
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
if (choice === 'p') {
|
|
2492
|
+
if (sess.pinned) {
|
|
2493
|
+
unpinSession(sess.id, cwd);
|
|
2494
|
+
console.log(' Unpinned.');
|
|
2495
|
+
return { next: 'session-manage', session: { ...sess, pinned: false } };
|
|
2496
|
+
} else {
|
|
2497
|
+
pinSession(sess.id, cwd);
|
|
2498
|
+
console.log(' Pinned.');
|
|
2499
|
+
return { next: 'session-manage', session: { ...sess, pinned: true } };
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
if (choice === 'c') {
|
|
2504
|
+
console.log('');
|
|
2505
|
+
CATEGORIES.forEach((cat, i) => console.log(` (${i + 1}) ${cat}`));
|
|
2506
|
+
console.log(` (${CATEGORIES.length + 1}) custom`);
|
|
2507
|
+
console.log('');
|
|
2508
|
+
const catChoice = (await ask(' Category: ')).trim();
|
|
2509
|
+
const catIndex = parseInt(catChoice, 10);
|
|
2510
|
+
let category = null;
|
|
2511
|
+
if (!isNaN(catIndex) && catIndex >= 1 && catIndex <= CATEGORIES.length) {
|
|
2512
|
+
category = CATEGORIES[catIndex - 1];
|
|
2513
|
+
} else if (catIndex === CATEGORIES.length + 1) {
|
|
2514
|
+
category = (await ask(' Custom category: ')).trim() || null;
|
|
2515
|
+
} else if (catChoice) {
|
|
2516
|
+
category = catChoice;
|
|
2517
|
+
}
|
|
2518
|
+
if (category) {
|
|
2519
|
+
categorizeSession(sess.id, category, cwd);
|
|
2520
|
+
console.log(` Category set to: ${category}`);
|
|
2521
|
+
}
|
|
2522
|
+
return { next: 'session-manage', session: { ...sess, category: category ?? sess.category } };
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
if (choice === 'o') {
|
|
2526
|
+
const { spawnSync } = await import('node:child_process');
|
|
2527
|
+
console.log(`\n Launching: claude --resume ${sess.id}\n`);
|
|
2528
|
+
spawnSync('claude', ['--resume', sess.id], { stdio: 'inherit' });
|
|
2529
|
+
return { next: 'sessions' };
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
if (choice === 'b' || choice === 'back') return { next: 'sessions' };
|
|
2533
|
+
|
|
2534
|
+
return { next: 'session-manage', session: sess };
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
// ─── Screen state machine ─────────────────────────────────────────────────────
|
|
2538
|
+
|
|
2539
|
+
const SCREENS = {
|
|
2540
|
+
welcome: welcomeScreen,
|
|
2541
|
+
main: mainScreen,
|
|
2542
|
+
'new-session': newSessionScreen,
|
|
2543
|
+
settings: settingsScreen,
|
|
2544
|
+
subscriptions: subscriptionsScreen,
|
|
2545
|
+
dashboard: dashboardScreen,
|
|
2546
|
+
auth: authScreen,
|
|
2547
|
+
profile: profileScreen,
|
|
2548
|
+
diagnostics: diagnosticsScreen,
|
|
2549
|
+
repl: replScreen,
|
|
2550
|
+
'session-detail': sessionDetailScreen,
|
|
2551
|
+
sessions: sessionsScreen,
|
|
2552
|
+
'session-manage': sessionManageScreen,
|
|
2553
|
+
};
|
|
2554
|
+
|
|
2555
|
+
async function runScreens(startScreen = 'dashboard') {
|
|
2556
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2557
|
+
const ask = (q) => new Promise(res => rl.question(q, res));
|
|
2558
|
+
|
|
2559
|
+
let current = startScreen;
|
|
2560
|
+
let ctx = {};
|
|
2561
|
+
while (current && current !== 'exit') {
|
|
2562
|
+
const screen = SCREENS[current];
|
|
2563
|
+
if (!screen) break;
|
|
2564
|
+
try {
|
|
2565
|
+
const result = await screen(rl, ask, ctx);
|
|
2566
|
+
current = result?.next || 'exit';
|
|
2567
|
+
// Pass through context (e.g. selected session) to next screen
|
|
2568
|
+
ctx = result?.session ? { session: result.session } : {};
|
|
2569
|
+
} catch (e) {
|
|
2570
|
+
console.error(`Error: ${e.message}`);
|
|
2571
|
+
current = 'main';
|
|
2572
|
+
ctx = {};
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
rl.close();
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
// ─── Specialist commands ──────────────────────────────────────────────────────
|
|
2579
|
+
|
|
2580
|
+
const SPECIALIST_DEFAULTS = {
|
|
2581
|
+
python: { name: 'Python', description: 'Python stdlib, typing, async' },
|
|
2582
|
+
typescript: { name: 'TypeScript', description: 'TS type system, React, Node' },
|
|
2583
|
+
html: { name: 'HTML/CSS', description: 'Semantic HTML, CSS, accessibility' },
|
|
2584
|
+
linux: { name: 'Linux/DevOps', description: 'Sysadmin, Docker, nginx, shell' },
|
|
2585
|
+
security: { name: 'Security', description: 'Auth, crypto, OWASP, threat model' },
|
|
2586
|
+
};
|
|
2587
|
+
|
|
2588
|
+
function loadSpecialistRegistry() {
|
|
2589
|
+
const regPath = join(__dirname, '..', 'agents', 'specialists', 'registry.json');
|
|
2590
|
+
try {
|
|
2591
|
+
const raw = JSON.parse(readFileSync(regPath, 'utf8'));
|
|
2592
|
+
const out = {};
|
|
2593
|
+
for (const [key, val] of Object.entries(raw.specialists || {})) {
|
|
2594
|
+
out[key] = { name: val.name || key, description: val.description || '' };
|
|
2595
|
+
}
|
|
2596
|
+
return out;
|
|
2597
|
+
} catch {
|
|
2598
|
+
return SPECIALIST_DEFAULTS;
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
function cmdSpecialists() {
|
|
2603
|
+
const registry = loadSpecialistRegistry();
|
|
2604
|
+
const entries = Object.entries(registry);
|
|
2605
|
+
|
|
2606
|
+
// Build padded rows
|
|
2607
|
+
const rows = entries.map(([key, val]) => {
|
|
2608
|
+
const k = key.padEnd(12);
|
|
2609
|
+
const d = val.description;
|
|
2610
|
+
return `│ ${k}${d}`;
|
|
2611
|
+
});
|
|
2612
|
+
|
|
2613
|
+
// Find longest row for width
|
|
2614
|
+
const inner = Math.max(
|
|
2615
|
+
...rows.map(r => r.length),
|
|
2616
|
+
'│ Usage: dual-brain python "task description" │'.length - 2,
|
|
2617
|
+
);
|
|
2618
|
+
const width = inner + 1; // account for trailing │
|
|
2619
|
+
|
|
2620
|
+
function pad(str) {
|
|
2621
|
+
return str + ' '.repeat(Math.max(0, width - str.length - 1)) + '│';
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
const top = '┌' + '─'.repeat(width - 1) + '┐';
|
|
2625
|
+
const title = pad('│ 🎯 Available Specialists');
|
|
2626
|
+
const divTop = '├' + '─'.repeat(width - 1) + '┤';
|
|
2627
|
+
const divBot = '├' + '─'.repeat(width - 1) + '┤';
|
|
2628
|
+
const bot = '└' + '─'.repeat(width - 1) + '┘';
|
|
2629
|
+
|
|
2630
|
+
console.log(top);
|
|
2631
|
+
console.log(title);
|
|
2632
|
+
console.log(divTop);
|
|
2633
|
+
for (const row of rows) console.log(pad(row));
|
|
2634
|
+
console.log(divBot);
|
|
2635
|
+
console.log(pad('│ Usage: dual-brain python "task description"'));
|
|
2636
|
+
console.log(pad('│ Auto-routing: off (use dual-brain go for auto)'));
|
|
2637
|
+
console.log(bot);
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
async function cmdSpecialistGo(specialist, args) {
|
|
2641
|
+
const dryRun = args.includes('--dry-run');
|
|
2642
|
+
const verbose = args.includes('--verbose') || args.includes('-v');
|
|
2643
|
+
const filesRaw = flag(args, '--files');
|
|
2644
|
+
const files = filesRaw && typeof filesRaw === 'string'
|
|
2645
|
+
? filesRaw.split(',').map(f => f.trim()).filter(Boolean)
|
|
2646
|
+
: [];
|
|
2647
|
+
|
|
2648
|
+
const prompt = args.find(a => !a.startsWith('--') && !a.startsWith('-') && a !== (filesRaw ?? ''));
|
|
2649
|
+
if (!prompt) err(`Usage: dual-brain ${specialist} "task description" [--dry-run] [--files a,b]`);
|
|
2650
|
+
|
|
2651
|
+
const cwd = process.cwd();
|
|
2652
|
+
const profile = await ensureProfile(cwd);
|
|
2653
|
+
const detection = detectTask({ prompt, files });
|
|
2654
|
+
|
|
2655
|
+
// Override specialist, preserve everything else
|
|
2656
|
+
detection.specialist = specialist;
|
|
2657
|
+
|
|
2658
|
+
console.log(`[specialist: ${specialist}] ${detection.explanation}`);
|
|
2659
|
+
|
|
2660
|
+
if (verbose) {
|
|
2661
|
+
vtrace(`Intent: ${detection.intent} | Risk: ${detection.risk} | Complexity: ${detection.complexity} | Effort: ${detection.effort ?? 'n/a'}`);
|
|
2662
|
+
vtrace(`Tier: ${detection.tier} | Specialist override: ${specialist}`);
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
const decision = decideRoute({ profile, detection, cwd });
|
|
2666
|
+
|
|
2667
|
+
if (verbose) {
|
|
2668
|
+
const modelLabel = decision.effort ? `${decision.model} (${decision.effort})` : decision.model;
|
|
2669
|
+
vtrace(`Model selection: ${modelLabel}`);
|
|
2670
|
+
vtrace(`Dual-brain: ${decision.dualBrain ? 'yes' : 'no'}`);
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
console.log(` specialist : ${specialist}`);
|
|
2674
|
+
console.log(` provider : ${decision.provider}`);
|
|
2675
|
+
console.log(` model : ${decision.model}${decision.effort ? ' (' + decision.effort + ')' : ''}`);
|
|
2676
|
+
console.log(` tier : ${decision.tier}`);
|
|
2677
|
+
console.log(` dual-brain : ${decision.dualBrain ? 'yes' : 'no'}`);
|
|
2678
|
+
console.log(` reason : ${decision.explanation}`);
|
|
2679
|
+
|
|
2680
|
+
if (dryRun) {
|
|
2681
|
+
console.log('\n(dry-run — not executing)');
|
|
2682
|
+
return;
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
console.log('\nDispatching...');
|
|
2686
|
+
let result;
|
|
2687
|
+
if (decision.dualBrain) {
|
|
2688
|
+
result = await dispatchDualBrain({ decision, prompt, files, cwd });
|
|
2689
|
+
console.log(`\nConsensus: ${result.consensus}`);
|
|
2690
|
+
if (result.claude?.summary) console.log(`Claude : ${result.claude.summary}`);
|
|
2691
|
+
if (result.openai?.summary) console.log(`OpenAI : ${result.openai.summary}`);
|
|
2692
|
+
saveSession({
|
|
2693
|
+
objective: prompt,
|
|
2694
|
+
branch: null,
|
|
2695
|
+
filesChanged: files,
|
|
2696
|
+
commandsRun: [`dual-brain ${specialist} "${prompt}"`],
|
|
2697
|
+
lastResult: { status: 'success', summary: result.consensus || 'dual-brain complete' },
|
|
2698
|
+
provider: decision.provider,
|
|
2699
|
+
nextAction: null,
|
|
2700
|
+
}, cwd);
|
|
2701
|
+
} else {
|
|
2702
|
+
result = await dispatch({ decision, prompt, files, cwd });
|
|
2703
|
+
const statusLine = result.status === 'completed' ? 'Done' : `Failed (exit ${result.exitCode})`;
|
|
2704
|
+
console.log(`\n${statusLine} in ${(result.durationMs / 1000).toFixed(1)}s`);
|
|
2705
|
+
if (result.summary) console.log(result.summary);
|
|
2706
|
+
if (result.error) process.stderr.write(`${result.error}\n`);
|
|
2707
|
+
saveSession({
|
|
2708
|
+
objective: prompt,
|
|
2709
|
+
branch: null,
|
|
2710
|
+
filesChanged: files,
|
|
2711
|
+
commandsRun: [`dual-brain ${specialist} "${prompt}"`],
|
|
2712
|
+
lastResult: {
|
|
2713
|
+
status: result.status === 'completed' ? 'success' : 'failure',
|
|
2714
|
+
summary: result.summary || (result.status === 'completed' ? 'completed' : `exit ${result.exitCode}`),
|
|
2715
|
+
},
|
|
2716
|
+
provider: decision.provider,
|
|
2717
|
+
nextAction: null,
|
|
2718
|
+
}, cwd);
|
|
2719
|
+
if (result.status !== 'completed') process.exit(1);
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
// ─── Entry point ─────────────────────────────────────────────────────────────
|
|
2724
|
+
|
|
2725
|
+
async function main() {
|
|
2726
|
+
const args = process.argv.slice(2);
|
|
2727
|
+
const cmd = args[0];
|
|
2728
|
+
|
|
2729
|
+
if (cmd === '--help' || cmd === '-h') { printHelp(); return; }
|
|
2730
|
+
if (cmd === '--version' || cmd === '-v') { console.log(readVersion()); return; }
|
|
2731
|
+
|
|
2732
|
+
// Interactive-only commands: enter screen state machine (only when TTY)
|
|
2733
|
+
const isInteractive = process.stdin.isTTY;
|
|
2734
|
+
|
|
2735
|
+
if (!cmd) {
|
|
2736
|
+
if (isInteractive) {
|
|
2737
|
+
const cwd = process.cwd();
|
|
2738
|
+
cleanStaleMarkers(cwd);
|
|
2739
|
+
if (!process.argv.includes('--force') && checkLoopMarker(cwd)) {
|
|
2740
|
+
process.exit(0);
|
|
2741
|
+
}
|
|
2742
|
+
setLoopMarker(cwd);
|
|
2743
|
+
if (profileExists(cwd)) {
|
|
2744
|
+
await runScreens('main');
|
|
2745
|
+
} else {
|
|
2746
|
+
// First run: run the 5-step onboarding wizard, then go to main.
|
|
2747
|
+
process.stdout.write(`\ndual-brain v${readVersion()} — Setup\n\nDetecting your setup...\n`);
|
|
2748
|
+
const auth = await detectAuth();
|
|
2749
|
+
const plans = detectPlans();
|
|
2750
|
+
const existingSessions = importReplitSessions(cwd);
|
|
2751
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2752
|
+
const wizardProfile = await runOnboardingWizard({ auth, plans, existingSessions }, cwd, rl);
|
|
2753
|
+
if (wizardProfile) {
|
|
2754
|
+
saveProfile(wizardProfile, { cwd });
|
|
2755
|
+
await cmdInstall(cwd);
|
|
2756
|
+
console.log('\n ✅ Setup complete! Starting dual-brain...\n');
|
|
2757
|
+
}
|
|
2758
|
+
rl.close();
|
|
2759
|
+
await runScreens('main');
|
|
2760
|
+
}
|
|
2761
|
+
} else {
|
|
2762
|
+
// Non-TTY: print status card and exit
|
|
2763
|
+
const cwd = process.cwd();
|
|
2764
|
+
const repo = loadRepoCache(cwd);
|
|
2765
|
+
const session = loadSession(cwd);
|
|
2766
|
+
const health = getHealth(cwd);
|
|
2767
|
+
const card = formatSessionCard(session, repo, health);
|
|
2768
|
+
console.log(card);
|
|
2769
|
+
}
|
|
2770
|
+
return;
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
if (cmd === 'init') {
|
|
2774
|
+
if (isInteractive) {
|
|
2775
|
+
// Run 5-step onboarding wizard then main screen
|
|
2776
|
+
const cwd = process.cwd();
|
|
2777
|
+
process.stdout.write(`\ndual-brain v${readVersion()} — Setup\n\nDetecting your setup...\n`);
|
|
2778
|
+
const auth = await detectAuth();
|
|
2779
|
+
const plans = detectPlans();
|
|
2780
|
+
const existingSessions = importReplitSessions(cwd);
|
|
2781
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2782
|
+
const wizardProfile = await runOnboardingWizard({ auth, plans, existingSessions }, cwd, rl);
|
|
2783
|
+
if (wizardProfile) {
|
|
2784
|
+
saveProfile(wizardProfile, { cwd });
|
|
2785
|
+
await cmdInstall(cwd);
|
|
2786
|
+
console.log('\n ✅ Setup complete! Starting dual-brain...\n');
|
|
2787
|
+
}
|
|
2788
|
+
rl.close();
|
|
2789
|
+
await runScreens('main');
|
|
2790
|
+
} else {
|
|
2791
|
+
await cmdInit();
|
|
2792
|
+
}
|
|
2793
|
+
return;
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
// One-shot commands — run and exit
|
|
2797
|
+
if (cmd === 'install') { await cmdInstall(); return; }
|
|
2798
|
+
if (cmd === 'auth') {
|
|
2799
|
+
await cmdAuth(args.slice(1));
|
|
2800
|
+
return;
|
|
2801
|
+
}
|
|
2802
|
+
if (cmd === 'go') { await cmdGo(args.slice(1)); return; }
|
|
2803
|
+
if (cmd === 'status') { await cmdStatus(args.slice(1)); return; }
|
|
2804
|
+
if (cmd === 'hot') { cmdHot(args[1]); return; }
|
|
2805
|
+
if (cmd === 'cool') { cmdCool(args[1]); return; }
|
|
2806
|
+
if (cmd === 'remember') { cmdRemember(args[1]); return; }
|
|
2807
|
+
if (cmd === 'forget') { cmdForget(args[1]); return; }
|
|
2808
|
+
if (cmd === 'break-glass') { cmdBreakGlass(args.slice(1).join(' ')); return; }
|
|
2809
|
+
|
|
2810
|
+
if (cmd === 'specialists') { cmdSpecialists(); return; }
|
|
2811
|
+
|
|
2812
|
+
const SPECIALIST_CMDS = new Set(Object.keys(loadSpecialistRegistry()));
|
|
2813
|
+
if (SPECIALIST_CMDS.has(cmd)) { await cmdSpecialistGo(cmd, args.slice(1)); return; }
|
|
2814
|
+
|
|
2815
|
+
if (cmd === 'search') {
|
|
2816
|
+
const query = args.slice(1).filter(a => !a.startsWith('--')).join(' ');
|
|
2817
|
+
if (!query) {
|
|
2818
|
+
console.log('Usage: dual-brain search "keyword"');
|
|
2819
|
+
process.exit(1);
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
const { searchSessions, buildSessionIndex } = await import('../src/session.mjs');
|
|
2823
|
+
const cwd = process.cwd();
|
|
2824
|
+
try { buildSessionIndex(cwd); } catch {}
|
|
2825
|
+
|
|
2826
|
+
const results = searchSessions(query, cwd);
|
|
2827
|
+
if (results.length === 0) {
|
|
2828
|
+
console.log(`No sessions matching "${query}"`);
|
|
2829
|
+
process.exit(0);
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
console.log(`Found ${results.length} session${results.length === 1 ? '' : 's'}:\n`);
|
|
2833
|
+
results.slice(0, 10).forEach((sess, i) => {
|
|
2834
|
+
const tool = sess.tool === 'codex' ? 'cdx' : 'cld';
|
|
2835
|
+
const date = sess.date ? new Date(sess.date).toLocaleDateString() : '?';
|
|
2836
|
+
console.log(` ${i + 1}. [${tool}] ${date} ${sess.prompts.first || sess.id.slice(0, 8)}`);
|
|
2837
|
+
if (sess.topics.length > 0) console.log(` topics: ${sess.topics.slice(0, 5).join(', ')}`);
|
|
2838
|
+
if (sess.files.length > 0) console.log(` files: ${sess.files.slice(0, 5).join(', ')}`);
|
|
2839
|
+
console.log(` id: ${sess.id}`);
|
|
2840
|
+
console.log('');
|
|
2841
|
+
});
|
|
2842
|
+
|
|
2843
|
+
process.exit(0);
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
if (cmd === 'shell-hook') {
|
|
2847
|
+
// Output a bash snippet users can add to their .bashrc or source directly.
|
|
2848
|
+
const hook = `
|
|
2849
|
+
# dual-brain shell integration
|
|
2850
|
+
# Source this file or add to .bashrc
|
|
2851
|
+
if command -v dual-brain &>/dev/null; then
|
|
2852
|
+
alias db='dual-brain'
|
|
2853
|
+
alias dbgo='dual-brain go'
|
|
2854
|
+
alias dbstat='dual-brain status'
|
|
2855
|
+
fi
|
|
2856
|
+
`.trim();
|
|
2857
|
+
console.log(hook);
|
|
2858
|
+
return;
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
process.stderr.write(`Unknown command: ${cmd}\nRun "dual-brain --help" for usage.\n`);
|
|
2862
|
+
process.exit(1);
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
main().catch(e => {
|
|
2866
|
+
process.stderr.write(`${e.message}\n`);
|
|
2867
|
+
process.exit(1);
|
|
2868
|
+
});
|