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.
- package/bin/dual-brain.mjs +184 -6
- package/package.json +1 -1
- package/src/index.mjs +2 -2
- package/src/profile.mjs +87 -1
- package/src/session.mjs +241 -47
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) {
|
|
@@ -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
|
|
926
|
-
|
|
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
|
-
|
|
933
|
-
|
|
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
|
-
|
|
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
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
|
|
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
|
-
//
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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 {
|
|
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');
|