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.
- package/bin/dual-brain.mjs +337 -25
- package/package.json +1 -1
- package/src/decide.mjs +77 -1
- package/src/dispatch.mjs +132 -30
package/bin/dual-brain.mjs
CHANGED
|
@@ -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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
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:
|
|
3246
|
-
const
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
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
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
|
-
|
|
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(
|
|
757
|
-
const median = medianDuration(
|
|
827
|
+
recordDuration(currentProvider, currentModel, durationMs);
|
|
828
|
+
const median = medianDuration(currentProvider, currentModel);
|
|
758
829
|
if (median !== null && durationMs > median * 3) {
|
|
759
|
-
markDegraded(
|
|
830
|
+
markDegraded(currentProvider, currentModel, cwd);
|
|
760
831
|
} else {
|
|
761
|
-
markHealthy(
|
|
832
|
+
markHealthy(currentProvider, currentModel, cwd);
|
|
762
833
|
}
|
|
763
834
|
const totalTokens = (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0);
|
|
764
|
-
recordDispatch(
|
|
835
|
+
recordDispatch(currentProvider, currentModel, totalTokens, cwd);
|
|
765
836
|
} else {
|
|
766
837
|
if (RATE_LIMIT_PATTERNS.test(errorText)) {
|
|
767
|
-
markHot(
|
|
838
|
+
markHot(currentProvider, currentModel, cwd);
|
|
768
839
|
}
|
|
769
840
|
}
|
|
770
841
|
// ── End health tracking ────────────────────────────────────────────────
|
|
771
842
|
|
|
772
843
|
recordUsage({
|
|
773
|
-
provider:
|
|
774
|
-
model:
|
|
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:
|
|
786
|
-
model:
|
|
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
|
-
|
|
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(
|
|
825
|
-
const median = medianDuration(
|
|
926
|
+
recordDuration(subProvider, subModel, durationMs);
|
|
927
|
+
const median = medianDuration(subProvider, subModel);
|
|
826
928
|
if (median !== null && durationMs > median * 3) {
|
|
827
|
-
markDegraded(
|
|
929
|
+
markDegraded(subProvider, subModel, cwd);
|
|
828
930
|
} else {
|
|
829
|
-
markHealthy(
|
|
931
|
+
markHealthy(subProvider, subModel, cwd);
|
|
830
932
|
}
|
|
831
933
|
const totalTokens = (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0);
|
|
832
|
-
recordDispatch(
|
|
934
|
+
recordDispatch(subProvider, subModel, totalTokens, cwd);
|
|
833
935
|
} else {
|
|
834
936
|
if (RATE_LIMIT_PATTERNS.test(errorText)) {
|
|
835
|
-
markHot(
|
|
937
|
+
markHot(subProvider, subModel, cwd);
|
|
836
938
|
}
|
|
837
939
|
}
|
|
838
940
|
// ── End health tracking ──────────────────────────────────────────────────
|
|
839
941
|
|
|
840
942
|
recordUsage({
|
|
841
|
-
provider:
|
|
842
|
-
model:
|
|
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:
|
|
853
|
-
model:
|
|
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 {
|