dual-brain 7.1.12 → 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) {
@@ -824,6 +917,11 @@ async function mainScreen(rl, ask) {
824
917
  ];
825
918
 
826
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
+ }
827
925
  console.log('');
828
926
 
829
927
  // Help shortcuts box (matching data-tools style)
@@ -850,6 +948,24 @@ async function mainScreen(rl, ask) {
850
948
  console.log(` ${line}`);
851
949
  }
852
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 {}
968
+
853
969
  // Auto-refresh expired subscriptions
854
970
  if (claudeExpired || openaiExpired) {
855
971
  const { spawnSync } = await import('node:child_process');
@@ -904,11 +1020,21 @@ async function mainScreen(rl, ask) {
904
1020
  console.log(brandBottom);
905
1021
  console.log('');
906
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
+
907
1032
  console.log(' [c] Continue last session');
908
1033
  console.log(' [n] New session');
909
1034
  if (recentSessions.length > 0) {
910
1035
  console.log(' [1-9] Resume numbered above');
911
1036
  }
1037
+ console.log(' [r] Resume (full list)');
912
1038
  console.log(' [e] Manage sessions');
913
1039
  console.log(' [i] Import from replit-tools');
914
1040
  console.log(' [m] Manage subscriptions');
@@ -922,15 +1048,24 @@ async function mainScreen(rl, ask) {
922
1048
  if (choice === 'n') { return { next: 'new-session' }; }
923
1049
 
924
1050
  if (choice === 'c') {
925
- const sessions = importReplitSessions(cwd);
926
- 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) {
927
1059
  console.log('\n No recent sessions found.\n');
928
1060
  await ask(' Press Enter to continue...');
929
1061
  return { next: 'main' };
930
1062
  }
1063
+
931
1064
  const { spawnSync } = await import('node:child_process');
932
- console.log(`\n Launching: claude --resume ${sessions[0].id}\n`);
933
- 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);
934
1069
  return { next: 'main' };
935
1070
  }
936
1071
 
@@ -940,6 +1075,37 @@ async function mainScreen(rl, ask) {
940
1075
  const { spawnSync } = await import('node:child_process');
941
1076
  console.log(`\n Launching: claude --resume ${sess.id}\n`);
942
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
+ }
943
1109
  return { next: 'main' };
944
1110
  }
945
1111
 
@@ -992,12 +1158,19 @@ async function newSessionScreen(rl, ask) {
992
1158
  console.log(` Reason: ${decision.explanation}\n`);
993
1159
 
994
1160
  const { spawnSync } = await import('node:child_process');
995
- if (decision.provider === 'openai') {
1161
+ const launchTool = decision.provider === 'openai' ? 'codex' : 'claude';
1162
+ if (launchTool === 'codex') {
996
1163
  spawnSync('codex', [input], { stdio: 'inherit' });
997
1164
  } else {
998
1165
  spawnSync('claude', ['-p', input], { stdio: 'inherit' });
999
1166
  }
1000
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
+
1001
1174
  return { next: 'main' };
1002
1175
  }
1003
1176
 
@@ -1927,6 +2100,11 @@ async function main() {
1927
2100
  if (!cmd) {
1928
2101
  if (isInteractive) {
1929
2102
  const cwd = process.cwd();
2103
+ cleanStaleMarkers(cwd);
2104
+ if (!process.argv.includes('--force') && checkLoopMarker(cwd)) {
2105
+ process.exit(0);
2106
+ }
2107
+ setLoopMarker(cwd);
1930
2108
  if (profileExists(cwd)) {
1931
2109
  await runScreens('main');
1932
2110
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "7.1.12",
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, statSync } 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,61 +316,61 @@ 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)) {
307
321
  sess.firstPrompt = entry.display;
308
322
  }
309
323
  } catch { continue; }
310
324
  }
311
325
 
312
- // Scan ~/.claude/projects/-home-runner-workspace/ for JSONL files not already in bySession
313
- const projectsDir = join(process.env.HOME || '/root', '.claude', 'projects', '-home-runner-workspace');
314
- if (existsSync(projectsDir)) {
315
- try {
316
- for (const f of readdirSync(projectsDir)) {
317
- if (!f.endsWith('.jsonl') || f.startsWith('agent-')) continue;
318
- const sessionId = f.replace('.jsonl', '');
319
- if (bySession.has(sessionId)) continue;
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 */ }
320
334
 
321
- try {
322
- const content = readFileSync(join(projectsDir, f), 'utf8');
323
- const lines = content.split('\n').filter(Boolean).slice(0, 50);
324
- let firstPrompt = null;
325
- let lastTimestamp = 0;
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
326
340
 
327
- for (const line of lines) {
328
- try {
329
- const entry = JSON.parse(line);
330
- if (entry.timestamp && entry.timestamp > lastTimestamp) lastTimestamp = entry.timestamp;
331
- if (!firstPrompt && entry.type === 'user' && entry.message?.content) {
332
- const text = typeof entry.message.content === 'string'
333
- ? entry.message.content
334
- : entry.message.content?.[0]?.text;
335
- if (text && !text.startsWith('/') && text.length < 200) {
336
- firstPrompt = text;
337
- }
338
- }
339
- } catch { continue; }
340
- }
341
+ bySession.set(entry.sessionId, {
342
+ sessionId: entry.sessionId,
343
+ project: entry.project,
344
+ entries: [],
345
+ firstPrompt: null,
346
+ lastTimestamp: 0,
347
+ });
341
348
 
342
- if (lastTimestamp === 0) {
343
- const stat = statSync(join(projectsDir, f));
344
- lastTimestamp = Math.floor(stat.mtimeMs / 1000);
345
- }
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
+ }
346
357
 
347
- bySession.set(sessionId, {
348
- sessionId,
349
- project: '-home-runner-workspace',
350
- entries: [],
351
- firstPrompt: firstPrompt || sessionId.slice(0, 8) + '...',
352
- lastTimestamp,
353
- });
354
- } catch { continue; }
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)) {
371
+ sess.firstPrompt = entry.display;
355
372
  }
356
- } catch { /* non-fatal */ }
373
+ } catch { continue; }
357
374
  }
358
375
 
359
376
  // Scan ~/.codex/sessions/ for codex session JSONLs (YYYY/MM/DD tree)
@@ -431,8 +448,22 @@ export function importReplitSessions(cwd = process.cwd()) {
431
448
  } catch { /* non-fatal */ }
432
449
  }
433
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
+
434
463
  // Build session list
435
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;
436
467
  // Derive display name
437
468
  let name = sess.firstPrompt;
438
469
  if (!name) {
@@ -546,6 +577,169 @@ export function enrichSessions(sessions, cwd = process.cwd()) {
546
577
  return enriched;
547
578
  }
548
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
+
549
743
  // ─── CLI (direct invocation) ──────────────────────────────────────────────────
550
744
 
551
745
  const isMain = process.argv[1]?.endsWith('session.mjs');