dual-brain 0.1.4 → 0.1.6
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 +474 -45
- package/package.json +5 -3
- package/scripts/verify-publish.mjs +26 -0
- 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
|
|
|
@@ -1094,7 +1094,12 @@ async function mainScreen(rl, ask) {
|
|
|
1094
1094
|
} catch {}
|
|
1095
1095
|
|
|
1096
1096
|
// Gather recent sessions
|
|
1097
|
-
const
|
|
1097
|
+
const allSessions = enrichSessions(importReplitSessions(cwd), cwd);
|
|
1098
|
+
const recentSessions = allSessions.slice(0, 3);
|
|
1099
|
+
const staleCount = allSessions.filter(s => {
|
|
1100
|
+
const ageMs = s.lastActive ? Date.now() - new Date(s.lastActive).getTime() : 0;
|
|
1101
|
+
return ageMs >= 7 * 86400000;
|
|
1102
|
+
}).length;
|
|
1098
1103
|
|
|
1099
1104
|
// Detect data-tools version
|
|
1100
1105
|
const rtMain = detectReplitTools(cwd);
|
|
@@ -1152,21 +1157,45 @@ async function mainScreen(rl, ask) {
|
|
|
1152
1157
|
? sess.project.replace(/^-/, '/').replace(/-/g, '/')
|
|
1153
1158
|
: sess.id.slice(0, 8);
|
|
1154
1159
|
}
|
|
1155
|
-
|
|
1160
|
+
|
|
1161
|
+
// Build badges (ANSI color; track visible width separately)
|
|
1162
|
+
const badges = [];
|
|
1163
|
+
const badgeVisible = [];
|
|
1164
|
+
if (sess.isActive) {
|
|
1165
|
+
badges.push('\x1b[32m[active]\x1b[0m');
|
|
1166
|
+
badgeVisible.push('[active]'.length);
|
|
1167
|
+
}
|
|
1168
|
+
if (sess.source === 'replit-tools' || sess.source === 'data-tools') {
|
|
1169
|
+
badges.push('\x1b[36m[dt]\x1b[0m');
|
|
1170
|
+
badgeVisible.push('[dt]'.length);
|
|
1171
|
+
}
|
|
1172
|
+
const ageMs = sess.lastActive ? Date.now() - new Date(sess.lastActive).getTime() : 0;
|
|
1173
|
+
if (ageMs > 7 * 24 * 3600 * 1000) {
|
|
1174
|
+
badges.push('\x1b[2m[stale]\x1b[0m');
|
|
1175
|
+
badgeVisible.push('[stale]'.length);
|
|
1176
|
+
}
|
|
1177
|
+
const msgCount = sess.messageCount ?? sess.promptCount ?? 0;
|
|
1178
|
+
const msgBadge = `\x1b[2m(${msgCount})\x1b[0m`;
|
|
1179
|
+
const msgBadgeW = `(${msgCount})`.length;
|
|
1180
|
+
|
|
1181
|
+
const badgeStr = badges.join('');
|
|
1182
|
+
const badgesW = badgeVisible.reduce((s, n) => s + n, 0);
|
|
1183
|
+
|
|
1184
|
+
// Layout: "{num} {name...}{badges} {age} {msg}"
|
|
1156
1185
|
const numStr = String(i + 1);
|
|
1157
1186
|
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) + '...'
|
|
1187
|
+
// Available for name: W minus fixed chrome, badge widths, and msg badge
|
|
1188
|
+
const nameMax = W - numStr.length - 2 - badgesW - 2 - ageStr.length - 2 - msgBadgeW;
|
|
1189
|
+
const truncName = rawName.length > nameMax
|
|
1190
|
+
? rawName.slice(0, Math.max(0, nameMax - 3)) + '...'
|
|
1162
1191
|
: rawName.padEnd(nameMax);
|
|
1163
|
-
const content = `${numStr} ${
|
|
1192
|
+
const content = `${numStr} ${truncName}${badgeStr} ${ageStr} ${msgBadge}`;
|
|
1164
1193
|
sessionRows.push(row(content));
|
|
1165
1194
|
});
|
|
1166
1195
|
}
|
|
1167
1196
|
|
|
1168
1197
|
// ── Actions bar ───────────────────────────────────────────────────────────
|
|
1169
|
-
const actionsContent = '↵ Resume n New / Search s Settings q Quit';
|
|
1198
|
+
const actionsContent = '↵ Resume n New / Search i Import s Settings q Quit';
|
|
1170
1199
|
const actionsRow = row(actionsContent);
|
|
1171
1200
|
|
|
1172
1201
|
// ── Print the full box ────────────────────────────────────────────────────
|
|
@@ -1179,6 +1208,11 @@ async function mainScreen(rl, ask) {
|
|
|
1179
1208
|
actionsRow,
|
|
1180
1209
|
bot,
|
|
1181
1210
|
];
|
|
1211
|
+
// ── Stale session hint ──────────────────────────────────────────────────
|
|
1212
|
+
if (staleCount >= 3) {
|
|
1213
|
+
process.stdout.write(`\x1b[2m${staleCount} stale sessions (>7d) — press s → archive to clean up\x1b[0m\n`);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1182
1216
|
process.stdout.write(lines.join('\n') + '\n');
|
|
1183
1217
|
process.stdout.write(`\x1b[2mPowered by data-tools · Steve Moraco\x1b[0m\n\n`);
|
|
1184
1218
|
|
|
@@ -1267,7 +1301,7 @@ async function mainScreen(rl, ask) {
|
|
|
1267
1301
|
// Single-key commands only fire when buffer is empty
|
|
1268
1302
|
if (taskBuffer.length === 0) {
|
|
1269
1303
|
const lower = str.toLowerCase();
|
|
1270
|
-
if (lower === 'n' || lower === 's' || lower === 'q' || lower === '/') {
|
|
1304
|
+
if (lower === 'n' || lower === 's' || lower === 'q' || lower === '/' || lower === 'i') {
|
|
1271
1305
|
cleanup();
|
|
1272
1306
|
process.stdout.write('\n');
|
|
1273
1307
|
resolve(lower);
|
|
@@ -1372,6 +1406,7 @@ async function mainScreen(rl, ask) {
|
|
|
1372
1406
|
}
|
|
1373
1407
|
|
|
1374
1408
|
if (choice === 's') { return { next: 'settings' }; }
|
|
1409
|
+
if (choice === 'i') { return { next: 'import-picker' }; }
|
|
1375
1410
|
if (choice === 'q' || choice === 'exit') { return { next: 'exit' }; }
|
|
1376
1411
|
|
|
1377
1412
|
return { next: 'main' };
|
|
@@ -1408,6 +1443,236 @@ async function newSessionScreen(rl, ask) {
|
|
|
1408
1443
|
return { next: 'main' };
|
|
1409
1444
|
}
|
|
1410
1445
|
|
|
1446
|
+
// ─── Screen: importPickerScreen ──────────────────────────────────────────────
|
|
1447
|
+
|
|
1448
|
+
async function importPickerScreen() {
|
|
1449
|
+
const cwd = process.cwd();
|
|
1450
|
+
|
|
1451
|
+
// Load all available sessions from data-tools
|
|
1452
|
+
const allSessions = importReplitSessions(cwd);
|
|
1453
|
+
|
|
1454
|
+
// Load existing session meta to filter already-imported ones
|
|
1455
|
+
const meta = getSessionMeta(cwd);
|
|
1456
|
+
const alreadyImported = new Set(
|
|
1457
|
+
Object.entries(meta)
|
|
1458
|
+
.filter(([, v]) => v.source === 'data-tools')
|
|
1459
|
+
.map(([id]) => id)
|
|
1460
|
+
);
|
|
1461
|
+
|
|
1462
|
+
// Filter out already-imported sessions
|
|
1463
|
+
const candidates = allSessions.filter(s => !alreadyImported.has(s.id));
|
|
1464
|
+
|
|
1465
|
+
// ── Box layout ────────────────────────────────────────────────────────────
|
|
1466
|
+
const termW = process.stdout.columns || 60;
|
|
1467
|
+
const boxW = Math.min(termW - 2, 60);
|
|
1468
|
+
const W = boxW - 4;
|
|
1469
|
+
|
|
1470
|
+
const top = `┌${'─'.repeat(boxW - 2)}┐`;
|
|
1471
|
+
const sep = `├${'─'.repeat(boxW - 2)}┤`;
|
|
1472
|
+
const bot = `└${'─'.repeat(boxW - 2)}┘`;
|
|
1473
|
+
|
|
1474
|
+
const row = (content) => makeBoxRow(content, W);
|
|
1475
|
+
|
|
1476
|
+
// Helper: wait for any keypress (used in edge-case screens)
|
|
1477
|
+
const waitKey = async () => {
|
|
1478
|
+
const rl2 = await import('node:readline');
|
|
1479
|
+
rl2.emitKeypressEvents(process.stdin);
|
|
1480
|
+
await new Promise(resolve => {
|
|
1481
|
+
const wasRaw2 = process.stdin.isRaw;
|
|
1482
|
+
const canRaw2 = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
|
|
1483
|
+
if (canRaw2) process.stdin.setRawMode(true);
|
|
1484
|
+
const onKey2 = () => {
|
|
1485
|
+
process.stdin.removeListener('keypress', onKey2);
|
|
1486
|
+
if (canRaw2) { try { process.stdin.setRawMode(wasRaw2 || false); } catch {} }
|
|
1487
|
+
resolve();
|
|
1488
|
+
};
|
|
1489
|
+
process.stdin.once('keypress', onKey2);
|
|
1490
|
+
});
|
|
1491
|
+
};
|
|
1492
|
+
|
|
1493
|
+
// Handle edge cases
|
|
1494
|
+
if (allSessions.length === 0) {
|
|
1495
|
+
process.stdout.write('\n');
|
|
1496
|
+
process.stdout.write(top + '\n');
|
|
1497
|
+
process.stdout.write(row('Import from data-tools') + '\n');
|
|
1498
|
+
process.stdout.write(sep + '\n');
|
|
1499
|
+
process.stdout.write(row('No data-tools sessions found.') + '\n');
|
|
1500
|
+
process.stdout.write(row('Install replit-tools: npm i -g replit-tools') + '\n');
|
|
1501
|
+
process.stdout.write(sep + '\n');
|
|
1502
|
+
process.stdout.write(row('Press any key to go back...') + '\n');
|
|
1503
|
+
process.stdout.write(bot + '\n\n');
|
|
1504
|
+
await waitKey();
|
|
1505
|
+
return { next: 'main' };
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
if (candidates.length === 0) {
|
|
1509
|
+
process.stdout.write('\n');
|
|
1510
|
+
process.stdout.write(top + '\n');
|
|
1511
|
+
process.stdout.write(row('Import from data-tools') + '\n');
|
|
1512
|
+
process.stdout.write(sep + '\n');
|
|
1513
|
+
process.stdout.write(row(`All ${allSessions.length} sessions already imported.`) + '\n');
|
|
1514
|
+
process.stdout.write(sep + '\n');
|
|
1515
|
+
process.stdout.write(row('Press any key to go back...') + '\n');
|
|
1516
|
+
process.stdout.write(bot + '\n\n');
|
|
1517
|
+
await waitKey();
|
|
1518
|
+
return { next: 'main' };
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// Pre-select sessions < 3 days old
|
|
1522
|
+
const threeDaysMs = 3 * 24 * 60 * 60 * 1000;
|
|
1523
|
+
const selected = new Set(
|
|
1524
|
+
candidates
|
|
1525
|
+
.filter(s => s.lastActive && (Date.now() - new Date(s.lastActive).getTime()) < threeDaysMs)
|
|
1526
|
+
.map(s => s.id)
|
|
1527
|
+
);
|
|
1528
|
+
|
|
1529
|
+
let cursor = 0;
|
|
1530
|
+
|
|
1531
|
+
const renderPicker = () => {
|
|
1532
|
+
process.stdout.write('\x1b[2J\x1b[H'); // clear screen
|
|
1533
|
+
|
|
1534
|
+
const headerTitle = 'Import from data-tools';
|
|
1535
|
+
const footerLine = '↑↓ Navigate Space Toggle Enter Import q Back';
|
|
1536
|
+
|
|
1537
|
+
process.stdout.write('\n');
|
|
1538
|
+
process.stdout.write(top + '\n');
|
|
1539
|
+
process.stdout.write(row(headerTitle) + '\n');
|
|
1540
|
+
process.stdout.write(sep + '\n');
|
|
1541
|
+
|
|
1542
|
+
candidates.forEach((sess, i) => {
|
|
1543
|
+
const isCursor = i === cursor;
|
|
1544
|
+
const isSelected = selected.has(sess.id);
|
|
1545
|
+
const check = isSelected ? '☑' : '☐';
|
|
1546
|
+
const cursor_ch = isCursor ? '▸ ' : ' ';
|
|
1547
|
+
|
|
1548
|
+
// Format age compactly
|
|
1549
|
+
const ageStr = sess.age || '';
|
|
1550
|
+
// Message count
|
|
1551
|
+
const msgCount = sess.promptCount ?? sess.messageCount ?? 0;
|
|
1552
|
+
const msgStr = `${msgCount} msgs`;
|
|
1553
|
+
|
|
1554
|
+
// Name: truncate to fit
|
|
1555
|
+
// Layout: "cursor_ch(2) check(1) space(1) name age msgs"
|
|
1556
|
+
// chrome = 2 + 1 + 1 + 2 + ageStr.length + 2 + msgStr.length = 8 + ageStr.length + msgStr.length
|
|
1557
|
+
const chrome = 2 + 1 + 1 + 2 + ageStr.length + 2 + msgStr.length;
|
|
1558
|
+
const nameMax = Math.max(0, W - chrome);
|
|
1559
|
+
let name = sess.name || sess.id.slice(0, 8);
|
|
1560
|
+
if (name.length > nameMax) name = name.slice(0, nameMax - 3) + '...';
|
|
1561
|
+
else name = name.padEnd(nameMax);
|
|
1562
|
+
|
|
1563
|
+
const line = `${cursor_ch}${check} ${name} ${ageStr} ${msgStr}`;
|
|
1564
|
+
// Highlight cursor row with dim inverse
|
|
1565
|
+
const renderedLine = isCursor
|
|
1566
|
+
? `\x1b[7m${cursor_ch}${check} ${name} ${ageStr} ${msgStr}\x1b[0m`
|
|
1567
|
+
: line;
|
|
1568
|
+
process.stdout.write(row(renderedLine) + '\n');
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
process.stdout.write(sep + '\n');
|
|
1572
|
+
process.stdout.write(row(footerLine) + '\n');
|
|
1573
|
+
process.stdout.write(bot + '\n\n');
|
|
1574
|
+
};
|
|
1575
|
+
|
|
1576
|
+
// Run the interactive picker
|
|
1577
|
+
const readline = await import('node:readline');
|
|
1578
|
+
readline.emitKeypressEvents(process.stdin);
|
|
1579
|
+
|
|
1580
|
+
const result = await new Promise((resolve) => {
|
|
1581
|
+
const wasRaw = process.stdin.isRaw;
|
|
1582
|
+
const canRaw = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
|
|
1583
|
+
if (canRaw) process.stdin.setRawMode(true);
|
|
1584
|
+
|
|
1585
|
+
const cleanup = () => {
|
|
1586
|
+
process.stdin.removeListener('keypress', onKey);
|
|
1587
|
+
if (canRaw) {
|
|
1588
|
+
try { process.stdin.setRawMode(wasRaw || false); } catch {}
|
|
1589
|
+
}
|
|
1590
|
+
};
|
|
1591
|
+
|
|
1592
|
+
renderPicker();
|
|
1593
|
+
|
|
1594
|
+
const onKey = (str, key) => {
|
|
1595
|
+
if (!key) return;
|
|
1596
|
+
const name = key.name || '';
|
|
1597
|
+
const seq = key.sequence || str || '';
|
|
1598
|
+
|
|
1599
|
+
// Ctrl-C / Ctrl-D → exit to main
|
|
1600
|
+
if (key.ctrl && (name === 'c' || name === 'd')) {
|
|
1601
|
+
cleanup();
|
|
1602
|
+
process.stdout.write('\n');
|
|
1603
|
+
resolve({ action: 'back' });
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
// q or Escape → back
|
|
1608
|
+
if (name === 'escape' || (str && str.toLowerCase() === 'q')) {
|
|
1609
|
+
cleanup();
|
|
1610
|
+
process.stdout.write('\n');
|
|
1611
|
+
resolve({ action: 'back' });
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// Arrow up
|
|
1616
|
+
if (name === 'up') {
|
|
1617
|
+
cursor = Math.max(0, cursor - 1);
|
|
1618
|
+
renderPicker();
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// Arrow down
|
|
1623
|
+
if (name === 'down') {
|
|
1624
|
+
cursor = Math.min(candidates.length - 1, cursor + 1);
|
|
1625
|
+
renderPicker();
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// Space → toggle selection
|
|
1630
|
+
if (seq === ' ') {
|
|
1631
|
+
const id = candidates[cursor].id;
|
|
1632
|
+
if (selected.has(id)) selected.delete(id);
|
|
1633
|
+
else selected.add(id);
|
|
1634
|
+
renderPicker();
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// Enter → import
|
|
1639
|
+
if (name === 'return' || name === 'enter' || seq === '\r' || seq === '\n') {
|
|
1640
|
+
cleanup();
|
|
1641
|
+
process.stdout.write('\n');
|
|
1642
|
+
resolve({ action: 'import', ids: [...selected] });
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
};
|
|
1646
|
+
|
|
1647
|
+
process.stdin.on('keypress', onKey);
|
|
1648
|
+
});
|
|
1649
|
+
|
|
1650
|
+
if (result.action === 'back' || result.ids.length === 0) {
|
|
1651
|
+
return { next: 'main' };
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
// Persist imported sessions to sessions.json
|
|
1655
|
+
const updatedMeta = getSessionMeta(cwd);
|
|
1656
|
+
const now = new Date().toISOString();
|
|
1657
|
+
let importCount = 0;
|
|
1658
|
+
for (const id of result.ids) {
|
|
1659
|
+
const sess = candidates.find(s => s.id === id);
|
|
1660
|
+
if (!sess) continue;
|
|
1661
|
+
updatedMeta[id] = {
|
|
1662
|
+
...updatedMeta[id],
|
|
1663
|
+
source: 'data-tools',
|
|
1664
|
+
importedAt: now,
|
|
1665
|
+
createdAt: updatedMeta[id]?.createdAt ?? now,
|
|
1666
|
+
};
|
|
1667
|
+
importCount++;
|
|
1668
|
+
}
|
|
1669
|
+
saveSessionMeta(updatedMeta, cwd);
|
|
1670
|
+
|
|
1671
|
+
process.stdout.write(`✓ Imported ${importCount} session${importCount !== 1 ? 's' : ''} from data-tools\n\n`);
|
|
1672
|
+
|
|
1673
|
+
return { next: 'main' };
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1411
1676
|
// ─── Screen: settingsScreen ───────────────────────────────────────────────────
|
|
1412
1677
|
|
|
1413
1678
|
async function settingsScreen(rl, ask) {
|
|
@@ -1447,15 +1712,7 @@ async function settingsScreen(rl, ask) {
|
|
|
1447
1712
|
if (choice === 'e') { return { next: 'sessions' }; }
|
|
1448
1713
|
|
|
1449
1714
|
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' };
|
|
1715
|
+
return { next: 'import-picker' };
|
|
1459
1716
|
}
|
|
1460
1717
|
|
|
1461
1718
|
if (choice === 'd') {
|
|
@@ -2439,45 +2696,216 @@ async function sessionDetailScreen(rl, ask, ctx = {}) {
|
|
|
2439
2696
|
// ─── Screen: sessionsScreen ───────────────────────────────────────────────────
|
|
2440
2697
|
|
|
2441
2698
|
const CATEGORIES = ['security', 'ui', 'refactor', 'bugfix', 'testing', 'devops', 'planning'];
|
|
2699
|
+
const STALE_DAYS = 7;
|
|
2700
|
+
|
|
2701
|
+
/**
|
|
2702
|
+
* Return a compact status badge string for a session row (plain text, no ANSI).
|
|
2703
|
+
*/
|
|
2704
|
+
function sessionBadge(sess) {
|
|
2705
|
+
if (sess.isActive) return '[active]';
|
|
2706
|
+
const ageMs = sess.lastActive ? Date.now() - new Date(sess.lastActive).getTime() : 0;
|
|
2707
|
+
if (ageMs >= STALE_DAYS * 86400000) return '[stale]';
|
|
2708
|
+
if (sess.tool === 'codex') return '[dt]';
|
|
2709
|
+
return '';
|
|
2710
|
+
}
|
|
2442
2711
|
|
|
2712
|
+
/**
|
|
2713
|
+
* Interactive full session list with arrow-key navigation.
|
|
2714
|
+
* Enter = resume, x = archive, r = rename, q/Esc = back to dashboard.
|
|
2715
|
+
*/
|
|
2443
2716
|
async function sessionsScreen(rl, ask) {
|
|
2444
2717
|
const cwd = process.cwd();
|
|
2445
|
-
const sessions = enrichSessions(importReplitSessions(cwd), cwd).slice(0, 9);
|
|
2446
2718
|
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2719
|
+
// Load all active sessions (no slice limit)
|
|
2720
|
+
let sessions = enrichSessions(importReplitSessions(cwd), cwd);
|
|
2721
|
+
|
|
2722
|
+
// ── Box geometry ────────────────────────────────────────────────────────────
|
|
2723
|
+
const termW = process.stdout.columns || 60;
|
|
2724
|
+
const boxW = Math.min(termW - 2, 52);
|
|
2725
|
+
const W = boxW - 4;
|
|
2726
|
+
|
|
2727
|
+
const top = `┌${'─'.repeat(boxW - 2)}┐`;
|
|
2728
|
+
const sep = `├${'─'.repeat(boxW - 2)}┤`;
|
|
2729
|
+
const bot = `└${'─'.repeat(boxW - 2)}┘`;
|
|
2450
2730
|
|
|
2451
2731
|
if (sessions.length === 0) {
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2732
|
+
process.stdout.write('\n' + top + '\n');
|
|
2733
|
+
process.stdout.write(makeBoxRow('Sessions', W) + '\n');
|
|
2734
|
+
process.stdout.write(sep + '\n');
|
|
2735
|
+
process.stdout.write(makeBoxRow('No sessions found.', W) + '\n');
|
|
2736
|
+
process.stdout.write(sep + '\n');
|
|
2737
|
+
process.stdout.write(makeBoxRow('q Back', W) + '\n');
|
|
2738
|
+
process.stdout.write(bot + '\n\n');
|
|
2739
|
+
await ask(' Press Enter to continue...');
|
|
2740
|
+
return { next: 'main' };
|
|
2457
2741
|
}
|
|
2458
2742
|
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2743
|
+
/**
|
|
2744
|
+
* Format one session row.
|
|
2745
|
+
* Right side: badge(9) + age(4) + space + count(4) = 18 chars total.
|
|
2746
|
+
*/
|
|
2747
|
+
function formatRow(sess, selected) {
|
|
2748
|
+
const arrow = selected ? '▸ ' : ' ';
|
|
2749
|
+
const badge = sessionBadge(sess);
|
|
2750
|
+
const badgeStr = badge ? badge.padEnd(9) : ' ';
|
|
2751
|
+
const age = (sess.age || '').replace(/ ago$/, '').padStart(4);
|
|
2752
|
+
const count = `(${sess.promptCount ?? 0})`.padStart(4);
|
|
2753
|
+
const right = `${badgeStr}${age} ${count}`;
|
|
2754
|
+
const nameMax = W - 2 - right.length;
|
|
2755
|
+
let name = sess.name || sess.id.slice(0, 8);
|
|
2756
|
+
if (name.length > nameMax) name = name.slice(0, nameMax - 3) + '...';
|
|
2757
|
+
else name = name.padEnd(nameMax);
|
|
2758
|
+
return makeBoxRow(`${arrow}${name}${right}`, W);
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
let cursor = 0;
|
|
2762
|
+
|
|
2763
|
+
function render() {
|
|
2764
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
2765
|
+
process.stdout.write(top + '\n');
|
|
2766
|
+
process.stdout.write(makeBoxRow('Sessions', W) + '\n');
|
|
2767
|
+
process.stdout.write(sep + '\n');
|
|
2768
|
+
for (let i = 0; i < sessions.length; i++) {
|
|
2769
|
+
process.stdout.write(formatRow(sessions[i], i === cursor) + '\n');
|
|
2770
|
+
}
|
|
2771
|
+
process.stdout.write(sep + '\n');
|
|
2772
|
+
process.stdout.write(makeBoxRow('↑↓ Navigate Enter Resume x Archive r Rename', W) + '\n');
|
|
2773
|
+
process.stdout.write(makeBoxRow('q Back', W) + '\n');
|
|
2774
|
+
process.stdout.write(bot + '\n');
|
|
2775
|
+
}
|
|
2465
2776
|
|
|
2466
|
-
|
|
2467
|
-
console.log(' [1-9] Select a session to manage');
|
|
2468
|
-
console.log(' [b] Back');
|
|
2469
|
-
console.log('');
|
|
2777
|
+
render();
|
|
2470
2778
|
|
|
2471
|
-
const
|
|
2779
|
+
const readline = await import('node:readline');
|
|
2780
|
+
readline.emitKeypressEvents(process.stdin, rl);
|
|
2472
2781
|
|
|
2473
|
-
|
|
2782
|
+
const result = await new Promise((resolve) => {
|
|
2783
|
+
const wasRaw = process.stdin.isRaw;
|
|
2784
|
+
const canRaw = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
|
|
2785
|
+
if (canRaw) process.stdin.setRawMode(true);
|
|
2474
2786
|
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2787
|
+
const cleanup = () => {
|
|
2788
|
+
process.stdin.removeListener('keypress', onKey);
|
|
2789
|
+
if (canRaw) {
|
|
2790
|
+
try { process.stdin.setRawMode(wasRaw || false); } catch {}
|
|
2791
|
+
}
|
|
2792
|
+
};
|
|
2793
|
+
|
|
2794
|
+
const onKey = async (str, key) => {
|
|
2795
|
+
if (!key) return;
|
|
2796
|
+
const kname = key.name || '';
|
|
2797
|
+
|
|
2798
|
+
// Ctrl-C / Ctrl-D → exit
|
|
2799
|
+
if (key.ctrl && (kname === 'c' || kname === 'd')) {
|
|
2800
|
+
cleanup();
|
|
2801
|
+
process.stdout.write('\n');
|
|
2802
|
+
resolve({ next: 'main' });
|
|
2803
|
+
return;
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
// q / Escape → back
|
|
2807
|
+
if (kname === 'q' || kname === 'escape' || str === 'q') {
|
|
2808
|
+
cleanup();
|
|
2809
|
+
process.stdout.write('\n');
|
|
2810
|
+
resolve({ next: 'main' });
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
// Arrow up
|
|
2815
|
+
if (kname === 'up') {
|
|
2816
|
+
cursor = Math.max(0, cursor - 1);
|
|
2817
|
+
render();
|
|
2818
|
+
return;
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
// Arrow down
|
|
2822
|
+
if (kname === 'down') {
|
|
2823
|
+
cursor = Math.min(sessions.length - 1, cursor + 1);
|
|
2824
|
+
render();
|
|
2825
|
+
return;
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
// Enter → resume highlighted session
|
|
2829
|
+
if (kname === 'return' || kname === 'enter') {
|
|
2830
|
+
const sess = sessions[cursor];
|
|
2831
|
+
cleanup();
|
|
2832
|
+
process.stdout.write('\n');
|
|
2833
|
+
process.stdout.write(`\n Launching: claude --resume ${sess.id}\n\n`);
|
|
2834
|
+
const { spawnSync } = await import('node:child_process');
|
|
2835
|
+
spawnSync('claude', ['--resume', sess.id], { stdio: 'inherit' });
|
|
2836
|
+
saveTerminalState(cwd, getTerminalId(), sess.id, sess.tool || 'claude');
|
|
2837
|
+
resolve({ next: 'main' });
|
|
2838
|
+
return;
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
// x → archive highlighted session (non-destructive)
|
|
2842
|
+
if (str === 'x' || str === 'X') {
|
|
2843
|
+
const sess = sessions[cursor];
|
|
2844
|
+
archiveSession(sess.id, cwd);
|
|
2845
|
+
sessions = sessions.filter(s => s.id !== sess.id);
|
|
2846
|
+
if (sessions.length === 0) {
|
|
2847
|
+
cleanup();
|
|
2848
|
+
process.stdout.write('\n');
|
|
2849
|
+
resolve({ next: 'main' });
|
|
2850
|
+
return;
|
|
2851
|
+
}
|
|
2852
|
+
cursor = Math.min(cursor, sessions.length - 1);
|
|
2853
|
+
render();
|
|
2854
|
+
return;
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
// r → rename highlighted session
|
|
2858
|
+
if (str === 'r' || str === 'R') {
|
|
2859
|
+
const sess = sessions[cursor];
|
|
2860
|
+
cleanup();
|
|
2861
|
+
|
|
2862
|
+
// Briefly collect a line of text
|
|
2863
|
+
process.stdout.write('\n New name: ');
|
|
2864
|
+
const newName = await new Promise(res2 => {
|
|
2865
|
+
let buf = '';
|
|
2866
|
+
const onData = (chunk) => {
|
|
2867
|
+
const s = chunk.toString();
|
|
2868
|
+
for (const ch of s) {
|
|
2869
|
+
if (ch === '\n' || ch === '\r') {
|
|
2870
|
+
process.stdin.removeListener('data', onData);
|
|
2871
|
+
process.stdout.write('\n');
|
|
2872
|
+
res2(buf.trim());
|
|
2873
|
+
return;
|
|
2874
|
+
}
|
|
2875
|
+
if (ch === '\x7f' || ch === '\b') {
|
|
2876
|
+
if (buf.length > 0) {
|
|
2877
|
+
buf = buf.slice(0, -1);
|
|
2878
|
+
process.stdout.write('\b \b');
|
|
2879
|
+
}
|
|
2880
|
+
} else {
|
|
2881
|
+
buf += ch;
|
|
2882
|
+
process.stdout.write(ch);
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
};
|
|
2886
|
+
process.stdin.on('data', onData);
|
|
2887
|
+
});
|
|
2888
|
+
|
|
2889
|
+
if (newName) {
|
|
2890
|
+
renameSession(sess.id, newName, cwd);
|
|
2891
|
+
sessions[cursor] = { ...sess, name: newName };
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
// Re-enable raw mode and re-attach listener
|
|
2895
|
+
if (canRaw) {
|
|
2896
|
+
try { process.stdin.setRawMode(true); } catch {}
|
|
2897
|
+
}
|
|
2898
|
+
readline.emitKeypressEvents(process.stdin, rl);
|
|
2899
|
+
process.stdin.on('keypress', onKey);
|
|
2900
|
+
render();
|
|
2901
|
+
return;
|
|
2902
|
+
}
|
|
2903
|
+
};
|
|
2904
|
+
|
|
2905
|
+
process.stdin.on('keypress', onKey);
|
|
2906
|
+
});
|
|
2479
2907
|
|
|
2480
|
-
return
|
|
2908
|
+
return result;
|
|
2481
2909
|
}
|
|
2482
2910
|
|
|
2483
2911
|
async function sessionManageScreen(rl, ask, ctx = {}) {
|
|
@@ -2568,6 +2996,7 @@ const SCREENS = {
|
|
|
2568
2996
|
main: mainScreen,
|
|
2569
2997
|
'new-session': newSessionScreen,
|
|
2570
2998
|
settings: settingsScreen,
|
|
2999
|
+
'import-picker': importPickerScreen,
|
|
2571
3000
|
subscriptions: subscriptionsScreen,
|
|
2572
3001
|
dashboard: dashboardScreen,
|
|
2573
3002
|
auth: authScreen,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dual-brain",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"scripts": {
|
|
41
41
|
"test": "node .claude/hooks/test-orchestrator.mjs",
|
|
42
42
|
"test:core": "node --test src/test.mjs",
|
|
43
|
-
"postinstall": "echo 'dual-brain installed. Run: dual-brain install (in your project) to set up hooks.'"
|
|
43
|
+
"postinstall": "echo 'dual-brain installed. Run: dual-brain install (in your project) to set up hooks.'",
|
|
44
|
+
"postpublish": "node scripts/verify-publish.mjs"
|
|
44
45
|
},
|
|
45
46
|
"engines": {
|
|
46
47
|
"node": ">=20.0.0"
|
|
@@ -103,6 +104,7 @@
|
|
|
103
104
|
"plugin.json",
|
|
104
105
|
"skills/*.md",
|
|
105
106
|
"agents/*.md",
|
|
106
|
-
"shell-hook.sh"
|
|
107
|
+
"shell-hook.sh",
|
|
108
|
+
"scripts/verify-publish.mjs"
|
|
107
109
|
]
|
|
108
110
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
4
|
+
const url = `https://registry.npmjs.org/dual-brain/${version}`;
|
|
5
|
+
const maxWait = 30000;
|
|
6
|
+
const start = Date.now();
|
|
7
|
+
|
|
8
|
+
async function check() {
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch(url);
|
|
11
|
+
if (res.ok) {
|
|
12
|
+
console.log(`✓ dual-brain@${version} verified on registry`);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
} catch {}
|
|
16
|
+
|
|
17
|
+
if (Date.now() - start > maxWait) {
|
|
18
|
+
console.log(`⚠ dual-brain@${version} published but CDN propagation may take a moment`);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
23
|
+
return check();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
check();
|
package/src/session.mjs
CHANGED
|
@@ -523,7 +523,7 @@ export function getSessionMeta(cwd = process.cwd()) {
|
|
|
523
523
|
try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return {}; }
|
|
524
524
|
}
|
|
525
525
|
|
|
526
|
-
function saveSessionMeta(meta, cwd = process.cwd()) {
|
|
526
|
+
export function saveSessionMeta(meta, cwd = process.cwd()) {
|
|
527
527
|
ensureDir(cwd);
|
|
528
528
|
const p = sessionMetaPath(cwd);
|
|
529
529
|
const tmp = p + '.tmp.' + process.pid;
|
|
@@ -531,6 +531,76 @@ function saveSessionMeta(meta, cwd = process.cwd()) {
|
|
|
531
531
|
renameSync(tmp, p);
|
|
532
532
|
}
|
|
533
533
|
|
|
534
|
+
// ─── Archive support ──────────────────────────────────────────────────────────
|
|
535
|
+
|
|
536
|
+
const ARCHIVE_FILE = '.dualbrain/archive/sessions.json';
|
|
537
|
+
|
|
538
|
+
function archivePath(cwd) {
|
|
539
|
+
return join(cwd ?? process.cwd(), ARCHIVE_FILE);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Archive a session — moves it from active sessions.json to archive/sessions.json.
|
|
544
|
+
* The session data stays in the index (searchable), just flagged as archived.
|
|
545
|
+
* Non-destructive and reversible.
|
|
546
|
+
*
|
|
547
|
+
* @param {string} sessionId
|
|
548
|
+
* @param {string} [cwd]
|
|
549
|
+
*/
|
|
550
|
+
export function archiveSession(sessionId, cwd = process.cwd()) {
|
|
551
|
+
// Load active sessions meta
|
|
552
|
+
const meta = getSessionMeta(cwd);
|
|
553
|
+
const existing = meta[sessionId] ?? {};
|
|
554
|
+
|
|
555
|
+
// Load or init archive
|
|
556
|
+
const ap = archivePath(cwd);
|
|
557
|
+
mkdirSync(dirname(ap), { recursive: true });
|
|
558
|
+
let archive = [];
|
|
559
|
+
try {
|
|
560
|
+
if (existsSync(ap)) archive = JSON.parse(readFileSync(ap, 'utf8'));
|
|
561
|
+
} catch { archive = []; }
|
|
562
|
+
|
|
563
|
+
// Avoid duplicates
|
|
564
|
+
if (!archive.some(s => s.id === sessionId)) {
|
|
565
|
+
archive.push({
|
|
566
|
+
...existing,
|
|
567
|
+
id: sessionId,
|
|
568
|
+
archived: true,
|
|
569
|
+
archivedAt: new Date().toISOString(),
|
|
570
|
+
});
|
|
571
|
+
const tmp = ap + '.tmp.' + process.pid;
|
|
572
|
+
writeFileSync(tmp, JSON.stringify(archive, null, 2) + '\n');
|
|
573
|
+
renameSync(tmp, ap);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Remove from active sessions.json
|
|
577
|
+
delete meta[sessionId];
|
|
578
|
+
saveSessionMeta(meta, cwd);
|
|
579
|
+
|
|
580
|
+
// Mark archived in the session index (best-effort)
|
|
581
|
+
try {
|
|
582
|
+
const indexPath = join(cwd ?? process.cwd(), '.dualbrain', 'session-index.json');
|
|
583
|
+
if (existsSync(indexPath)) {
|
|
584
|
+
const index = JSON.parse(readFileSync(indexPath, 'utf8'));
|
|
585
|
+
if (index[sessionId]) {
|
|
586
|
+
index[sessionId].archived = true;
|
|
587
|
+
writeFileSync(indexPath, JSON.stringify(index, null, 2) + '\n');
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
} catch { /* non-fatal */ }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Return all archived sessions.
|
|
595
|
+
* @param {string} [cwd]
|
|
596
|
+
* @returns {Array<object>}
|
|
597
|
+
*/
|
|
598
|
+
export function getArchivedSessions(cwd = process.cwd()) {
|
|
599
|
+
const ap = archivePath(cwd);
|
|
600
|
+
if (!existsSync(ap)) return [];
|
|
601
|
+
try { return JSON.parse(readFileSync(ap, 'utf8')); } catch { return []; }
|
|
602
|
+
}
|
|
603
|
+
|
|
534
604
|
export function renameSession(sessionId, name, cwd = process.cwd()) {
|
|
535
605
|
const meta = getSessionMeta(cwd);
|
|
536
606
|
meta[sessionId] = { ...meta[sessionId], name, createdAt: meta[sessionId]?.createdAt ?? new Date().toISOString() };
|