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.
- package/bin/dual-brain.mjs +225 -8
- package/package.json +1 -1
- package/src/index.mjs +2 -2
- package/src/profile.mjs +87 -1
- package/src/session.mjs +308 -7
package/bin/dual-brain.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
887
|
-
|
|
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
|
-
|
|
894
|
-
|
|
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
|
-
|
|
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
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
|
|
303
|
-
if (!sess.firstPrompt && entry.display
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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');
|