dual-brain 0.1.5 → 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 +811 -70
- package/package.json +1 -1
- package/src/decide.mjs +77 -1
- package/src/dispatch.mjs +132 -30
- package/src/session.mjs +71 -1
package/bin/dual-brain.mjs
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
import { dispatch, detectRuntime, dispatchDualBrain } from '../src/dispatch.mjs';
|
|
30
30
|
|
|
31
31
|
import { loadRepoCache } from '../src/repo.mjs';
|
|
32
|
-
import { loadSession, saveSession, formatSessionCard, importReplitSessions, renameSession, pinSession, unpinSession, categorizeSession, enrichSessions } from '../src/session.mjs';
|
|
32
|
+
import { loadSession, saveSession, formatSessionCard, importReplitSessions, getSessionMeta, saveSessionMeta, renameSession, pinSession, unpinSession, categorizeSession, enrichSessions, archiveSession, getArchivedSessions } from '../src/session.mjs';
|
|
33
33
|
|
|
34
34
|
import { box, bar, badge, menu, separator } from '../src/tui.mjs';
|
|
35
35
|
|
|
@@ -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"
|
|
@@ -1094,12 +1266,20 @@ async function mainScreen(rl, ask) {
|
|
|
1094
1266
|
} catch {}
|
|
1095
1267
|
|
|
1096
1268
|
// Gather recent sessions
|
|
1097
|
-
const
|
|
1269
|
+
const allSessions = enrichSessions(importReplitSessions(cwd), cwd);
|
|
1270
|
+
const recentSessions = allSessions.slice(0, 3);
|
|
1271
|
+
const staleCount = allSessions.filter(s => {
|
|
1272
|
+
const ageMs = s.lastActive ? Date.now() - new Date(s.lastActive).getTime() : 0;
|
|
1273
|
+
return ageMs >= 7 * 86400000;
|
|
1274
|
+
}).length;
|
|
1098
1275
|
|
|
1099
1276
|
// Detect data-tools version
|
|
1100
1277
|
const rtMain = detectReplitTools(cwd);
|
|
1101
1278
|
const dtVersion = (rtMain.installed && rtMain.version) ? rtMain.version : null;
|
|
1102
1279
|
|
|
1280
|
+
// ── Interrupted work detection ────────────────────────────────────────────
|
|
1281
|
+
const interrupted = detectInterruptedWork(allSessions, cwd);
|
|
1282
|
+
|
|
1103
1283
|
// ── Box layout ────────────────────────────────────────────────────────────
|
|
1104
1284
|
const termW = process.stdout.columns || 60;
|
|
1105
1285
|
const boxW = Math.min(termW - 2, 60); // outer width (including │ │)
|
|
@@ -1130,6 +1310,84 @@ async function mainScreen(rl, ask) {
|
|
|
1130
1310
|
}
|
|
1131
1311
|
}
|
|
1132
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
|
+
|
|
1133
1391
|
// ── Status section ────────────────────────────────────────────────────────
|
|
1134
1392
|
const providerLine = buildProviderStatusLine(profile, auth);
|
|
1135
1393
|
|
|
@@ -1138,6 +1396,10 @@ async function mainScreen(rl, ask) {
|
|
|
1138
1396
|
statusRows.push(row(`\x1b[2m📦 data-tools v${dtVersion}\x1b[0m`));
|
|
1139
1397
|
}
|
|
1140
1398
|
|
|
1399
|
+
// ── Action cards (git state) ──────────────────────────────────────────────
|
|
1400
|
+
const repoState = detectRepoState(cwd);
|
|
1401
|
+
const actionRows = buildActionRows(repoState, row);
|
|
1402
|
+
|
|
1141
1403
|
// ── Sessions section ──────────────────────────────────────────────────────
|
|
1142
1404
|
const sessionRows = [];
|
|
1143
1405
|
if (recentSessions.length === 0) {
|
|
@@ -1152,33 +1414,64 @@ async function mainScreen(rl, ask) {
|
|
|
1152
1414
|
? sess.project.replace(/^-/, '/').replace(/-/g, '/')
|
|
1153
1415
|
: sess.id.slice(0, 8);
|
|
1154
1416
|
}
|
|
1155
|
-
|
|
1417
|
+
|
|
1418
|
+
// Build badges (ANSI color; track visible width separately)
|
|
1419
|
+
const badges = [];
|
|
1420
|
+
const badgeVisible = [];
|
|
1421
|
+
if (sess.isActive) {
|
|
1422
|
+
badges.push('\x1b[32m[active]\x1b[0m');
|
|
1423
|
+
badgeVisible.push('[active]'.length);
|
|
1424
|
+
}
|
|
1425
|
+
if (sess.source === 'replit-tools' || sess.source === 'data-tools') {
|
|
1426
|
+
badges.push('\x1b[36m[dt]\x1b[0m');
|
|
1427
|
+
badgeVisible.push('[dt]'.length);
|
|
1428
|
+
}
|
|
1429
|
+
const ageMs = sess.lastActive ? Date.now() - new Date(sess.lastActive).getTime() : 0;
|
|
1430
|
+
if (ageMs > 7 * 24 * 3600 * 1000) {
|
|
1431
|
+
badges.push('\x1b[2m[stale]\x1b[0m');
|
|
1432
|
+
badgeVisible.push('[stale]'.length);
|
|
1433
|
+
}
|
|
1434
|
+
const msgCount = sess.messageCount ?? sess.promptCount ?? 0;
|
|
1435
|
+
const msgBadge = `\x1b[2m(${msgCount})\x1b[0m`;
|
|
1436
|
+
const msgBadgeW = `(${msgCount})`.length;
|
|
1437
|
+
|
|
1438
|
+
const badgeStr = badges.join('');
|
|
1439
|
+
const badgesW = badgeVisible.reduce((s, n) => s + n, 0);
|
|
1440
|
+
|
|
1441
|
+
// Layout: "{num} {name...}{badges} {age} {msg}"
|
|
1156
1442
|
const numStr = String(i + 1);
|
|
1157
1443
|
const ageStr = sess.age || '';
|
|
1158
|
-
// Available for name: W
|
|
1159
|
-
const nameMax = W - numStr.length - 2 - 2 - ageStr.length;
|
|
1160
|
-
const
|
|
1161
|
-
? rawName.slice(0, nameMax - 3) + '...'
|
|
1444
|
+
// Available for name: W minus fixed chrome, badge widths, and msg badge
|
|
1445
|
+
const nameMax = W - numStr.length - 2 - badgesW - 2 - ageStr.length - 2 - msgBadgeW;
|
|
1446
|
+
const truncName = rawName.length > nameMax
|
|
1447
|
+
? rawName.slice(0, Math.max(0, nameMax - 3)) + '...'
|
|
1162
1448
|
: rawName.padEnd(nameMax);
|
|
1163
|
-
const content = `${numStr} ${
|
|
1449
|
+
const content = `${numStr} ${truncName}${badgeStr} ${ageStr} ${msgBadge}`;
|
|
1164
1450
|
sessionRows.push(row(content));
|
|
1165
1451
|
});
|
|
1166
1452
|
}
|
|
1167
1453
|
|
|
1168
1454
|
// ── Actions bar ───────────────────────────────────────────────────────────
|
|
1169
|
-
const actionsContent = '↵ Resume n New / Search s Settings q Quit';
|
|
1455
|
+
const actionsContent = '↵ Resume n New / Search i Import s Settings q Quit';
|
|
1170
1456
|
const actionsRow = row(actionsContent);
|
|
1171
1457
|
|
|
1172
1458
|
// ── Print the full box ────────────────────────────────────────────────────
|
|
1459
|
+
// Include action cards between status and sessions (with separators only when non-empty)
|
|
1173
1460
|
const lines = [
|
|
1174
1461
|
top,
|
|
1175
1462
|
...statusRows,
|
|
1463
|
+
...(actionRows.length > 0 ? [sep, ...actionRows] : []),
|
|
1176
1464
|
sep,
|
|
1177
1465
|
...sessionRows,
|
|
1178
1466
|
sep,
|
|
1179
1467
|
actionsRow,
|
|
1180
1468
|
bot,
|
|
1181
1469
|
];
|
|
1470
|
+
// ── Stale session hint ──────────────────────────────────────────────────
|
|
1471
|
+
if (staleCount >= 3) {
|
|
1472
|
+
process.stdout.write(`\x1b[2m${staleCount} stale sessions (>7d) — press s → archive to clean up\x1b[0m\n`);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1182
1475
|
process.stdout.write(lines.join('\n') + '\n');
|
|
1183
1476
|
process.stdout.write(`\x1b[2mPowered by data-tools · Steve Moraco\x1b[0m\n\n`);
|
|
1184
1477
|
|
|
@@ -1267,7 +1560,7 @@ async function mainScreen(rl, ask) {
|
|
|
1267
1560
|
// Single-key commands only fire when buffer is empty
|
|
1268
1561
|
if (taskBuffer.length === 0) {
|
|
1269
1562
|
const lower = str.toLowerCase();
|
|
1270
|
-
if (lower === 'n' || lower === 's' || lower === 'q' || lower === '/') {
|
|
1563
|
+
if (lower === 'n' || lower === 's' || lower === 'q' || lower === '/' || lower === 'i') {
|
|
1271
1564
|
cleanup();
|
|
1272
1565
|
process.stdout.write('\n');
|
|
1273
1566
|
resolve(lower);
|
|
@@ -1372,6 +1665,7 @@ async function mainScreen(rl, ask) {
|
|
|
1372
1665
|
}
|
|
1373
1666
|
|
|
1374
1667
|
if (choice === 's') { return { next: 'settings' }; }
|
|
1668
|
+
if (choice === 'i') { return { next: 'import-picker' }; }
|
|
1375
1669
|
if (choice === 'q' || choice === 'exit') { return { next: 'exit' }; }
|
|
1376
1670
|
|
|
1377
1671
|
return { next: 'main' };
|
|
@@ -1408,6 +1702,236 @@ async function newSessionScreen(rl, ask) {
|
|
|
1408
1702
|
return { next: 'main' };
|
|
1409
1703
|
}
|
|
1410
1704
|
|
|
1705
|
+
// ─── Screen: importPickerScreen ──────────────────────────────────────────────
|
|
1706
|
+
|
|
1707
|
+
async function importPickerScreen() {
|
|
1708
|
+
const cwd = process.cwd();
|
|
1709
|
+
|
|
1710
|
+
// Load all available sessions from data-tools
|
|
1711
|
+
const allSessions = importReplitSessions(cwd);
|
|
1712
|
+
|
|
1713
|
+
// Load existing session meta to filter already-imported ones
|
|
1714
|
+
const meta = getSessionMeta(cwd);
|
|
1715
|
+
const alreadyImported = new Set(
|
|
1716
|
+
Object.entries(meta)
|
|
1717
|
+
.filter(([, v]) => v.source === 'data-tools')
|
|
1718
|
+
.map(([id]) => id)
|
|
1719
|
+
);
|
|
1720
|
+
|
|
1721
|
+
// Filter out already-imported sessions
|
|
1722
|
+
const candidates = allSessions.filter(s => !alreadyImported.has(s.id));
|
|
1723
|
+
|
|
1724
|
+
// ── Box layout ────────────────────────────────────────────────────────────
|
|
1725
|
+
const termW = process.stdout.columns || 60;
|
|
1726
|
+
const boxW = Math.min(termW - 2, 60);
|
|
1727
|
+
const W = boxW - 4;
|
|
1728
|
+
|
|
1729
|
+
const top = `┌${'─'.repeat(boxW - 2)}┐`;
|
|
1730
|
+
const sep = `├${'─'.repeat(boxW - 2)}┤`;
|
|
1731
|
+
const bot = `└${'─'.repeat(boxW - 2)}┘`;
|
|
1732
|
+
|
|
1733
|
+
const row = (content) => makeBoxRow(content, W);
|
|
1734
|
+
|
|
1735
|
+
// Helper: wait for any keypress (used in edge-case screens)
|
|
1736
|
+
const waitKey = async () => {
|
|
1737
|
+
const rl2 = await import('node:readline');
|
|
1738
|
+
rl2.emitKeypressEvents(process.stdin);
|
|
1739
|
+
await new Promise(resolve => {
|
|
1740
|
+
const wasRaw2 = process.stdin.isRaw;
|
|
1741
|
+
const canRaw2 = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
|
|
1742
|
+
if (canRaw2) process.stdin.setRawMode(true);
|
|
1743
|
+
const onKey2 = () => {
|
|
1744
|
+
process.stdin.removeListener('keypress', onKey2);
|
|
1745
|
+
if (canRaw2) { try { process.stdin.setRawMode(wasRaw2 || false); } catch {} }
|
|
1746
|
+
resolve();
|
|
1747
|
+
};
|
|
1748
|
+
process.stdin.once('keypress', onKey2);
|
|
1749
|
+
});
|
|
1750
|
+
};
|
|
1751
|
+
|
|
1752
|
+
// Handle edge cases
|
|
1753
|
+
if (allSessions.length === 0) {
|
|
1754
|
+
process.stdout.write('\n');
|
|
1755
|
+
process.stdout.write(top + '\n');
|
|
1756
|
+
process.stdout.write(row('Import from data-tools') + '\n');
|
|
1757
|
+
process.stdout.write(sep + '\n');
|
|
1758
|
+
process.stdout.write(row('No data-tools sessions found.') + '\n');
|
|
1759
|
+
process.stdout.write(row('Install replit-tools: npm i -g replit-tools') + '\n');
|
|
1760
|
+
process.stdout.write(sep + '\n');
|
|
1761
|
+
process.stdout.write(row('Press any key to go back...') + '\n');
|
|
1762
|
+
process.stdout.write(bot + '\n\n');
|
|
1763
|
+
await waitKey();
|
|
1764
|
+
return { next: 'main' };
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
if (candidates.length === 0) {
|
|
1768
|
+
process.stdout.write('\n');
|
|
1769
|
+
process.stdout.write(top + '\n');
|
|
1770
|
+
process.stdout.write(row('Import from data-tools') + '\n');
|
|
1771
|
+
process.stdout.write(sep + '\n');
|
|
1772
|
+
process.stdout.write(row(`All ${allSessions.length} sessions already imported.`) + '\n');
|
|
1773
|
+
process.stdout.write(sep + '\n');
|
|
1774
|
+
process.stdout.write(row('Press any key to go back...') + '\n');
|
|
1775
|
+
process.stdout.write(bot + '\n\n');
|
|
1776
|
+
await waitKey();
|
|
1777
|
+
return { next: 'main' };
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// Pre-select sessions < 3 days old
|
|
1781
|
+
const threeDaysMs = 3 * 24 * 60 * 60 * 1000;
|
|
1782
|
+
const selected = new Set(
|
|
1783
|
+
candidates
|
|
1784
|
+
.filter(s => s.lastActive && (Date.now() - new Date(s.lastActive).getTime()) < threeDaysMs)
|
|
1785
|
+
.map(s => s.id)
|
|
1786
|
+
);
|
|
1787
|
+
|
|
1788
|
+
let cursor = 0;
|
|
1789
|
+
|
|
1790
|
+
const renderPicker = () => {
|
|
1791
|
+
process.stdout.write('\x1b[2J\x1b[H'); // clear screen
|
|
1792
|
+
|
|
1793
|
+
const headerTitle = 'Import from data-tools';
|
|
1794
|
+
const footerLine = '↑↓ Navigate Space Toggle Enter Import q Back';
|
|
1795
|
+
|
|
1796
|
+
process.stdout.write('\n');
|
|
1797
|
+
process.stdout.write(top + '\n');
|
|
1798
|
+
process.stdout.write(row(headerTitle) + '\n');
|
|
1799
|
+
process.stdout.write(sep + '\n');
|
|
1800
|
+
|
|
1801
|
+
candidates.forEach((sess, i) => {
|
|
1802
|
+
const isCursor = i === cursor;
|
|
1803
|
+
const isSelected = selected.has(sess.id);
|
|
1804
|
+
const check = isSelected ? '☑' : '☐';
|
|
1805
|
+
const cursor_ch = isCursor ? '▸ ' : ' ';
|
|
1806
|
+
|
|
1807
|
+
// Format age compactly
|
|
1808
|
+
const ageStr = sess.age || '';
|
|
1809
|
+
// Message count
|
|
1810
|
+
const msgCount = sess.promptCount ?? sess.messageCount ?? 0;
|
|
1811
|
+
const msgStr = `${msgCount} msgs`;
|
|
1812
|
+
|
|
1813
|
+
// Name: truncate to fit
|
|
1814
|
+
// Layout: "cursor_ch(2) check(1) space(1) name age msgs"
|
|
1815
|
+
// chrome = 2 + 1 + 1 + 2 + ageStr.length + 2 + msgStr.length = 8 + ageStr.length + msgStr.length
|
|
1816
|
+
const chrome = 2 + 1 + 1 + 2 + ageStr.length + 2 + msgStr.length;
|
|
1817
|
+
const nameMax = Math.max(0, W - chrome);
|
|
1818
|
+
let name = sess.name || sess.id.slice(0, 8);
|
|
1819
|
+
if (name.length > nameMax) name = name.slice(0, nameMax - 3) + '...';
|
|
1820
|
+
else name = name.padEnd(nameMax);
|
|
1821
|
+
|
|
1822
|
+
const line = `${cursor_ch}${check} ${name} ${ageStr} ${msgStr}`;
|
|
1823
|
+
// Highlight cursor row with dim inverse
|
|
1824
|
+
const renderedLine = isCursor
|
|
1825
|
+
? `\x1b[7m${cursor_ch}${check} ${name} ${ageStr} ${msgStr}\x1b[0m`
|
|
1826
|
+
: line;
|
|
1827
|
+
process.stdout.write(row(renderedLine) + '\n');
|
|
1828
|
+
});
|
|
1829
|
+
|
|
1830
|
+
process.stdout.write(sep + '\n');
|
|
1831
|
+
process.stdout.write(row(footerLine) + '\n');
|
|
1832
|
+
process.stdout.write(bot + '\n\n');
|
|
1833
|
+
};
|
|
1834
|
+
|
|
1835
|
+
// Run the interactive picker
|
|
1836
|
+
const readline = await import('node:readline');
|
|
1837
|
+
readline.emitKeypressEvents(process.stdin);
|
|
1838
|
+
|
|
1839
|
+
const result = await new Promise((resolve) => {
|
|
1840
|
+
const wasRaw = process.stdin.isRaw;
|
|
1841
|
+
const canRaw = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
|
|
1842
|
+
if (canRaw) process.stdin.setRawMode(true);
|
|
1843
|
+
|
|
1844
|
+
const cleanup = () => {
|
|
1845
|
+
process.stdin.removeListener('keypress', onKey);
|
|
1846
|
+
if (canRaw) {
|
|
1847
|
+
try { process.stdin.setRawMode(wasRaw || false); } catch {}
|
|
1848
|
+
}
|
|
1849
|
+
};
|
|
1850
|
+
|
|
1851
|
+
renderPicker();
|
|
1852
|
+
|
|
1853
|
+
const onKey = (str, key) => {
|
|
1854
|
+
if (!key) return;
|
|
1855
|
+
const name = key.name || '';
|
|
1856
|
+
const seq = key.sequence || str || '';
|
|
1857
|
+
|
|
1858
|
+
// Ctrl-C / Ctrl-D → exit to main
|
|
1859
|
+
if (key.ctrl && (name === 'c' || name === 'd')) {
|
|
1860
|
+
cleanup();
|
|
1861
|
+
process.stdout.write('\n');
|
|
1862
|
+
resolve({ action: 'back' });
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// q or Escape → back
|
|
1867
|
+
if (name === 'escape' || (str && str.toLowerCase() === 'q')) {
|
|
1868
|
+
cleanup();
|
|
1869
|
+
process.stdout.write('\n');
|
|
1870
|
+
resolve({ action: 'back' });
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
// Arrow up
|
|
1875
|
+
if (name === 'up') {
|
|
1876
|
+
cursor = Math.max(0, cursor - 1);
|
|
1877
|
+
renderPicker();
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
// Arrow down
|
|
1882
|
+
if (name === 'down') {
|
|
1883
|
+
cursor = Math.min(candidates.length - 1, cursor + 1);
|
|
1884
|
+
renderPicker();
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// Space → toggle selection
|
|
1889
|
+
if (seq === ' ') {
|
|
1890
|
+
const id = candidates[cursor].id;
|
|
1891
|
+
if (selected.has(id)) selected.delete(id);
|
|
1892
|
+
else selected.add(id);
|
|
1893
|
+
renderPicker();
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
// Enter → import
|
|
1898
|
+
if (name === 'return' || name === 'enter' || seq === '\r' || seq === '\n') {
|
|
1899
|
+
cleanup();
|
|
1900
|
+
process.stdout.write('\n');
|
|
1901
|
+
resolve({ action: 'import', ids: [...selected] });
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
};
|
|
1905
|
+
|
|
1906
|
+
process.stdin.on('keypress', onKey);
|
|
1907
|
+
});
|
|
1908
|
+
|
|
1909
|
+
if (result.action === 'back' || result.ids.length === 0) {
|
|
1910
|
+
return { next: 'main' };
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// Persist imported sessions to sessions.json
|
|
1914
|
+
const updatedMeta = getSessionMeta(cwd);
|
|
1915
|
+
const now = new Date().toISOString();
|
|
1916
|
+
let importCount = 0;
|
|
1917
|
+
for (const id of result.ids) {
|
|
1918
|
+
const sess = candidates.find(s => s.id === id);
|
|
1919
|
+
if (!sess) continue;
|
|
1920
|
+
updatedMeta[id] = {
|
|
1921
|
+
...updatedMeta[id],
|
|
1922
|
+
source: 'data-tools',
|
|
1923
|
+
importedAt: now,
|
|
1924
|
+
createdAt: updatedMeta[id]?.createdAt ?? now,
|
|
1925
|
+
};
|
|
1926
|
+
importCount++;
|
|
1927
|
+
}
|
|
1928
|
+
saveSessionMeta(updatedMeta, cwd);
|
|
1929
|
+
|
|
1930
|
+
process.stdout.write(`✓ Imported ${importCount} session${importCount !== 1 ? 's' : ''} from data-tools\n\n`);
|
|
1931
|
+
|
|
1932
|
+
return { next: 'main' };
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1411
1935
|
// ─── Screen: settingsScreen ───────────────────────────────────────────────────
|
|
1412
1936
|
|
|
1413
1937
|
async function settingsScreen(rl, ask) {
|
|
@@ -1447,15 +1971,7 @@ async function settingsScreen(rl, ask) {
|
|
|
1447
1971
|
if (choice === 'e') { return { next: 'sessions' }; }
|
|
1448
1972
|
|
|
1449
1973
|
if (choice === 'i') {
|
|
1450
|
-
|
|
1451
|
-
if (sessions.length === 0) {
|
|
1452
|
-
process.stdout.write('\n No replit-tools sessions found to import.\n\n');
|
|
1453
|
-
} else {
|
|
1454
|
-
process.stdout.write(`\n Found ${sessions.length} sessions from replit-tools.\n`);
|
|
1455
|
-
process.stdout.write(' Sessions are automatically available in the Recent list.\n\n');
|
|
1456
|
-
}
|
|
1457
|
-
await ask(' Press Enter to continue...');
|
|
1458
|
-
return { next: 'settings' };
|
|
1974
|
+
return { next: 'import-picker' };
|
|
1459
1975
|
}
|
|
1460
1976
|
|
|
1461
1977
|
if (choice === 'd') {
|
|
@@ -2439,45 +2955,216 @@ async function sessionDetailScreen(rl, ask, ctx = {}) {
|
|
|
2439
2955
|
// ─── Screen: sessionsScreen ───────────────────────────────────────────────────
|
|
2440
2956
|
|
|
2441
2957
|
const CATEGORIES = ['security', 'ui', 'refactor', 'bugfix', 'testing', 'devops', 'planning'];
|
|
2958
|
+
const STALE_DAYS = 7;
|
|
2442
2959
|
|
|
2960
|
+
/**
|
|
2961
|
+
* Return a compact status badge string for a session row (plain text, no ANSI).
|
|
2962
|
+
*/
|
|
2963
|
+
function sessionBadge(sess) {
|
|
2964
|
+
if (sess.isActive) return '[active]';
|
|
2965
|
+
const ageMs = sess.lastActive ? Date.now() - new Date(sess.lastActive).getTime() : 0;
|
|
2966
|
+
if (ageMs >= STALE_DAYS * 86400000) return '[stale]';
|
|
2967
|
+
if (sess.tool === 'codex') return '[dt]';
|
|
2968
|
+
return '';
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
/**
|
|
2972
|
+
* Interactive full session list with arrow-key navigation.
|
|
2973
|
+
* Enter = resume, x = archive, r = rename, q/Esc = back to dashboard.
|
|
2974
|
+
*/
|
|
2443
2975
|
async function sessionsScreen(rl, ask) {
|
|
2444
2976
|
const cwd = process.cwd();
|
|
2445
|
-
const sessions = enrichSessions(importReplitSessions(cwd), cwd).slice(0, 9);
|
|
2446
2977
|
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2978
|
+
// Load all active sessions (no slice limit)
|
|
2979
|
+
let sessions = enrichSessions(importReplitSessions(cwd), cwd);
|
|
2980
|
+
|
|
2981
|
+
// ── Box geometry ────────────────────────────────────────────────────────────
|
|
2982
|
+
const termW = process.stdout.columns || 60;
|
|
2983
|
+
const boxW = Math.min(termW - 2, 52);
|
|
2984
|
+
const W = boxW - 4;
|
|
2985
|
+
|
|
2986
|
+
const top = `┌${'─'.repeat(boxW - 2)}┐`;
|
|
2987
|
+
const sep = `├${'─'.repeat(boxW - 2)}┤`;
|
|
2988
|
+
const bot = `└${'─'.repeat(boxW - 2)}┘`;
|
|
2450
2989
|
|
|
2451
2990
|
if (sessions.length === 0) {
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2991
|
+
process.stdout.write('\n' + top + '\n');
|
|
2992
|
+
process.stdout.write(makeBoxRow('Sessions', W) + '\n');
|
|
2993
|
+
process.stdout.write(sep + '\n');
|
|
2994
|
+
process.stdout.write(makeBoxRow('No sessions found.', W) + '\n');
|
|
2995
|
+
process.stdout.write(sep + '\n');
|
|
2996
|
+
process.stdout.write(makeBoxRow('q Back', W) + '\n');
|
|
2997
|
+
process.stdout.write(bot + '\n\n');
|
|
2998
|
+
await ask(' Press Enter to continue...');
|
|
2999
|
+
return { next: 'main' };
|
|
2457
3000
|
}
|
|
2458
3001
|
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
3002
|
+
/**
|
|
3003
|
+
* Format one session row.
|
|
3004
|
+
* Right side: badge(9) + age(4) + space + count(4) = 18 chars total.
|
|
3005
|
+
*/
|
|
3006
|
+
function formatRow(sess, selected) {
|
|
3007
|
+
const arrow = selected ? '▸ ' : ' ';
|
|
3008
|
+
const badge = sessionBadge(sess);
|
|
3009
|
+
const badgeStr = badge ? badge.padEnd(9) : ' ';
|
|
3010
|
+
const age = (sess.age || '').replace(/ ago$/, '').padStart(4);
|
|
3011
|
+
const count = `(${sess.promptCount ?? 0})`.padStart(4);
|
|
3012
|
+
const right = `${badgeStr}${age} ${count}`;
|
|
3013
|
+
const nameMax = W - 2 - right.length;
|
|
3014
|
+
let name = sess.name || sess.id.slice(0, 8);
|
|
3015
|
+
if (name.length > nameMax) name = name.slice(0, nameMax - 3) + '...';
|
|
3016
|
+
else name = name.padEnd(nameMax);
|
|
3017
|
+
return makeBoxRow(`${arrow}${name}${right}`, W);
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
let cursor = 0;
|
|
3021
|
+
|
|
3022
|
+
function render() {
|
|
3023
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
3024
|
+
process.stdout.write(top + '\n');
|
|
3025
|
+
process.stdout.write(makeBoxRow('Sessions', W) + '\n');
|
|
3026
|
+
process.stdout.write(sep + '\n');
|
|
3027
|
+
for (let i = 0; i < sessions.length; i++) {
|
|
3028
|
+
process.stdout.write(formatRow(sessions[i], i === cursor) + '\n');
|
|
3029
|
+
}
|
|
3030
|
+
process.stdout.write(sep + '\n');
|
|
3031
|
+
process.stdout.write(makeBoxRow('↑↓ Navigate Enter Resume x Archive r Rename', W) + '\n');
|
|
3032
|
+
process.stdout.write(makeBoxRow('q Back', W) + '\n');
|
|
3033
|
+
process.stdout.write(bot + '\n');
|
|
3034
|
+
}
|
|
2465
3035
|
|
|
2466
|
-
|
|
2467
|
-
console.log(' [1-9] Select a session to manage');
|
|
2468
|
-
console.log(' [b] Back');
|
|
2469
|
-
console.log('');
|
|
3036
|
+
render();
|
|
2470
3037
|
|
|
2471
|
-
const
|
|
3038
|
+
const readline = await import('node:readline');
|
|
3039
|
+
readline.emitKeypressEvents(process.stdin, rl);
|
|
2472
3040
|
|
|
2473
|
-
|
|
3041
|
+
const result = await new Promise((resolve) => {
|
|
3042
|
+
const wasRaw = process.stdin.isRaw;
|
|
3043
|
+
const canRaw = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
|
|
3044
|
+
if (canRaw) process.stdin.setRawMode(true);
|
|
2474
3045
|
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
3046
|
+
const cleanup = () => {
|
|
3047
|
+
process.stdin.removeListener('keypress', onKey);
|
|
3048
|
+
if (canRaw) {
|
|
3049
|
+
try { process.stdin.setRawMode(wasRaw || false); } catch {}
|
|
3050
|
+
}
|
|
3051
|
+
};
|
|
3052
|
+
|
|
3053
|
+
const onKey = async (str, key) => {
|
|
3054
|
+
if (!key) return;
|
|
3055
|
+
const kname = key.name || '';
|
|
3056
|
+
|
|
3057
|
+
// Ctrl-C / Ctrl-D → exit
|
|
3058
|
+
if (key.ctrl && (kname === 'c' || kname === 'd')) {
|
|
3059
|
+
cleanup();
|
|
3060
|
+
process.stdout.write('\n');
|
|
3061
|
+
resolve({ next: 'main' });
|
|
3062
|
+
return;
|
|
3063
|
+
}
|
|
2479
3064
|
|
|
2480
|
-
|
|
3065
|
+
// q / Escape → back
|
|
3066
|
+
if (kname === 'q' || kname === 'escape' || str === 'q') {
|
|
3067
|
+
cleanup();
|
|
3068
|
+
process.stdout.write('\n');
|
|
3069
|
+
resolve({ next: 'main' });
|
|
3070
|
+
return;
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
// Arrow up
|
|
3074
|
+
if (kname === 'up') {
|
|
3075
|
+
cursor = Math.max(0, cursor - 1);
|
|
3076
|
+
render();
|
|
3077
|
+
return;
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
// Arrow down
|
|
3081
|
+
if (kname === 'down') {
|
|
3082
|
+
cursor = Math.min(sessions.length - 1, cursor + 1);
|
|
3083
|
+
render();
|
|
3084
|
+
return;
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
// Enter → resume highlighted session
|
|
3088
|
+
if (kname === 'return' || kname === 'enter') {
|
|
3089
|
+
const sess = sessions[cursor];
|
|
3090
|
+
cleanup();
|
|
3091
|
+
process.stdout.write('\n');
|
|
3092
|
+
process.stdout.write(`\n Launching: claude --resume ${sess.id}\n\n`);
|
|
3093
|
+
const { spawnSync } = await import('node:child_process');
|
|
3094
|
+
spawnSync('claude', ['--resume', sess.id], { stdio: 'inherit' });
|
|
3095
|
+
saveTerminalState(cwd, getTerminalId(), sess.id, sess.tool || 'claude');
|
|
3096
|
+
resolve({ next: 'main' });
|
|
3097
|
+
return;
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
// x → archive highlighted session (non-destructive)
|
|
3101
|
+
if (str === 'x' || str === 'X') {
|
|
3102
|
+
const sess = sessions[cursor];
|
|
3103
|
+
archiveSession(sess.id, cwd);
|
|
3104
|
+
sessions = sessions.filter(s => s.id !== sess.id);
|
|
3105
|
+
if (sessions.length === 0) {
|
|
3106
|
+
cleanup();
|
|
3107
|
+
process.stdout.write('\n');
|
|
3108
|
+
resolve({ next: 'main' });
|
|
3109
|
+
return;
|
|
3110
|
+
}
|
|
3111
|
+
cursor = Math.min(cursor, sessions.length - 1);
|
|
3112
|
+
render();
|
|
3113
|
+
return;
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
// r → rename highlighted session
|
|
3117
|
+
if (str === 'r' || str === 'R') {
|
|
3118
|
+
const sess = sessions[cursor];
|
|
3119
|
+
cleanup();
|
|
3120
|
+
|
|
3121
|
+
// Briefly collect a line of text
|
|
3122
|
+
process.stdout.write('\n New name: ');
|
|
3123
|
+
const newName = await new Promise(res2 => {
|
|
3124
|
+
let buf = '';
|
|
3125
|
+
const onData = (chunk) => {
|
|
3126
|
+
const s = chunk.toString();
|
|
3127
|
+
for (const ch of s) {
|
|
3128
|
+
if (ch === '\n' || ch === '\r') {
|
|
3129
|
+
process.stdin.removeListener('data', onData);
|
|
3130
|
+
process.stdout.write('\n');
|
|
3131
|
+
res2(buf.trim());
|
|
3132
|
+
return;
|
|
3133
|
+
}
|
|
3134
|
+
if (ch === '\x7f' || ch === '\b') {
|
|
3135
|
+
if (buf.length > 0) {
|
|
3136
|
+
buf = buf.slice(0, -1);
|
|
3137
|
+
process.stdout.write('\b \b');
|
|
3138
|
+
}
|
|
3139
|
+
} else {
|
|
3140
|
+
buf += ch;
|
|
3141
|
+
process.stdout.write(ch);
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
};
|
|
3145
|
+
process.stdin.on('data', onData);
|
|
3146
|
+
});
|
|
3147
|
+
|
|
3148
|
+
if (newName) {
|
|
3149
|
+
renameSession(sess.id, newName, cwd);
|
|
3150
|
+
sessions[cursor] = { ...sess, name: newName };
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
// Re-enable raw mode and re-attach listener
|
|
3154
|
+
if (canRaw) {
|
|
3155
|
+
try { process.stdin.setRawMode(true); } catch {}
|
|
3156
|
+
}
|
|
3157
|
+
readline.emitKeypressEvents(process.stdin, rl);
|
|
3158
|
+
process.stdin.on('keypress', onKey);
|
|
3159
|
+
render();
|
|
3160
|
+
return;
|
|
3161
|
+
}
|
|
3162
|
+
};
|
|
3163
|
+
|
|
3164
|
+
process.stdin.on('keypress', onKey);
|
|
3165
|
+
});
|
|
3166
|
+
|
|
3167
|
+
return result;
|
|
2481
3168
|
}
|
|
2482
3169
|
|
|
2483
3170
|
async function sessionManageScreen(rl, ask, ctx = {}) {
|
|
@@ -2568,6 +3255,7 @@ const SCREENS = {
|
|
|
2568
3255
|
main: mainScreen,
|
|
2569
3256
|
'new-session': newSessionScreen,
|
|
2570
3257
|
settings: settingsScreen,
|
|
3258
|
+
'import-picker': importPickerScreen,
|
|
2571
3259
|
subscriptions: subscriptionsScreen,
|
|
2572
3260
|
dashboard: dashboardScreen,
|
|
2573
3261
|
auth: authScreen,
|
|
@@ -2724,22 +3412,25 @@ async function cmdSpecialistGo(specialist, args) {
|
|
|
2724
3412
|
vtrace(`Dual-brain: ${decision.dualBrain ? 'yes' : 'no'}`);
|
|
2725
3413
|
}
|
|
2726
3414
|
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
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
|
+
}
|
|
2733
3424
|
|
|
2734
3425
|
if (dryRun) {
|
|
2735
3426
|
console.log('\n(dry-run — not executing)');
|
|
2736
3427
|
return;
|
|
2737
3428
|
}
|
|
2738
3429
|
|
|
2739
|
-
console.log('\nDispatching...');
|
|
3430
|
+
if (verbose) console.log('\nDispatching...');
|
|
2740
3431
|
let result;
|
|
2741
3432
|
if (decision.dualBrain) {
|
|
2742
|
-
result = await dispatchDualBrain({ decision, prompt, files, cwd });
|
|
3433
|
+
result = await dispatchDualBrain({ decision, prompt, files, cwd, verbose });
|
|
2743
3434
|
console.log(`\nConsensus: ${result.consensus}`);
|
|
2744
3435
|
if (result.claude?.summary) console.log(`Claude : ${result.claude.summary}`);
|
|
2745
3436
|
if (result.openai?.summary) console.log(`OpenAI : ${result.openai.summary}`);
|
|
@@ -2753,7 +3444,7 @@ async function cmdSpecialistGo(specialist, args) {
|
|
|
2753
3444
|
nextAction: null,
|
|
2754
3445
|
}, cwd);
|
|
2755
3446
|
} else {
|
|
2756
|
-
result = await dispatch({ decision, prompt, files, cwd });
|
|
3447
|
+
result = await dispatch({ decision, prompt, files, cwd, verbose });
|
|
2757
3448
|
const statusLine = result.status === 'completed' ? 'Done' : `Failed (exit ${result.exitCode})`;
|
|
2758
3449
|
console.log(`\n${statusLine} in ${(result.durationMs / 1000).toFixed(1)}s`);
|
|
2759
3450
|
if (result.summary) console.log(result.summary);
|
|
@@ -2813,13 +3504,26 @@ async function main() {
|
|
|
2813
3504
|
await runScreens('main');
|
|
2814
3505
|
}
|
|
2815
3506
|
} else {
|
|
2816
|
-
// Non-TTY:
|
|
2817
|
-
const
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
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
|
+
}
|
|
2823
3527
|
}
|
|
2824
3528
|
return;
|
|
2825
3529
|
}
|
|
@@ -2912,6 +3616,43 @@ fi
|
|
|
2912
3616
|
return;
|
|
2913
3617
|
}
|
|
2914
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
|
+
|
|
2915
3656
|
process.stderr.write(`Unknown command: ${cmd}\nRun "dual-brain --help" for usage.\n`);
|
|
2916
3657
|
process.exit(1);
|
|
2917
3658
|
}
|