dual-brain 7.1.11 → 7.1.13

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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // dual-brain — CLI entry point. Commands: init, go, status, remember, forget
3
3
 
4
- import { existsSync, readFileSync } from 'node:fs';
4
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, statSync, readdirSync, unlinkSync } from 'node:fs';
5
5
  import { join, dirname } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { execSync, spawnSync as _spawnSyncTop } from 'node:child_process';
@@ -41,10 +41,60 @@ const PKG_PATH = join(__dirname, '..', 'package.json');
41
41
  function readVersion() {
42
42
  try { return JSON.parse(readFileSync(PKG_PATH, 'utf8')).version; } catch { return '0.0.0'; }
43
43
  }
44
+ async function checkForUpdates(currentVersion) {
45
+ try {
46
+ const { execSync } = await import('node:child_process');
47
+ const latest = execSync('npm view dual-brain version 2>/dev/null', {
48
+ encoding: 'utf8',
49
+ timeout: 3000
50
+ }).trim();
51
+ if (latest && latest !== currentVersion) {
52
+ return latest;
53
+ }
54
+ } catch {}
55
+ return null;
56
+ }
44
57
  function flag(args, name) { const i = args.indexOf(name); return i !== -1 ? (args[i + 1] ?? true) : null; }
45
58
  function err(msg) { process.stderr.write(`Error: ${msg}\n`); process.exit(1); }
46
59
  function vtrace(msg) { process.stderr.write(`[verbose] ${msg}\n`); }
47
60
 
61
+ // ─── Loop-prevention markers ──────────────────────────────────────────────────
62
+
63
+ function checkLoopMarker(cwd) {
64
+ const markerPath = join(cwd, '.dualbrain', `.prompt-shown-${process.pid}`);
65
+ if (existsSync(markerPath)) {
66
+ try {
67
+ const age = Date.now() - statSync(markerPath).mtimeMs;
68
+ if (age < 3600000) return true; // Not stale, skip prompt
69
+ } catch {}
70
+ }
71
+ return false;
72
+ }
73
+
74
+ function setLoopMarker(cwd) {
75
+ const dir = join(cwd, '.dualbrain');
76
+ try {
77
+ mkdirSync(dir, { recursive: true });
78
+ writeFileSync(join(dir, `.prompt-shown-${process.pid}`), String(Date.now()));
79
+ } catch {}
80
+ }
81
+
82
+ function cleanStaleMarkers(cwd) {
83
+ const dir = join(cwd, '.dualbrain');
84
+ try {
85
+ for (const f of readdirSync(dir)) {
86
+ if (!f.startsWith('.prompt-shown-')) continue;
87
+ const pid = f.replace('.prompt-shown-', '');
88
+ try {
89
+ process.kill(parseInt(pid, 10), 0);
90
+ } catch {
91
+ // Process dead, remove marker
92
+ try { unlinkSync(join(dir, f)); } catch {}
93
+ }
94
+ }
95
+ } catch {}
96
+ }
97
+
48
98
  function daysUntil(isoDate) {
49
99
  if (!isoDate) return null;
50
100
  const ms = Date.parse(isoDate) - Date.now();
@@ -685,6 +735,13 @@ async function welcomeScreen(rl, ask) {
685
735
  existing.mode = enabledCount >= 2 ? 'dual' : claudeReady ? 'solo-claude' : 'solo-openai';
686
736
  saveProfile(existing, { cwd });
687
737
  }
738
+ try {
739
+ const { ensurePersistence } = await import('../src/session.mjs');
740
+ const persisted = ensurePersistence(cwd);
741
+ if (persisted.length > 0) {
742
+ persisted.forEach(msg => console.log(` ✅ ${msg}`));
743
+ }
744
+ } catch {}
688
745
  await cmdInstall(cwd);
689
746
  return { next: 'main' };
690
747
  }
@@ -783,6 +840,42 @@ async function welcomeScreen(rl, ask) {
783
840
  return { next: 'main' };
784
841
  }
785
842
 
