dual-brain 0.1.6 → 0.1.7

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.
@@ -368,22 +368,24 @@ async function cmdGo(args) {
368
368
  vtrace(`Dual-brain: ${decision.dualBrain ? 'yes' : 'no'} (${isSoloBrain(profile) ? 'solo provider' : 'dual provider'}, ${detection.risk} risk)`);
369
369
  }
370
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}`);
371
+ // Print routing table (only in dry-run or verbose; silent in normal mode)
372
+ if (dryRun || verbose) {
373
+ console.log(` provider : ${decision.provider}`);
374
+ console.log(` model : ${decision.model}${decision.effort ? ' (' + decision.effort + ')' : ''}`);
375
+ console.log(` tier : ${decision.tier}`);
376
+ console.log(` dual-brain : ${decision.dualBrain ? 'yes' : 'no'}`);
377
+ console.log(` reason : ${decision.explanation}`);
378
+ }
377
379
 
378
380
  if (dryRun) {
379
381
  console.log('\n(dry-run — not executing)');
380
382
  return;
381
383
  }
382
384
 
383
- console.log('\nDispatching...');
385
+ if (verbose) console.log('\nDispatching...');
384
386
  let result;
385
387
  if (decision.dualBrain) {
386
- result = await dispatchDualBrain({ decision, prompt, files, cwd });
388
+ result = await dispatchDualBrain({ decision, prompt, files, cwd, verbose });
387
389
  console.log(`\nConsensus: ${result.consensus}`);
388
390
  if (result.claude?.summary) console.log(`Claude : ${result.claude.summary}`);
389
391
  if (result.openai?.summary) console.log(`OpenAI : ${result.openai.summary}`);
@@ -398,7 +400,7 @@ async function cmdGo(args) {
398
400
  nextAction: null,
399
401
  }, cwd);
400
402
  } else {
401
- result = await dispatch({ decision, prompt, files, cwd });
403
+ result = await dispatch({ decision, prompt, files, cwd, verbose });
402
404
  const statusLine = result.status === 'completed' ? 'Done' : `Failed (exit ${result.exitCode})`;
403
405
  console.log(`\n${statusLine} in ${(result.durationMs / 1000).toFixed(1)}s`);
404
406
  if (result.summary) console.log(result.summary);
@@ -1000,6 +1002,176 @@ function loadTerminalState(cwd, terminalId) {
1000
1002
 
1001
1003
  // ─── Dashboard box helpers ────────────────────────────────────────────────────
1002
1004
 
1005
+ /**
1006
+ * Detect repo state for action cards. All checks run with tight timeouts —
1007
+ * best-effort only, never blocks startup.
1008
+ *
1009
+ * Returns: { dirtyCount, lastCommitAgeDays, lastFailure, isGitRepo }
1010
+ */
1011
+ function detectRepoState(cwd) {
1012
+ const result = { dirtyCount: 0, lastCommitAgeDays: 0, lastFailure: null, isGitRepo: false };
1013
+ try {
1014
+ execSync('git rev-parse --git-dir', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' });
1015
+ result.isGitRepo = true;
1016
+ } catch { return result; }
1017
+
1018
+ try {
1019
+ const status = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' });
1020
+ result.dirtyCount = status.trim().split('\n').filter(Boolean).length;
1021
+ } catch {}
1022
+
1023
+ try {
1024
+ const logOut = execSync('git log --format="%ct" -1', { cwd, encoding: 'utf8', timeout: 2000, stdio: 'pipe' }).trim();
1025
+ if (logOut) {
1026
+ const commitTs = parseInt(logOut, 10) * 1000;
1027
+ result.lastCommitAgeDays = Math.floor((Date.now() - commitTs) / 86400000);
1028
+ }
1029
+ } catch {}
1030
+
1031
+ try {
1032
+ const sessionPath = join(cwd, '.dualbrain', 'session.json');
1033
+ if (existsSync(sessionPath)) {
1034
+ const sess = JSON.parse(readFileSync(sessionPath, 'utf8'));
1035
+ const lastResult = sess?.lastResult;
1036
+ if (lastResult?.status === 'failure') {
1037
+ const summary = lastResult.task
1038
+ ? String(lastResult.task).slice(0, 40)
1039
+ : 'last task';
1040
+ result.lastFailure = summary;
1041
+ }
1042
+ }
1043
+ } catch {}
1044
+
1045
+ return result;
1046
+ }
1047
+
1048
+ /**
1049
+ * Build action card rows for the dashboard based on repo state.
1050
+ * Returns an array of box row strings (may be empty).
1051
+ */
1052
+ function buildActionRows(repoState, rowFn) {
1053
+ if (!repoState.isGitRepo) return [];
1054
+
1055
+ const YELLOW = '\x1b[33m';
1056
+ const RED = '\x1b[31m';
1057
+ const GREEN = '\x1b[32m';
1058
+ const DIM = '\x1b[2m';
1059
+ const RESET = '\x1b[0m';
1060
+
1061
+ const cards = [];
1062
+
1063
+ if (repoState.dirtyCount > 0) {
1064
+ cards.push(`${YELLOW}⚡${RESET} ${repoState.dirtyCount} uncommitted file${repoState.dirtyCount === 1 ? '' : 's'}`);
1065
+ }
1066
+
1067
+ if (repoState.lastFailure !== null) {
1068
+ cards.push(`${RED}⚡${RESET} Last task failed: ${repoState.lastFailure}`);
1069
+ }
1070
+
1071
+ if (repoState.lastCommitAgeDays >= 3) {
1072
+ cards.push(`${YELLOW}⚡${RESET} ${repoState.lastCommitAgeDays} day${repoState.lastCommitAgeDays === 1 ? '' : 's'} since last commit`);
1073
+ }
1074
+
1075
+ if (cards.length === 0) {
1076
+ return [rowFn(`${DIM}${GREEN}✓${RESET}${DIM} Repo clean${RESET}`)];
1077
+ }
1078
+
1079
+ return cards.map(c => rowFn(c));
1080
+ }
1081
+
1082
+ /**
1083
+ * Detect interrupted work from the most recent session.
1084
+ * Returns a continuation hint if confidence is high enough, or null to skip.
1085
+ *
1086
+ * Signals that indicate interrupted work:
1087
+ * - Session < 4 hours old with no clean exit
1088
+ * - Last result was a failure
1089
+ * - Uncommitted git changes exist
1090
+ * - Session has high message count (user was deep in work)
1091
+ *
1092
+ * Minimum thresholds: messageCount > 5 OR filesChanged > 0
1093
+ *
1094
+ * @param {Array} sessions — from importReplitSessions / enrichSessions
1095
+ * @param {string} cwd
1096
+ * @returns {{ shouldContinue: boolean, reason: string, sessionId: string, sessionName: string, lastState: string|null, ageLabel: string }|null}
1097
+ */
1098
+ function detectInterruptedWork(sessions, cwd) {
1099
+ if (!sessions || sessions.length === 0) return null;
1100
+
1101
+ const most = sessions[0]; // already sorted most-recent first
1102
+ if (!most || !most.lastActive) return null;
1103
+
1104
+ const ageMs = Date.now() - new Date(most.lastActive).getTime();
1105
+ const fourH = 4 * 60 * 60 * 1000;
1106
+
1107
+ // Must be within 4 hours
1108
+ if (ageMs >= fourH) return null;
1109
+
1110
+ // Load session.json for deeper signal
1111
+ const session = loadSession(cwd);
1112
+
1113
+ // Minimum thresholds: must have real work depth
1114
+ const msgCount = most.messageCount ?? most.promptCount ?? 0;
1115
+ const filesChanged = session?.filesChanged?.length ?? 0;
1116
+ if (msgCount <= 5 && filesChanged === 0) return null;
1117
+
1118
+ const lastResultStatus = session?.lastResult?.status ?? null;
1119
+
1120
+ // Build confidence signals
1121
+ const signals = [];
1122
+ if (lastResultStatus === 'failure') signals.push('last run failed');
1123
+ if (filesChanged > 0) signals.push(`${filesChanged} file${filesChanged !== 1 ? 's' : ''} changed`);
1124
+ if (msgCount > 10) signals.push('deep session');
1125
+
1126
+ // Check for uncommitted git changes
1127
+ try {
1128
+ const gitResult = _spawnSyncTop('git', ['status', '--porcelain'], {
1129
+ cwd,
1130
+ encoding: 'utf8',
1131
+ stdio: ['pipe', 'pipe', 'pipe'],
1132
+ timeout: 3000,
1133
+ });
1134
+ if (gitResult.status === 0 && gitResult.stdout.trim().length > 0) {
1135
+ signals.push('uncommitted changes');
1136
+ }
1137
+ } catch { /* non-fatal */ }
1138
+
1139
+ // Need at least one signal beyond base thresholds to avoid annoying low-signal cards
1140
+ if (signals.length === 0 && msgCount <= 10) return null;
1141
+
1142
+ // Build a human-readable "last state" from available data
1143
+ let lastState = null;
1144
+ if (session?.lastResult?.summary) {
1145
+ lastState = session.lastResult.summary;
1146
+ } else if (session?.objective) {
1147
+ lastState = session.objective;
1148
+ } else if (most.name && !/^Session [0-9a-f]{8}/i.test(most.name)) {
1149
+ lastState = most.name;
1150
+ }
1151
+
1152
+ // Trim lastState to fit on one line
1153
+ if (lastState && lastState.length > 45) lastState = lastState.slice(0, 42) + '...';
1154
+
1155
+ // Build reason label
1156
+ const reason = signals.length > 0 ? signals.join(', ') : `${msgCount} messages`;
1157
+
1158
+ // Age label
1159
+ const mins = Math.floor(ageMs / 60000);
1160
+ let ageLabel;
1161
+ if (mins < 1) ageLabel = 'just now';
1162
+ else if (mins < 60) ageLabel = `${mins}m ago`;
1163
+ else ageLabel = `${Math.floor(mins / 60)}h ago`;
1164
+
1165
+ return {
1166
+ shouldContinue: true,
1167
+ reason,
1168
+ sessionId: most.id,
1169
+ sessionName: most.name || most.id.slice(0, 8),
1170
+ lastState,
1171
+ ageLabel,
1172
+ };
1173
+ }
1174
+
1003
1175
  /**
1004
1176
  * Build a provider status string for the dashboard status line.
1005
1177
  * Returns a string like: "🟢 Claude $100×2 $20×1 🟢 OpenAI $100"
@@ -1105,6 +1277,9 @@ async function mainScreen(rl, ask) {
1105
1277
  const rtMain = detectReplitTools(cwd);
1106
1278
  const dtVersion = (rtMain.installed && rtMain.version) ? rtMain.version : null;
1107
1279
 
1280
+ // ── Interrupted work detection ────────────────────────────────────────────
1281
+ const interrupted = detectInterruptedWork(allSessions, cwd);
1282
+
1108
1283
  // ── Box layout ────────────────────────────────────────────────────────────
1109
1284
  const termW = process.stdout.columns || 60;
1110
1285
  const boxW = Math.min(termW - 2, 60); // outer width (including │ │)
@@ -1135,6 +1310,84 @@ async function mainScreen(rl, ask) {
1135
1310
  }
1136
1311
  }
1137
1312
 
1313
+ // ── Continuation card (interrupted work) ─────────────────────────────────
1314
+ if (interrupted) {
1315
+ const ctop = `┌${'─'.repeat(boxW - 2)}┐`;
1316
+ const csep = `├${'─'.repeat(boxW - 2)}┤`;
1317
+ const cbot = `└${'─'.repeat(boxW - 2)}┘`;
1318
+ const crow = (content) => makeBoxRow(content, W);
1319
+
1320
+ const titleLine = `\x1b[33m💡\x1b[0m Continue: ${interrupted.sessionName}`;
1321
+ const lastLine = interrupted.lastState
1322
+ ? ` Last: ${interrupted.lastState} · ${interrupted.ageLabel}`
1323
+ : ` ${interrupted.reason} · ${interrupted.ageLabel}`;
1324
+ const actLine = ' [Enter] Resume [n] New session [s] Skip';
1325
+
1326
+ process.stdout.write([ctop, crow(titleLine), csep, crow(lastLine), crow(actLine), cbot].join('\n') + '\n\n');
1327
+
1328
+ // Wait for a keypress to decide what to do with the card
1329
+ const readline2 = await import('node:readline');
1330
+ readline2.emitKeypressEvents(process.stdin, rl);
1331
+
1332
+ const cardChoice = await new Promise((resolve) => {
1333
+ const wasRaw2 = process.stdin.isRaw;
1334
+ const canRaw2 = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
1335
+ if (canRaw2) process.stdin.setRawMode(true);
1336
+
1337
+ const cleanup2 = () => {
1338
+ process.stdin.removeListener('keypress', onCardKey);
1339
+ if (canRaw2) {
1340
+ try { process.stdin.setRawMode(wasRaw2 || false); } catch {}
1341
+ }
1342
+ };
1343
+
1344
+ const onCardKey = (str, key) => {
1345
+ if (!key) return;
1346
+ const name = key.name || '';
1347
+ const seq = key.sequence || str || '';
1348
+
1349
+ if (key.ctrl && (name === 'c' || name === 'd')) {
1350
+ cleanup2();
1351
+ process.stdout.write('\n');
1352
+ resolve('q');
1353
+ return;
1354
+ }
1355
+
1356
+ if (name === 'return' || name === 'enter' || seq === '\r' || seq === '\n') {
1357
+ cleanup2();
1358
+ process.stdout.write('\n');
1359
+ resolve('resume');
1360
+ return;
1361
+ }
1362
+
1363
+ if (!str || str.length === 0) return;
1364
+ const lower = str.toLowerCase();
1365
+ if (lower === 'n' || lower === 's' || lower === 'q') {
1366
+ cleanup2();
1367
+ process.stdout.write('\n');
1368
+ resolve(lower);
1369
+ return;
1370
+ }
1371
+ };
1372
+
1373
+ process.stdin.on('keypress', onCardKey);
1374
+ });
1375
+
1376
+ if (cardChoice === 'q') return { next: 'exit' };
1377
+
1378
+ if (cardChoice === 'resume') {
1379
+ const { spawnSync } = await import('node:child_process');
1380
+ process.stdout.write(` Launching: claude --resume ${interrupted.sessionId}\n\n`);
1381
+ spawnSync('claude', ['--resume', interrupted.sessionId], { stdio: 'inherit' });
1382
+ saveTerminalState(cwd, getTerminalId(), interrupted.sessionId, 'claude');
1383
+ return { next: 'main' };
1384
+ }
1385
+
1386
+ if (cardChoice === 'n') return { next: 'new-session' };
1387
+
1388
+ // 's' → fall through to normal dashboard
1389
+ }
1390
+
1138
1391
  // ── Status section ────────────────────────────────────────────────────────
1139
1392
  const providerLine = buildProviderStatusLine(profile, auth);
1140
1393
 
@@ -1143,6 +1396,10 @@ async function mainScreen(rl, ask) {
1143
1396
  statusRows.push(row(`\x1b[2m📦 data-tools v${dtVersion}\x1b[0m`));
1144
1397
  }
1145
1398
 
1399
+ // ── Action cards (git state) ──────────────────────────────────────────────
1400
+ const repoState = detectRepoState(cwd);
1401
+ const actionRows = buildActionRows(repoState, row);
1402
+
1146
1403
  // ── Sessions section ──────────────────────────────────────────────────────
1147
1404
  const sessionRows = [];
1148
1405
  if (recentSessions.length === 0) {
@@ -1199,9 +1456,11 @@ async function mainScreen(rl, ask) {
1199
1456
  const actionsRow = row(actionsContent);
1200
1457
 
1201
1458
  // ── Print the full box ────────────────────────────────────────────────────
1459
+ // Include action cards between status and sessions (with separators only when non-empty)
1202
1460
  const lines = [
1203
1461
  top,
1204
1462
  ...statusRows,
1463
+ ...(actionRows.length > 0 ? [sep, ...actionRows] : []),
1205
1464
  sep,
1206
1465
  ...sessionRows,
1207
1466
  sep,
@@ -3153,22 +3412,25 @@ async function cmdSpecialistGo(specialist, args) {
3153
3412
  vtrace(`Dual-brain: ${decision.dualBrain ? 'yes' : 'no'}`);
3154
3413
  }
3155
3414
 
3156
- console.log(` specialist : ${specialist}`);
3157
- console.log(` provider : ${decision.provider}`);
3158
- console.log(` model : ${decision.model}${decision.effort ? ' (' + decision.effort + ')' : ''}`);
3159
- console.log(` tier : ${decision.tier}`);
3160
- console.log(` dual-brain : ${decision.dualBrain ? 'yes' : 'no'}`);
3161
- console.log(` reason : ${decision.explanation}`);
3415
+ // Print routing table (only in dry-run or verbose; silent in normal mode)
3416
+ if (dryRun || verbose) {
3417
+ console.log(` specialist : ${specialist}`);
3418
+ console.log(` provider : ${decision.provider}`);
3419
+ console.log(` model : ${decision.model}${decision.effort ? ' (' + decision.effort + ')' : ''}`);
3420
+ console.log(` tier : ${decision.tier}`);
3421
+ console.log(` dual-brain : ${decision.dualBrain ? 'yes' : 'no'}`);
3422
+ console.log(` reason : ${decision.explanation}`);
3423
+ }
3162
3424
 
3163
3425
  if (dryRun) {
3164
3426
  console.log('\n(dry-run — not executing)');
3165
3427
  return;
3166
3428
  }
3167
3429
 
3168
- console.log('\nDispatching...');
3430
+ if (verbose) console.log('\nDispatching...');
3169
3431
  let result;
3170
3432
  if (decision.dualBrain) {
3171
- result = await dispatchDualBrain({ decision, prompt, files, cwd });
3433
+ result = await dispatchDualBrain({ decision, prompt, files, cwd, verbose });
3172
3434
  console.log(`\nConsensus: ${result.consensus}`);
3173
3435
  if (result.claude?.summary) console.log(`Claude : ${result.claude.summary}`);
3174
3436
  if (result.openai?.summary) console.log(`OpenAI : ${result.openai.summary}`);
@@ -3182,7 +3444,7 @@ async function cmdSpecialistGo(specialist, args) {
3182
3444
  nextAction: null,
3183
3445
  }, cwd);
3184
3446
  } else {
3185
- result = await dispatch({ decision, prompt, files, cwd });
3447
+ result = await dispatch({ decision, prompt, files, cwd, verbose });
3186
3448
  const statusLine = result.status === 'completed' ? 'Done' : `Failed (exit ${result.exitCode})`;
3187
3449
  console.log(`\n${statusLine} in ${(result.durationMs / 1000).toFixed(1)}s`);
3188
3450
  if (result.summary) console.log(result.summary);
@@ -3242,13 +3504,26 @@ async function main() {
3242
3504
  await runScreens('main');
3243
3505
  }
3244
3506
  } else {
3245
- // Non-TTY: print status card and exit
3246
- const cwd = process.cwd();
3247
- const repo = loadRepoCache(cwd);
3248
- const session = loadSession(cwd);
3249
- const health = getHealth(cwd);
3250
- const card = formatSessionCard(session, repo, health);
3251
- console.log(card);
3507
+ // Non-TTY with no args: read stdin as a task and run one-shot
3508
+ const stdinTask = await new Promise((resolve) => {
3509
+ let data = '';
3510
+ process.stdin.setEncoding('utf8');
3511
+ process.stdin.on('data', chunk => { data += chunk; });
3512
+ process.stdin.on('end', () => resolve(data.trim()));
3513
+ // If stdin has no data within 200ms (not truly piped), fall back to status card
3514
+ setTimeout(() => resolve(null), 200);
3515
+ });
3516
+ if (stdinTask) {
3517
+ process.stderr.write('🧠 routing...\n');
3518
+ await cmdGo([stdinTask]);
3519
+ } else {
3520
+ const cwd = process.cwd();
3521
+ const repo = loadRepoCache(cwd);
3522
+ const session = loadSession(cwd);
3523
+ const health = getHealth(cwd);
3524
+ const card = formatSessionCard(session, repo, health);
3525
+ console.log(card);
3526
+ }
3252
3527
  }
3253
3528
  return;
3254
3529
  }
@@ -3341,6 +3616,43 @@ fi
3341
3616
  return;
3342
3617
  }
3343
3618
 
3619
+ // ─── One-shot mode ────────────────────────────────────────────────────────────
3620
+ // If cmd is not a recognized subcommand, treat the entire arg list as a task.
3621
+ // e.g. `dual-brain fix failing tests` → same as `dual-brain go "fix failing tests"`
3622
+ const KNOWN_COMMANDS = new Set([
3623
+ 'init', 'install', 'auth', 'go', 'status', 'hot', 'cool',
3624
+ 'remember', 'forget', 'break-glass', 'specialists', 'search', 'shell-hook',
3625
+ '--help', '-h', '--version', '-v',
3626
+ ...Object.keys(loadSpecialistRegistry()),
3627
+ ]);
3628
+
3629
+ if (!KNOWN_COMMANDS.has(cmd)) {
3630
+ // All of args are part of the task description (plus any flags like --dry-run/--files).
3631
+ // Join non-flag words into a single prompt string so cmdGo's args.find() picks it up.
3632
+ // We strip out flag values (e.g. the value after --files) before collecting prompt words.
3633
+ process.stderr.write('🧠 routing...\n');
3634
+ const flagValuesToSkip = new Set();
3635
+ const pairedFlags = ['--files'];
3636
+ for (const f of pairedFlags) {
3637
+ const idx = args.indexOf(f);
3638
+ if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith('--')) {
3639
+ flagValuesToSkip.add(args[idx + 1]);
3640
+ }
3641
+ }
3642
+ const passedFlags = [];
3643
+ for (let i = 0; i < args.length; i++) {
3644
+ if (args[i].startsWith('--') || args[i].startsWith('-')) {
3645
+ passedFlags.push(args[i]);
3646
+ if (pairedFlags.includes(args[i]) && args[i + 1] && !args[i + 1].startsWith('--')) {
3647
+ passedFlags.push(args[++i]);
3648
+ }
3649
+ }
3650
+ }
3651
+ const promptWords = args.filter(a => !a.startsWith('--') && !a.startsWith('-') && !flagValuesToSkip.has(a));
3652
+ await cmdGo([promptWords.join(' '), ...passedFlags]);
3653
+ return;
3654
+ }
3655
+
3344
3656
  process.stderr.write(`Unknown command: ${cmd}\nRun "dual-brain --help" for usage.\n`);
3345
3657
  process.exit(1);
3346
3658
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {
package/src/decide.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  * to use and explains why in one sentence.
7
7
  *
8
8
  * Exports: decideRoute, getModelCapabilities, getAvailableModels,
9
- * estimateBudgetPressure, shouldDualBrain, explainDecision
9
+ * estimateBudgetPressure, shouldDualBrain, explainDecision, getFailoverOrder
10
10
  *
11
11
  * CLI: node src/decide.mjs --profile /path/to/profile.json \
12
12
  * --detection '{"intent":"edit","risk":"low","complexity":"simple","effort":"medium","tier":"execute"}'
@@ -602,6 +602,82 @@ export function decideRoute({ profile = {}, detection = {}, cwd } = {}) {
602
602
  return result;
603
603
  }
604
604
 
605
+ // ─── Exported: getFailoverOrder ──────────────────────────────────────────────
606
+
607
+ /**
608
+ * Given a failed routing decision and the active profile, return an ordered list
609
+ * of fallback options to try next.
610
+ *
611
+ * Priority order:
612
+ * 1. Other subscriptions of the same provider (e.g. Claude Max #2 before Claude Pro)
613
+ * 2. Other provider (OpenAI or Claude, whichever wasn't tried)
614
+ *
615
+ * Within each group, options are ordered by capability match for the tier
616
+ * (best fit first, cheapest last).
617
+ *
618
+ * @param {object} decision The routing decision that just failed (provider, model, tier)
619
+ * @param {object} profile Active profile with providers/subscriptions info
620
+ * @returns {Array<{ provider: string, model: string, plan: string, label: string }>}
621
+ */
622
+ export function getFailoverOrder(decision, profile) {
623
+ const { provider: failedProvider, model: failedModel, tier = 'execute' } = decision;
624
+ const available = getAvailableModels(profile);
625
+
626
+ // Build a ranked model list for Claude (best capability for tier → cheapest)
627
+ const claudeRankByTier = {
628
+ think: ['opus', 'sonnet', 'haiku'],
629
+ execute: ['sonnet', 'opus', 'haiku'],
630
+ search: ['haiku', 'sonnet', 'opus'],
631
+ };
632
+ const openaiRankByTier = {
633
+ think: ['o3', 'gpt-4o', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-4o-mini'],
634
+ execute: ['gpt-4o', 'gpt-4.1', 'o3', 'gpt-4.1-mini', 'gpt-4o-mini'],
635
+ search: ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'gpt-4o', 'o3'],
636
+ };
637
+
638
+ const claudeRank = claudeRankByTier[tier] ?? claudeRankByTier.execute;
639
+ const openaiRank = openaiRankByTier[tier] ?? openaiRankByTier.execute;
640
+
641
+ const claudeEnabled = !!(profile?.providers?.claude?.enabled && profile?.providers?.claude?.plan);
642
+ const openaiEnabled = !!(profile?.providers?.openai?.enabled && profile?.providers?.openai?.plan);
643
+ const claudePlan = profile?.providers?.claude?.plan ?? '$20';
644
+ const openaiPlan = profile?.providers?.openai?.plan ?? '$20';
645
+
646
+ const fallbacks = [];
647
+
648
+ if (failedProvider === 'claude') {
649
+ // Same-provider fallbacks: other Claude models (skip the one that just failed)
650
+ for (const m of claudeRank) {
651
+ if (m === failedModel) continue;
652
+ if (!available.claude.includes(m)) continue;
653
+ fallbacks.push({ provider: 'claude', model: m, plan: claudePlan, label: `Claude ${m} (${claudePlan})` });
654
+ }
655
+ // Cross-provider fallbacks: OpenAI models
656
+ if (openaiEnabled) {
657
+ for (const m of openaiRank) {
658
+ if (!available.openai.includes(m)) continue;
659
+ fallbacks.push({ provider: 'openai', model: m, plan: openaiPlan, label: `OpenAI ${m} (${openaiPlan})` });
660
+ }
661
+ }
662
+ } else {
663
+ // Same-provider fallbacks: other OpenAI models (skip the one that just failed)
664
+ for (const m of openaiRank) {
665
+ if (m === failedModel) continue;
666
+ if (!available.openai.includes(m)) continue;
667
+ fallbacks.push({ provider: 'openai', model: m, plan: openaiPlan, label: `OpenAI ${m} (${openaiPlan})` });
668
+ }
669
+ // Cross-provider fallbacks: Claude models
670
+ if (claudeEnabled) {
671
+ for (const m of claudeRank) {
672
+ if (!available.claude.includes(m)) continue;
673
+ fallbacks.push({ provider: 'claude', model: m, plan: claudePlan, label: `Claude ${m} (${claudePlan})` });
674
+ }
675
+ }
676
+ }
677
+
678
+ return fallbacks;
679
+ }
680
+
605
681
  // ─── CLI ──────────────────────────────────────────────────────────────────────
606
682
 
607
683
  if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
package/src/dispatch.mjs CHANGED
@@ -14,6 +14,7 @@ import { fileURLToPath } from 'node:url';
14
14
  import { createHash } from 'node:crypto';
15
15
  import { markHot, markDegraded, markHealthy, recordDispatch } from './health.mjs';
16
16
  import { redact } from './redact.mjs';
17
+ import { getFailoverOrder } from './decide.mjs';
17
18
 
18
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
20
  const USAGE_DIR = join(__dirname, '..', '.dualbrain', 'usage');
@@ -93,6 +94,44 @@ function medianDuration(provider, model) {
93
94
  // Rate-limit error keywords
94
95
  const RATE_LIMIT_PATTERNS = /rate.?limit|quota|capacity|too many requests|overloaded|throttl/i;
95
96
 
97
+ // ─── Auto-heal failover helpers ───────────────────────────────────────────────
98
+
99
+ const FAILOVER_LOG_DIR = join(__dirname, '..', '.dualbrain', 'audit');
100
+
101
+ /** Retryable exit-code-1 patterns: rate limits, quota, capacity, timeouts */
102
+ const RETRYABLE_PATTERNS = /rate.?limit|429|quota.?exceeded|capacity|overloaded|timeout/i;
103
+
104
+ /** Non-retryable patterns: auth failures, bad input, user cancellation */
105
+ const NON_RETRYABLE_PATTERNS = /unauthorized|forbidden|invalid.?api.?key|authentication|bad.?request|cancelled|canceled/i;
106
+
107
+ /**
108
+ * Decide if a subprocess result is a retryable failure.
109
+ * Must be exit code 1 (or non-zero) AND match retryable keywords AND NOT match
110
+ * non-retryable keywords.
111
+ * @param {{ exitCode: number, stderr: string, stdout: string }} result
112
+ * @returns {boolean}
113
+ */
114
+ function isRetryableFailure({ exitCode, stderr, stdout }) {
115
+ if (exitCode === 0) return false;
116
+ const errText = `${stderr} ${stdout}`.slice(0, 1000);
117
+ if (NON_RETRYABLE_PATTERNS.test(errText)) return false;
118
+ return RETRYABLE_PATTERNS.test(errText);
119
+ }
120
+
121
+ /**
122
+ * Append a failover event to .dualbrain/audit/failover.jsonl.
123
+ * @param {{ from: string, to: string, reason: string, attempt: number }} info
124
+ */
125
+ function logFailover({ from, to, reason, attempt }) {
126
+ try {
127
+ mkdirSync(FAILOVER_LOG_DIR, { recursive: true });
128
+ appendFileSync(
129
+ join(FAILOVER_LOG_DIR, 'failover.jsonl'),
130
+ JSON.stringify({ ts: new Date().toISOString(), from, to, reason, attempt }) + '\n',
131
+ );
132
+ } catch {}
133
+ }
134
+
96
135
  // ─── Native Claude Code detection ────────────────────────────────────────────
97
136
 
98
137
  /**
@@ -607,7 +646,7 @@ function _prependDispatchMarker(prompt) {
607
646
 
608
647
  // ─── Main dispatch ────────────────────────────────────────────────────────────
609
648
  async function dispatch(input = {}) {
610
- const { files = [], cwd = process.cwd(), dryRun = false } = input;
649
+ const { files = [], cwd = process.cwd(), dryRun = false, verbose = false } = input;
611
650
  let decision = input.decision ?? {};
612
651
  let { prompt } = input;
613
652
 
@@ -629,7 +668,7 @@ async function dispatch(input = {}) {
629
668
  const specialistPrompt = loadSpecialistPrompt(specialist);
630
669
  if (specialistPrompt) {
631
670
  prompt = `${specialistPrompt}\n\n---\n\n${prompt}`;
632
- process.stderr.write(`[dual-brain] specialist: ${specialist}\n`);
671
+ if (verbose) process.stderr.write(`[dual-brain] specialist: ${specialist}\n`);
633
672
  }
634
673
 
635
674
  // Apply tier_bias from registry if decision didn't already pin a tier
@@ -638,7 +677,7 @@ async function dispatch(input = {}) {
638
677
  const tierBias = registry?.specialists?.[specialist]?.tier_bias;
639
678
  if (tierBias) {
640
679
  decision = { ...decision, tier: tierBias };
641
- process.stderr.write(`[dual-brain] specialist tier_bias applied: ${tierBias}\n`);
680
+ if (verbose) process.stderr.write(`[dual-brain] specialist tier_bias applied: ${tierBias}\n`);
642
681
  }
643
682
  }
644
683
  }
@@ -736,7 +775,39 @@ async function dispatch(input = {}) {
736
775
  _recordDispatchBudget(prompt);
737
776
 
738
777
  const dispatchEnv = { DUAL_BRAIN_DISPATCH: '1' };
739
- const { exitCode, stdout, stderr, durationMs } = await runProcess(command, cwd, timeoutMs, dispatchEnv);
778
+
779
+ // ── Auto-heal failover retry loop (native Claude path) ────────────────
780
+ const MAX_FAILOVER_ATTEMPTS = 2;
781
+ let currentProvider = effectiveProvider;
782
+ let currentModel = effectiveModel;
783
+ let currentDecision = effectiveDecision;
784
+ let currentCommand = command;
785
+ let lastRaw;
786
+
787
+ for (let attempt = 0; attempt <= MAX_FAILOVER_ATTEMPTS; attempt++) {
788
+ lastRaw = await runProcess(currentCommand, cwd, timeoutMs, dispatchEnv);
789
+ if (lastRaw.exitCode === 0 || !isRetryableFailure(lastRaw) || attempt === MAX_FAILOVER_ATTEMPTS) break;
790
+
791
+ const failoverList = getFailoverOrder(
792
+ { provider: currentProvider, model: currentModel, tier },
793
+ input.profile ?? {},
794
+ );
795
+ if (failoverList.length === 0) break;
796
+
797
+ const next = failoverList[0];
798
+ const reason = `${lastRaw.stderr || lastRaw.stdout}`.slice(0, 120);
799
+ logFailover({ from: `${currentProvider}/${currentModel}`, to: `${next.provider}/${next.model}`, reason, attempt: attempt + 1 });
800
+ process.stderr.write(`\x1b[2m[dual-brain] Provider busy, failing over to ${next.label}...\x1b[0m\n`);
801
+
802
+ markHot(currentProvider, currentModel, cwd);
803
+ currentProvider = next.provider;
804
+ currentModel = next.model;
805
+ currentDecision = { ...currentDecision, provider: currentProvider, model: currentModel };
806
+ currentCommand = buildCommand(currentDecision, prompt, files, cwd);
807
+ }
808
+
809
+ const { exitCode, stdout, stderr, durationMs } = lastRaw;
810
+ // ── End failover loop ────────────────────────────────────────────────
740
811
 
741
812
  // Extract token usage from JSON output if available
742
813
  let usage = null;
@@ -753,25 +824,25 @@ async function dispatch(input = {}) {
753
824
 
754
825
  // ── Health tracking ────────────────────────────────────────────────────
755
826
  if (success) {
756
- recordDuration(effectiveProvider, effectiveModel, durationMs);
757
- const median = medianDuration(effectiveProvider, effectiveModel);
827
+ recordDuration(currentProvider, currentModel, durationMs);
828
+ const median = medianDuration(currentProvider, currentModel);
758
829
  if (median !== null && durationMs > median * 3) {
759
- markDegraded(effectiveProvider, effectiveModel, cwd);
830
+ markDegraded(currentProvider, currentModel, cwd);
760
831
  } else {
761
- markHealthy(effectiveProvider, effectiveModel, cwd);
832
+ markHealthy(currentProvider, currentModel, cwd);
762
833
  }
763
834
  const totalTokens = (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0);
764
- recordDispatch(effectiveProvider, effectiveModel, totalTokens, cwd);
835
+ recordDispatch(currentProvider, currentModel, totalTokens, cwd);
765
836
  } else {
766
837
  if (RATE_LIMIT_PATTERNS.test(errorText)) {
767
- markHot(effectiveProvider, effectiveModel, cwd);
838
+ markHot(currentProvider, currentModel, cwd);
768
839
  }
769
840
  }
770
841
  // ── End health tracking ────────────────────────────────────────────────
771
842
 
772
843
  recordUsage({
773
- provider: effectiveProvider,
774
- model: effectiveModel,
844
+ provider: currentProvider,
845
+ model: currentModel,
775
846
  tier,
776
847
  durationMs,
777
848
  inputTokens: usage?.inputTokens ?? null,
@@ -782,10 +853,10 @@ async function dispatch(input = {}) {
782
853
  return {
783
854
  status: success ? 'completed' : 'failed',
784
855
  type: 'native-agent',
785
- provider: effectiveProvider,
786
- model: effectiveModel,
856
+ provider: currentProvider,
857
+ model: currentModel,
787
858
  specialist: specialist ?? 'generic',
788
- command,
859
+ command: currentCommand,
789
860
  nativeDispatch: nativeDescriptor,
790
861
  exitCode,
791
862
  summary,
@@ -804,7 +875,38 @@ async function dispatch(input = {}) {
804
875
  // Record this dispatch against the budget
805
876
  _recordDispatchBudget(prompt);
806
877
 
807
- const { exitCode, stdout, stderr, durationMs } = await runProcess(command, cwd, timeoutMs);
878
+ // ── Auto-heal failover retry loop (subprocess path) ──────────────────────
879
+ const MAX_FAILOVER_ATTEMPTS_SUB = 2;
880
+ let subProvider = effectiveProvider;
881
+ let subModel = effectiveModel;
882
+ let subDecision = effectiveDecision;
883
+ let subCommand = command;
884
+ let subRaw;
885
+
886
+ for (let attempt = 0; attempt <= MAX_FAILOVER_ATTEMPTS_SUB; attempt++) {
887
+ subRaw = await runProcess(subCommand, cwd, timeoutMs);
888
+ if (subRaw.exitCode === 0 || !isRetryableFailure(subRaw) || attempt === MAX_FAILOVER_ATTEMPTS_SUB) break;
889
+
890
+ const failoverList = getFailoverOrder(
891
+ { provider: subProvider, model: subModel, tier },
892
+ input.profile ?? {},
893
+ );
894
+ if (failoverList.length === 0) break;
895
+
896
+ const next = failoverList[0];
897
+ const reason = `${subRaw.stderr || subRaw.stdout}`.slice(0, 120);
898
+ logFailover({ from: `${subProvider}/${subModel}`, to: `${next.provider}/${next.model}`, reason, attempt: attempt + 1 });
899
+ process.stderr.write(`\x1b[2m[dual-brain] Provider busy, failing over to ${next.label}...\x1b[0m\n`);
900
+
901
+ markHot(subProvider, subModel, cwd);
902
+ subProvider = next.provider;
903
+ subModel = next.model;
904
+ subDecision = { ...subDecision, provider: subProvider, model: subModel };
905
+ subCommand = buildCommand(subDecision, prompt, files, cwd);
906
+ }
907
+
908
+ const { exitCode, stdout, stderr, durationMs } = subRaw;
909
+ // ── End failover loop ──────────────────────────────────────────────────────
808
910
 
809
911
  // Extract token usage from JSON output if available
810
912
  let usage = null;
@@ -821,25 +923,25 @@ async function dispatch(input = {}) {
821
923
 
822
924
  // ── Health tracking ──────────────────────────────────────────────────────
823
925
  if (success) {
824
- recordDuration(effectiveProvider, effectiveModel, durationMs);
825
- const median = medianDuration(effectiveProvider, effectiveModel);
926
+ recordDuration(subProvider, subModel, durationMs);
927
+ const median = medianDuration(subProvider, subModel);
826
928
  if (median !== null && durationMs > median * 3) {
827
- markDegraded(effectiveProvider, effectiveModel, cwd);
929
+ markDegraded(subProvider, subModel, cwd);
828
930
  } else {
829
- markHealthy(effectiveProvider, effectiveModel, cwd);
931
+ markHealthy(subProvider, subModel, cwd);
830
932
  }
831
933
  const totalTokens = (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0);
832
- recordDispatch(effectiveProvider, effectiveModel, totalTokens, cwd);
934
+ recordDispatch(subProvider, subModel, totalTokens, cwd);
833
935
  } else {
834
936
  if (RATE_LIMIT_PATTERNS.test(errorText)) {
835
- markHot(effectiveProvider, effectiveModel, cwd);
937
+ markHot(subProvider, subModel, cwd);
836
938
  }
837
939
  }
838
940
  // ── End health tracking ──────────────────────────────────────────────────
839
941
 
840
942
  recordUsage({
841
- provider: effectiveProvider,
842
- model: effectiveModel,
943
+ provider: subProvider,
944
+ model: subModel,
843
945
  tier,
844
946
  durationMs,
845
947
  inputTokens: usage?.inputTokens ?? null,
@@ -849,10 +951,10 @@ async function dispatch(input = {}) {
849
951
 
850
952
  return {
851
953
  status: success ? 'completed' : 'failed',
852
- provider: effectiveProvider,
853
- model: effectiveModel,
954
+ provider: subProvider,
955
+ model: subModel,
854
956
  specialist: specialist ?? 'generic',
855
- command,
957
+ command: subCommand,
856
958
  exitCode,
857
959
  summary,
858
960
  durationMs,
@@ -863,7 +965,7 @@ async function dispatch(input = {}) {
863
965
 
864
966
  // ─── Dual-brain dispatch (parallel) ───────────────────────────────────────────
865
967
  async function dispatchDualBrain(input = {}) {
866
- const { decision = {}, files = [], cwd = process.cwd(), dryRun = false } = input;
968
+ const { decision = {}, files = [], cwd = process.cwd(), dryRun = false, verbose = false } = input;
867
969
  let { prompt } = input;
868
970
  if (!prompt) throw new Error('prompt is required');
869
971
 
@@ -887,10 +989,10 @@ async function dispatchDualBrain(input = {}) {
887
989
  const [claudeResult, openaiResult] = await Promise.all([
888
990
  validatedClaude._error
889
991
  ? Promise.resolve({ status: 'error', provider: 'claude', model: claudeDecision.model, command: null, exitCode: null, summary: validatedClaude._error, durationMs: 0, usage: null, error: validatedClaude._error })
890
- : dispatch({ decision: validatedClaude, prompt, files, cwd, dryRun }),
992
+ : dispatch({ decision: validatedClaude, prompt, files, cwd, dryRun, verbose }),
891
993
  validatedOpenai._error
892
994
  ? Promise.resolve({ status: 'error', provider: 'openai', model: openaiDecision.model, command: null, exitCode: null, summary: validatedOpenai._error, durationMs: 0, usage: null, error: validatedOpenai._error })
893
- : dispatch({ decision: validatedOpenai, prompt, files, cwd, dryRun }),
995
+ : dispatch({ decision: validatedOpenai, prompt, files, cwd, dryRun, verbose }),
894
996
  ]);
895
997
 
896
998
  return {