dual-brain 0.1.5 → 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.
@@ -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 recentSessions = enrichSessions(importReplitSessions(cwd), cwd).slice(0, 3);
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
- // Layout: "{num} {name...} {age}"
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 - numStr.length - 2 spaces - 2 spaces before age - ageStr.length
1159
- const nameMax = W - numStr.length - 2 - 2 - ageStr.length;
1160
- const name = rawName.length > nameMax
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} ${name} ${ageStr}`;
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
- const sessions = importReplitSessions(cwd);
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
- console.log('');
2448
- console.log(separator('Session Manager'));
2449
- console.log('');
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
- console.log(' No sessions found.\n');
2453
- console.log(' [b] Back\n');
2454
- const choice = (await ask(' Choice: ')).trim().toLowerCase();
2455
- if (choice === 'b' || choice === 'back') return { next: 'main' };
2456
- return { next: 'sessions' };
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
- sessions.forEach((sess, i) => {
2460
- const pin = sess.pinned ? '📌 ' : ' ';
2461
- const active = sess.isActive ? ' ●' : '';
2462
- const cat = sess.category ? ` [${sess.category}]` : '';
2463
- console.log(` [${i + 1}] ${pin}${sess.age.padEnd(6)} ${sess.name}${active}${cat}`);
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
- console.log('');
2467
- console.log(' [1-9] Select a session to manage');
2468
- console.log(' [b] Back');
2469
- console.log('');
2777
+ render();
2470
2778
 
2471
- const choice = (await ask(' Choice: ')).trim().toLowerCase();
2779
+ const readline = await import('node:readline');
2780
+ readline.emitKeypressEvents(process.stdin, rl);
2472
2781
 
2473
- if (choice === 'b' || choice === 'back') return { next: 'main' };
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
- const numChoice = parseInt(choice, 10);
2476
- if (!isNaN(numChoice) && numChoice >= 1 && numChoice <= sessions.length) {
2477
- return { next: 'session-manage', session: sessions[numChoice - 1] };
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 { next: 'sessions' };
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.5",
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": {
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() };