843
+ // ─── Running-instance + terminal helpers ─────────────────────────────────────
844
+
845
+ function countRunningInstances() {
846
+ try {
847
+ const claude = parseInt(execSync('pgrep -x claude 2>/dev/null | wc -l', { encoding: 'utf8' }).trim(), 10) || 0;
848
+ const codex = parseInt(execSync('pgrep -x codex 2>/dev/null | wc -l', { encoding: 'utf8' }).trim(), 10) || 0;
849
+ return { claude, codex };
850
+ } catch { return { claude: 0, codex: 0 }; }
851
+ }
852
+
853
+ function getTerminalId() {
854
+ try {
855
+ const tty = execSync('tty 2>/dev/null', { encoding: 'utf8' }).trim();
856
+ if (tty && tty !== 'not a tty') {
857
+ return tty.replace('/dev/', '').replace(/\//g, '-');
858
+ }
859
+ } catch {}
860
+ return `shell-${process.pid}`;
861
+ }
862
+
863
+ function saveTerminalState(cwd, terminalId, sessionId, tool) {
864
+ const dir = join(cwd, '.dualbrain');
865
+ try {
866
+ mkdirSync(dir, { recursive: true });
867
+ writeFileSync(join(dir, `terminal-${terminalId}.json`), JSON.stringify({
868
+ sessionId, tool, terminalId, timestamp: Math.floor(Date.now() / 1000),
869
+ }));
870
+ } catch {}
871
+ }
872
+
873
+ function loadTerminalState(cwd, terminalId) {
874
+ try {
875
+ return JSON.parse(readFileSync(join(cwd, '.dualbrain', `terminal-${terminalId}.json`), 'utf8'));
876
+ } catch { return null; }
877
+ }
878
+
786
879
  // ─── Screen: mainScreen ───────────────────────────────────────────────────────
787
880
 
788
881
  async function mainScreen(rl, ask) {
@@ -823,8 +916,55 @@ async function mainScreen(rl, ask) {
823
916
  subLine('OpenAI', openaiPlan, auth.openai.found, openaiExpired, openaiDays, openaiSub),
824
917
  ];
825
918
 
919
+ console.log(`📦 DATA Tools - Dual Brain v${version}`);
920
+ const latestVersion = await checkForUpdates(version);
921
+ if (latestVersion) {
922
+ console.log(` ⬆️ Update available: v${version} → v${latestVersion}`);
923
+ console.log(` Run: npx -y dual-brain@latest`);
924
+ }
925
+ console.log('');
926
+
927
+ // Help shortcuts box (matching data-tools style)
928
+ const W = 37;
929
+ const helpTop = ` ┌${'─'.repeat(W)}┐`;
930
+ const helpSep = ` ├${'─'.repeat(W)}┤`;
931
+ const helpBottom = ` └${'─'.repeat(W)}┘`;
932
+ const helpPad = (s) => s + ' '.repeat(Math.max(0, W - s.length));
933
+
934
+ console.log(helpTop);
935
+ console.log(` │ ${helpPad('At ~/workspace$ prompt:')}│`);
936
+ console.log(` │ ${helpPad('db = show this menu')}│`);
937
+ console.log(` │ ${helpPad('j = login to claude')}│`);
938
+ console.log(` │ ${helpPad('k = login to codex')}│`);
939
+ console.log(helpSep);
940
+ console.log(` │ ${helpPad('In Claude:')}│`);
941
+ console.log(` │ ${helpPad('Ctrl+C x2 = back to menu')}│`);
942
+ console.log(` │ ${helpPad('Ctrl+C x3 = exit to shell')}│`);
943
+ console.log(helpBottom);
826
944
  console.log('');
827
- console.log(renderHeader(version, headerLines));
945
+
946
+ // Provider status (outside the box)
947
+ for (const line of headerLines) {
948
+ console.log(` ${line}`);
949
+ }
950
+
951
+ // Silent OAuth token auto-refresh (like data-tools)
952
+ try {
953
+ const { autoRefreshToken } = await import('../src/profile.mjs');
954
+ const refreshResult = await autoRefreshToken(cwd);
955
+ if (refreshResult.status === 'refreshed') {
956
+ console.log(` 🔄 Token auto-refreshed (${refreshResult.hoursRemaining}h remaining)`);
957
+ }
958
+ } catch {}
959
+
960
+ // Append-only session archive sync (like data-tools)
961
+ try {
962
+ const { syncSessionMirror } = await import('../src/session.mjs');
963
+ const mirror = syncSessionMirror(cwd);
964
+ if (mirror.copied > 0 || mirror.grew > 0) {
965
+ console.log(` ✅ Archive mirror: +${mirror.copied} new, ${mirror.grew} updated`);
966
+ }
967
+ } catch {}
828
968
 
829
969
  // Auto-refresh expired subscriptions
830
970
  if (claudeExpired || openaiExpired) {
@@ -860,16 +1000,41 @@ async function mainScreen(rl, ask) {
860
1000
  const pin = sess.pinned ? '📌 ' : ' ';
861
1001
  const active = sess.isActive ? ' ●' : '';
862
1002
  const cat = sess.category ? ` [${sess.category}]` : '';
863
- console.log(` [${i + 1}] ${pin}${sess.age.padEnd(6)} ${sess.name}${active}${cat}`);
1003
+ const tool = (sess.tool === 'codex') ? 'cdx' : 'cld';
1004
+ console.log(` [${i + 1}] ${pin}${tool} ${sess.age.padEnd(8)} ${sess.name}${active}${cat}`);
864
1005
  });
865
1006
  console.log('');
866
1007
  }
867
1008
 
1009
+ const brandW = 37;
1010
+ const brandTop = ` ┌${'─'.repeat(brandW)}┐`;
1011
+ const brandBottom = ` └${'─'.repeat(brandW)}┘`;
1012
+ const brandPad = (s) => {
1013
+ const leftPad = Math.floor((brandW - s.length) / 2);
1014
+ const rightPad = brandW - s.length - leftPad;
1015
+ return ' '.repeat(leftPad) + s + ' '.repeat(rightPad);
1016
+ };
1017
+ console.log(brandTop);
1018
+ console.log(` │ ${brandPad('Dual Brain Session Manager')}│`);
1019
+ console.log(` │ ${brandPad('by Steve Moraco + dual-brain')}│`);
1020
+ console.log(brandBottom);
1021
+ console.log('');
1022
+
1023
+ const running = countRunningInstances();
1024
+ const runningParts = [];
1025
+ if (running.claude > 0) runningParts.push(`${running.claude} claude`);
1026
+ if (running.codex > 0) runningParts.push(`${running.codex} codex`);
1027
+ if (runningParts.length > 0) {
1028
+ console.log(` (${runningParts.join(', ')} running)`);
1029
+ console.log('');
1030
+ }
1031
+
868
1032
  console.log(' [c] Continue last session');
869
1033
  console.log(' [n] New session');
870
1034
  if (recentSessions.length > 0) {
871
1035
  console.log(' [1-9] Resume numbered above');
872
1036
  }
1037
+ console.log(' [r] Resume (full list)');
873
1038
  console.log(' [e] Manage sessions');
874
1039
  console.log(' [i] Import from replit-tools');
875
1040
  console.log(' [m] Manage subscriptions');
@@ -883,15 +1048,24 @@ async function mainScreen(rl, ask) {
883
1048
  if (choice === 'n') { return { next: 'new-session' }; }
884
1049
 
885
1050
  if (choice === 'c') {
886
- const sessions = importReplitSessions(cwd);
887
- if (sessions.length === 0) {
1051
+ const termId = getTerminalId();
1052
+ const termState = loadTerminalState(cwd, termId);
1053
+ const sessions = importReplitSessions(cwd);
1054
+
1055
+ // Priority: terminal-specific last session, then global last session
1056
+ const targetId = termState?.sessionId || (sessions.length > 0 ? sessions[0].id : null);
1057
+
1058
+ if (!targetId) {
888
1059
  console.log('\n No recent sessions found.\n');
889
1060
  await ask(' Press Enter to continue...');
890
1061
  return { next: 'main' };
891
1062
  }
1063
+
892
1064
  const { spawnSync } = await import('node:child_process');
893
- console.log(`\n Launching: claude --resume ${sessions[0].id}\n`);
894
- spawnSync('claude', ['--resume', sessions[0].id], { stdio: 'inherit' });
1065
+ const tool = termState?.tool || 'claude';
1066
+ console.log(`\n Resuming: ${tool} --resume ${targetId}\n`);
1067
+ spawnSync(tool === 'codex' ? 'codex' : 'claude', ['--resume', targetId], { stdio: 'inherit' });
1068
+ saveTerminalState(cwd, termId, targetId, tool);
895
1069
  return { next: 'main' };
896
1070
  }
897
1071
 
@@ -901,6 +1075,37 @@ async function mainScreen(rl, ask) {
901
1075
  const { spawnSync } = await import('node:child_process');
902
1076
  console.log(`\n Launching: claude --resume ${sess.id}\n`);
903
1077
  spawnSync('claude', ['--resume', sess.id], { stdio: 'inherit' });
1078
+ saveTerminalState(cwd, getTerminalId(), sess.id, sess.tool || 'claude');
1079
+ return { next: 'main' };
1080
+ }
1081
+
1082
+ if (choice === 'r') {
1083
+ const allSessions = enrichSessions(importReplitSessions(cwd), cwd);
1084
+ if (allSessions.length === 0) {
1085
+ console.log('\n No sessions found.\n');
1086
+ await ask(' Press Enter to continue...');
1087
+ return { next: 'main' };
1088
+ }
1089
+
1090
+ console.log('\n All Sessions:');
1091
+ allSessions.forEach((sess, i) => {
1092
+ const pin = sess.pinned ? '📌 ' : ' ';
1093
+ const active = sess.isActive ? ' ●' : '';
1094
+ const cat = sess.category ? ` [${sess.category}]` : '';
1095
+ const tool = (sess.tool === 'codex') ? 'cdx' : 'cld';
1096
+ console.log(` [${String(i + 1).padStart(2)}] ${pin}${tool} ${sess.age.padEnd(8)} ${sess.name}${active}${cat}`);
1097
+ });
1098
+ console.log('');
1099
+
1100
+ const pick = (await ask(' Enter number to resume (or Enter to cancel): ')).trim();
1101
+ const num = parseInt(pick, 10);
1102
+ if (!isNaN(num) && num >= 1 && num <= allSessions.length) {
1103
+ const sess = allSessions[num - 1];
1104
+ const { spawnSync } = await import('node:child_process');
1105
+ const tool = sess.tool === 'codex' ? 'codex' : 'claude';
1106
+ console.log(`\n Launching: ${tool} --resume ${sess.id}\n`);
1107
+ spawnSync(tool, ['--resume', sess.id], { stdio: 'inherit' });
1108
+ }
904
1109
  return { next: 'main' };
905
1110
  }
906
1111
 
@@ -953,12 +1158,19 @@ async function newSessionScreen(rl, ask) {
953
1158
  console.log(` Reason: ${decision.explanation}\n`);
954
1159
 
955
1160
  const { spawnSync } = await import('node:child_process');
956
- if (decision.provider === 'openai') {
1161
+ const launchTool = decision.provider === 'openai' ? 'codex' : 'claude';
1162
+ if (launchTool === 'codex') {
957
1163
  spawnSync('codex', [input], { stdio: 'inherit' });
958
1164
  } else {
959
1165
  spawnSync('claude', ['-p', input], { stdio: 'inherit' });
960
1166
  }
961
1167
 
1168
+ // After session ends, capture the most-recent session ID so [c] can resume it
1169
+ const freshSessions = importReplitSessions(cwd);
1170
+ if (freshSessions.length > 0) {
1171
+ saveTerminalState(cwd, getTerminalId(), freshSessions[0].id, launchTool);
1172
+ }
1173
+
962
1174
  return { next: 'main' };
963
1175
  }
964
1176
 
@@ -1888,6 +2100,11 @@ async function main() {
1888
2100
  if (!cmd) {
1889
2101
  if (isInteractive) {
1890
2102
  const cwd = process.cwd();
2103
+ cleanStaleMarkers(cwd);
2104
+ if (!process.argv.includes('--force') && checkLoopMarker(cwd)) {
2105
+ process.exit(0);
2106
+ }
2107
+ setLoopMarker(cwd);
1891
2108
  if (profileExists(cwd)) {
1892
2109
  await runScreens('main');
1893
2110
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "7.1.11",
3
+ "version": "7.1.13",
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/index.mjs CHANGED
@@ -6,14 +6,14 @@
6
6
  * orchestrate() convenience function for programmatic use.
7
7
  */
8
8
 
9
- export { loadProfile, saveProfile, ensureProfile, runOnboarding, rememberPreference, forgetPreference, getActivePreferences, getAvailableProviders, isSoloBrain, getHeadModel, detectAuth, detectEnvironment, saveSubscription, listSubscriptions } from './profile.mjs';
9
+ export { loadProfile, saveProfile, ensureProfile, runOnboarding, rememberPreference, forgetPreference, getActivePreferences, getAvailableProviders, isSoloBrain, getHeadModel, detectAuth, detectEnvironment, saveSubscription, listSubscriptions, autoRefreshToken } from './profile.mjs';
10
10
  export { detectTask, classifyIntent, classifyRisk, estimateComplexity, inferTier, extractPaths } from './detect.mjs';
11
11
  export { decideRoute, getModelCapabilities, getAvailableModels, shouldDualBrain, explainDecision } from './decide.mjs';
12
12
  export { dispatch, buildCommand, detectRuntime, compressResult, dispatchDualBrain } from './dispatch.mjs';
13
13
  export { loadPlaybook, listPlaybooks, executePlaybook, createRunArtifact } from './playbook.mjs';
14
14
  export { getHealth, markHot, markDegraded, markHealthy, checkCooldown, getProviderScore, recordDispatch, getSessionStats, resetHealth, remainingCooldownMinutes } from './health.mjs';
15
15
  export { detectRepo, loadRepoCache, getTestCommand, getLintCommand } from './repo.mjs';
16
- export { loadSession, saveSession, updateSession, clearSession, formatSessionCard, importReplitSessions, renameSession, pinSession, unpinSession, categorizeSession, getSessionMeta, autoLabel, enrichSessions } from './session.mjs';
16
+ export { loadSession, saveSession, updateSession, clearSession, formatSessionCard, importReplitSessions, renameSession, pinSession, unpinSession, categorizeSession, getSessionMeta, autoLabel, enrichSessions, ensurePersistence, syncSessionMirror } from './session.mjs';
17
17
  export { decompose, isSimpleTask, taskGraphToWaves } from './decompose.mjs';
18
18
  export { generateBrief, compressPriorResults, listRoles } from './brief.mjs';
19
19
  export { redact, redactFiles, isSecretFile } from './redact.mjs';
package/src/profile.mjs CHANGED
@@ -666,6 +666,92 @@ async function autoSetup(cwd) {
666
666
  return result;
667
667
  }
668
668
 
669
+ // ---------------------------------------------------------------------------
670
+ // OAuth token auto-refresh
671
+ // ---------------------------------------------------------------------------
672
+
673
+ /**
674
+ * Silently refresh the Claude OAuth token before it expires.
675
+ * Mirrors the approach used by replit-tools/data-tools claude-auth-refresh.sh,
676
+ * but implemented in JavaScript.
677
+ *
678
+ * Returns one of:
679
+ * { status: 'valid', hoursRemaining }
680
+ * { status: 'refreshed', hoursRemaining }
681
+ * { status: 'expiring_no_refresh' | 'expired', hoursRemaining }
682
+ * { status: 'no_credentials' | 'parse_error' | 'no_expiry' }
683
+ * { status: 'refresh_failed', error }
684
+ *
685
+ * @param {string} [cwd]
686
+ */
687
+ async function autoRefreshToken(cwd) {
688
+ const home = process.env.HOME || '/root';
689
+ const credPaths = [
690
+ join(home, '.claude', '.credentials.json'),
691
+ join(cwd || '.', '.replit-tools', '.claude-persistent', '.credentials.json'),
692
+ ];
693
+
694
+ let credPath = null;
695
+ for (const p of credPaths) {
696
+ if (existsSync(p)) { credPath = p; break; }
697
+ }
698
+ if (!credPath) return { status: 'no_credentials' };
699
+
700
+ let creds;
701
+ try {
702
+ creds = JSON.parse(readFileSync(credPath, 'utf8'));
703
+ } catch { return { status: 'parse_error' }; }
704
+
705
+ const oauth = creds?.claudeAiOauth;
706
+ if (!oauth?.expiresAt) return { status: 'no_expiry' };
707
+
708
+ const now = Date.now();
709
+ const remainingMs = oauth.expiresAt - now;
710
+ const remainingHours = Math.floor(remainingMs / 1000 / 60 / 60);
711
+
712
+ // More than 2 hours left — no refresh needed
713
+ if (remainingHours >= 2) {
714
+ return { status: 'valid', hoursRemaining: remainingHours };
715
+ }
716
+
717
+ // Need refresh
718
+ if (!oauth.refreshToken) {
719
+ return { status: remainingMs > 0 ? 'expiring_no_refresh' : 'expired', hoursRemaining: remainingHours };
720
+ }
721
+
722
+ try {
723
+ const res = await fetch('https://console.anthropic.com/v1/oauth/token', {
724
+ method: 'POST',
725
+ headers: { 'Content-Type': 'application/json' },
726
+ body: JSON.stringify({
727
+ grant_type: 'refresh_token',
728
+ refresh_token: oauth.refreshToken,
729
+ client_id: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
730
+ }),
731
+ });
732
+
733
+ if (!res.ok) return { status: 'refresh_failed', error: `HTTP ${res.status}` };
734
+
735
+ const data = await res.json();
736
+ if (!data.access_token) return { status: 'refresh_failed', error: 'no access_token' };
737
+
738
+ // Update credentials
739
+ const newExpiresAt = now + (data.expires_in * 1000);
740
+ creds.claudeAiOauth.accessToken = data.access_token;
741
+ if (data.refresh_token) creds.claudeAiOauth.refreshToken = data.refresh_token;
742
+ creds.claudeAiOauth.expiresAt = newExpiresAt;
743
+
744
+ // Backup then write
745
+ try { writeFileSync(credPath + '.backup', readFileSync(credPath)); } catch {}
746
+ writeFileSync(credPath, JSON.stringify(creds));
747
+
748
+ const newHours = Math.floor((data.expires_in) / 60 / 60);
749
+ return { status: 'refreshed', hoursRemaining: newHours };
750
+ } catch (e) {
751
+ return { status: 'refresh_failed', error: e.message };
752
+ }
753
+ }
754
+
669
755
  export {
670
756
  loadProfile, saveProfile, ensureProfile, runOnboarding,
671
757
  rememberPreference, forgetPreference, getActivePreferences,
@@ -673,5 +759,5 @@ export {
673
759
  detectPlans, syncPreferencesToMemory,
674
760
  detectAuth, detectEnvironment,
675
761
  saveSubscription, listSubscriptions,
676
- defaultProfile, autoSetup,
762
+ defaultProfile, autoSetup, autoRefreshToken,
677
763
  };
package/src/session.mjs CHANGED
@@ -10,8 +10,8 @@
10
10
  * formatSessionCard(session, repo, health) → compact status card string (≤5 lines)
11
11
  */
12
12
 
13
- import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, renameSync, readdirSync } from 'node:fs';
14
- import { join } from 'node:path';
13
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, renameSync, readdirSync, statSync, copyFileSync } from 'node:fs';
14
+ import { join, dirname } from 'node:path';
15
15
 
16
16
  // ─── Constants ────────────────────────────────────────────────────────────────
17
17
 
@@ -216,6 +216,23 @@ export function formatSessionCard(session, repo, health, profile) {
216
216
 
217
217
  // ─── Replit-tools session import ──────────────────────────────────────────────
218
218
 
219
+ /**
220
+ * Returns true if the text looks like a real user prompt (not a status line,
221
+ * slash command, paste marker, or agent-generated noise).
222
+ * @param {string} text
223
+ * @returns {boolean}
224
+ */
225
+ function isRealPrompt(text) {
226
+ if (!text || !text.trim()) return false;
227
+ const t = text.trim();
228
+ if (/^[✅❌📦🔗⚠️🚀🎉🔧📝]/.test(t)) return false;
229
+ if (/Claude (history|binary|versions) symlink/.test(t)) return false;
230
+ if (t.startsWith('# AGENTS.md')) return false;
231
+ if (t.startsWith('/')) return false;
232
+ if (t.startsWith('[Pasted')) return false;
233
+ return true;
234
+ }
235
+
219
236
  /**
220
237
  * Human-readable time-ago string from a Unix timestamp (ms).
221
238
  * @param {number} timestamp
@@ -299,16 +316,122 @@ export function importReplitSessions(cwd = process.cwd()) {
299
316
  sess.entries.push(entry);
300
317
  if (entry.timestamp > sess.lastTimestamp) sess.lastTimestamp = entry.timestamp;
301
318
 
302
- // Find first meaningful user prompt (not slash commands, not login, not pastes)
303
- if (!sess.firstPrompt && entry.display
304
- && !entry.display.startsWith('/')
305
- && !entry.display.startsWith('login')
306
- && !entry.display.startsWith('[Pasted')) {
319
+ // Find first meaningful user prompt
320
+ if (!sess.firstPrompt && isRealPrompt(entry.display)) {
321
+ sess.firstPrompt = entry.display;
322
+ }
323
+ } catch { continue; }
324
+ }
325
+
326
+ // Also read from the session archive as a fallback (contains cleaned-up sessions)
327
+ const archivePath = join(cwd, '.replit-tools', '.session-archive', 'claude', 'history.jsonl');
328
+ let archiveLines = [];
329
+ try {
330
+ if (existsSync(archivePath)) {
331
+ archiveLines = readFileSync(archivePath, 'utf8').split('\n').filter(Boolean);
332
+ }
333
+ } catch { /* non-fatal */ }
334
+
335
+ for (const line of archiveLines) {
336
+ try {
337
+ const entry = JSON.parse(line);
338
+ if (!entry.sessionId) continue;
339
+ if (bySession.has(entry.sessionId)) continue; // already indexed from main history
340
+
341
+ bySession.set(entry.sessionId, {
342
+ sessionId: entry.sessionId,
343
+ project: entry.project,
344
+ entries: [],
345
+ firstPrompt: null,
346
+ lastTimestamp: 0,
347
+ });
348
+
349
+ const sess = bySession.get(entry.sessionId);
350
+ sess.entries.push(entry);
351
+ if (entry.timestamp > sess.lastTimestamp) sess.lastTimestamp = entry.timestamp;
352
+ if (!sess.firstPrompt && isRealPrompt(entry.display)) {
353
+ sess.firstPrompt = entry.display;
354
+ }
355
+ } catch { continue; }
356
+ }
357
+
358
+ // For archive sessions with multiple entries, finish accumulating them
359
+ // (second pass for sessions newly added from archive)
360
+ for (const line of archiveLines) {
361
+ try {
362
+ const entry = JSON.parse(line);
363
+ if (!entry.sessionId) continue;
364
+ const sess = bySession.get(entry.sessionId);
365
+ if (!sess) continue;
366
+ // Already pushed in first pass for new sessions; skip double-push
367
+ if (sess.entries.includes(entry)) continue;
368
+ sess.entries.push(entry);
369
+ if (entry.timestamp > sess.lastTimestamp) sess.lastTimestamp = entry.timestamp;
370
+ if (!sess.firstPrompt && isRealPrompt(entry.display)) {
307
371
  sess.firstPrompt = entry.display;
308
372
  }
309
373
  } catch { continue; }
310
374
  }
311
375
 
376
+ // Scan ~/.codex/sessions/ for codex session JSONLs (YYYY/MM/DD tree)
377
+ const codexSessionsDir = join(process.env.HOME || '/root', '.codex', 'sessions');
378
+ if (existsSync(codexSessionsDir)) {
379
+ try {
380
+ const walk = (dir) => {
381
+ let results = [];
382
+ try {
383
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
384
+ const full = join(dir, entry.name);
385
+ if (entry.isDirectory()) results = results.concat(walk(full));
386
+ else if (entry.isFile() && entry.name.endsWith('.jsonl')) results.push(full);
387
+ }
388
+ } catch {}
389
+ return results;
390
+ };
391
+
392
+ for (const f of walk(codexSessionsDir)) {
393
+ try {
394
+ const content = readFileSync(f, 'utf8');
395
+ const lines = content.split('\n').filter(Boolean);
396
+ if (!lines.length) continue;
397
+
398
+ const meta = JSON.parse(lines[0]);
399
+ if (meta.type !== 'session_meta' || !meta.payload) continue;
400
+ if (meta.payload.cwd !== cwd && meta.payload.cwd !== '/home/runner/workspace') continue;
401
+
402
+ const id = meta.payload.id;
403
+ if (bySession.has(id)) continue;
404
+
405
+ let firstPrompt = null;
406
+ let lastTimestamp = Date.parse(meta.payload.timestamp || meta.timestamp) / 1000;
407
+
408
+ for (const ln of lines) {
409
+ try {
410
+ const j = JSON.parse(ln);
411
+ if (j.timestamp) {
412
+ const ts = Date.parse(j.timestamp) / 1000;
413
+ if (ts > lastTimestamp) lastTimestamp = ts;
414
+ }
415
+ if (!firstPrompt && j.type === 'event_msg' && j.payload?.type === 'user_message') {
416
+ const text = (j.payload.message || '').trim();
417
+ if (text) firstPrompt = text;
418
+ }
419
+ } catch { continue; }
420
+ }
421
+
422
+ bySession.set(id, {
423
+ sessionId: id,
424
+ project: '-home-runner-workspace',
425
+ entries: [],
426
+ firstPrompt: firstPrompt || id.slice(0, 8) + '...',
427
+ lastTimestamp,
428
+ tool: 'codex',
429
+ });
430
+ } catch { continue; }
431
+ }
432
+ } catch { /* non-fatal */ }
433
+ }
434
+
312
435
  // Read active terminal sessions
313
436
  // Use the same root as replitBase (go up one level from .claude-persistent)
314
437
  const replitRoot = join(replitBase, '..');
@@ -325,8 +448,22 @@ export function importReplitSessions(cwd = process.cwd()) {
325
448
  } catch { /* non-fatal */ }
326
449
  }
327
450
 
451
+ // Determine recency window from config (default 48 hours)
452
+ const configPath = join(cwd, '.replit-tools', 'config.json');
453
+ let windowHours = 48;
454
+ try {
455
+ if (existsSync(configPath)) {
456
+ const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
457
+ windowHours = cfg.recentWindowHours || 48;
458
+ }
459
+ } catch { /* non-fatal */ }
460
+ const windowMs = windowHours * 60 * 60 * 1000;
461
+ const cutoff = Date.now() - windowMs;
462
+
328
463
  // Build session list
329
464
  for (const [id, sess] of bySession) {
465
+ // Skip sessions outside the recency window (timestamps are in seconds)
466
+ if (sess.lastTimestamp * 1000 < cutoff) continue;
330
467
  // Derive display name
331
468
  let name = sess.firstPrompt;
332
469
  if (!name) {
@@ -346,6 +483,7 @@ export function importReplitSessions(cwd = process.cwd()) {
346
483
  isActive: activeSessionIds.has(id),
347
484
  source: 'replit-tools',
348
485
  age: timeAgo(sess.lastTimestamp),
486
+ tool: sess.tool || 'claude',
349
487
  });
350
488
  }
351
489
 
@@ -439,6 +577,169 @@ export function enrichSessions(sessions, cwd = process.cwd()) {
439
577
  return enriched;
440
578
  }
441
579
 
580
+ // ─── Persistence settings ─────────────────────────────────────────────────────
581
+
582
+ /**
583
+ * Ensure Claude and Codex are configured to retain session history indefinitely.
584
+ * Mirrors what replit-tools does to prevent session cleanup/deletion.
585
+ *
586
+ * @param {string} [cwd]
587
+ * @returns {string[]} List of changes made (empty if already configured)
588
+ */
589
+ export function ensurePersistence(cwd = process.cwd()) {
590
+ const home = process.env.HOME || '/root';
591
+ const results = [];
592
+
593
+ // 1. Claude: set cleanupPeriodDays
594
+ const claudeSettingsPaths = [
595
+ join(home, '.claude', 'settings.json'),
596
+ join(cwd, '.replit-tools', '.claude-persistent', 'settings.json'),
597
+ ];
598
+
599
+ for (const settingsPath of claudeSettingsPaths) {
600
+ if (!existsSync(settingsPath)) continue;
601
+ try {
602
+ let settings = {};
603
+ try { settings = JSON.parse(readFileSync(settingsPath, 'utf8')); } catch { settings = {}; }
604
+ if (settings.cleanupPeriodDays !== 365250) {
605
+ settings.cleanupPeriodDays = 365250;
606
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
607
+ results.push('Claude cleanupPeriodDays set to 365250');
608
+ }
609
+ break; // only update one
610
+ } catch { continue; }
611
+ }
612
+
613
+ // 2. Codex: set history.persistence and max_bytes
614
+ const codexConfigPaths = [
615
+ join(home, '.codex', 'config.toml'),
616
+ join(cwd, '.replit-tools', '.codex-persistent', 'config.toml'),
617
+ ];
618
+
619
+ for (const configPath of codexConfigPaths) {
620
+ if (!existsSync(configPath)) continue;
621
+ try {
622
+ let content = readFileSync(configPath, 'utf8');
623
+ let changed = false;
624
+
625
+ if (!/\[history\]/.test(content)) {
626
+ content = content.trimEnd() + '\n\n[history]\npersistence = "save-all"\nmax_bytes = 104857600\n';
627
+ changed = true;
628
+ } else {
629
+ if (!/persistence\s*=/.test(content)) {
630
+ content = content.replace(/\[history\](\s*)/, '[history]$1persistence = "save-all"\n');
631
+ changed = true;
632
+ }
633
+ if (!/max_bytes\s*=/.test(content)) {
634
+ content = content.replace(/(persistence\s*=\s*"[^"]*"\s*\n)/, '$1max_bytes = 104857600\n');
635
+ changed = true;
636
+ }
637
+ }
638
+
639
+ if (changed) {
640
+ writeFileSync(configPath, content);
641
+ results.push('Codex history persistence enabled');
642
+ }
643
+ break;
644
+ } catch { continue; }
645
+ }
646
+
647
+ return results;
648
+ }
649
+
650
+ // ─── Session archive mirror sync ─────────────────────────────────────────────
651
+
652
+ /**
653
+ * Append-only mirror sync for Claude/Codex sessions (matches what replit-tools does).
654
+ * Files in the mirror only grow — if the source deletes a session, the mirror still has it.
655
+ *
656
+ * @param {string} [cwd]
657
+ * @returns {{ copied: number, grew: number, disabled?: boolean }}
658
+ */
659
+ export function syncSessionMirror(cwd = process.cwd()) {
660
+ const home = process.env.HOME || '/root';
661
+ const mirrorBase = join(cwd, '.replit-tools', '.session-archive');
662
+
663
+ // Check if replit-tools exists
664
+ if (!existsSync(join(cwd, '.replit-tools'))) return { copied: 0, grew: 0 };
665
+
666
+ // Check config — mirror can be disabled
667
+ const configPath = join(cwd, '.replit-tools', 'config.json');
668
+ try {
669
+ if (existsSync(configPath)) {
670
+ const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
671
+ if (cfg.mirror && cfg.mirror.enabled === false) return { copied: 0, grew: 0, disabled: true };
672
+ }
673
+ } catch {}
674
+
675
+ let totalCopied = 0, totalGrew = 0;
676
+
677
+ function syncTree(srcDir, destDir) {
678
+ if (!existsSync(srcDir)) return;
679
+
680
+ function walk(dir) {
681
+ let entries;
682
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
683
+
684
+ for (const entry of entries) {
685
+ const srcPath = join(dir, entry.name);
686
+ const relPath = srcPath.slice(srcDir.length);
687
+ const destPath = join(destDir, relPath);
688
+
689
+ if (entry.isDirectory()) {
690
+ try { mkdirSync(destPath, { recursive: true }); } catch {}
691
+ walk(srcPath);
692
+ } else if (entry.isFile()) {
693
+ let destSize = 0;
694
+ try { destSize = statSync(destPath).size; } catch {}
695
+
696
+ let srcSize = 0;
697
+ try { srcSize = statSync(srcPath).size; } catch { continue; }
698
+
699
+ // Append-only: only copy if source is larger than mirror
700
+ if (srcSize > destSize) {
701
+ try {
702
+ mkdirSync(dirname(destPath), { recursive: true });
703
+ copyFileSync(srcPath, destPath);
704
+ if (destSize === 0) totalCopied++;
705
+ else totalGrew++;
706
+ } catch {}
707
+ }
708
+ }
709
+ }
710
+ }
711
+
712
+ walk(srcDir);
713
+ }
714
+
715
+ try { mkdirSync(mirrorBase, { recursive: true }); } catch {}
716
+
717
+ // Sync Claude sessions
718
+ const claudeDir = join(home, '.claude');
719
+ syncTree(join(claudeDir, 'projects'), join(mirrorBase, 'claude', 'projects'));
720
+ // Sync history.jsonl as a single file
721
+ const histSrc = join(claudeDir, 'history.jsonl');
722
+ const histDest = join(mirrorBase, 'claude', 'history.jsonl');
723
+ if (existsSync(histSrc)) {
724
+ try {
725
+ const srcSize = statSync(histSrc).size;
726
+ let destSize = 0;
727
+ try { destSize = statSync(histDest).size; } catch {}
728
+ if (srcSize > destSize) {
729
+ mkdirSync(dirname(histDest), { recursive: true });
730
+ copyFileSync(histSrc, histDest);
731
+ if (destSize === 0) totalCopied++; else totalGrew++;
732
+ }
733
+ } catch {}
734
+ }
735
+
736
+ // Sync Codex sessions
737
+ const codexDir = join(home, '.codex');
738
+ syncTree(join(codexDir, 'sessions'), join(mirrorBase, 'codex', 'sessions'));
739
+
740
+ return { copied: totalCopied, grew: totalGrew };
741
+ }
742
+
442
743
  // ─── CLI (direct invocation) ──────────────────────────────────────────────────
443
744
 
444
745
  const isMain = process.argv[1]?.endsWith('session.mjs');