metame-cli 1.4.34 β 1.5.1
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/README.md +136 -94
- package/index.js +312 -57
- package/package.json +8 -4
- package/scripts/agent-layer.js +320 -0
- package/scripts/daemon-admin-commands.js +328 -28
- package/scripts/daemon-agent-commands.js +145 -6
- package/scripts/daemon-agent-tools.js +163 -7
- package/scripts/daemon-bridges.js +110 -20
- package/scripts/daemon-checkpoints.js +36 -7
- package/scripts/daemon-claude-engine.js +849 -358
- package/scripts/daemon-command-router.js +31 -10
- package/scripts/daemon-default.yaml +28 -4
- package/scripts/daemon-engine-runtime.js +328 -0
- package/scripts/daemon-exec-commands.js +15 -7
- package/scripts/daemon-notify.js +37 -1
- package/scripts/daemon-ops-commands.js +8 -6
- package/scripts/daemon-runtime-lifecycle.js +129 -5
- package/scripts/daemon-session-commands.js +60 -25
- package/scripts/daemon-session-store.js +121 -13
- package/scripts/daemon-task-scheduler.js +129 -49
- package/scripts/daemon-user-acl.js +35 -9
- package/scripts/daemon.js +268 -33
- package/scripts/distill.js +327 -18
- package/scripts/docs/agent-guide.md +12 -0
- package/scripts/docs/maintenance-manual.md +155 -0
- package/scripts/docs/pointer-map.md +110 -0
- package/scripts/feishu-adapter.js +42 -13
- package/scripts/hooks/stop-session-capture.js +243 -0
- package/scripts/memory-extract.js +105 -6
- package/scripts/memory-nightly-reflect.js +199 -11
- package/scripts/memory.js +134 -3
- package/scripts/mentor-engine.js +405 -0
- package/scripts/platform.js +24 -0
- package/scripts/providers.js +182 -22
- package/scripts/schema.js +12 -0
- package/scripts/session-analytics.js +245 -12
- package/scripts/skill-changelog.js +245 -0
- package/scripts/skill-evolution.js +288 -5
- package/scripts/telegram-adapter.js +12 -8
- package/scripts/usage-classifier.js +1 -1
- package/scripts/daemon-admin-commands.test.js +0 -333
- package/scripts/daemon-task-envelope.test.js +0 -59
- package/scripts/daemon-task-scheduler.test.js +0 -106
- package/scripts/reliability-core.test.js +0 -280
- package/scripts/skill-evolution.test.js +0 -113
- package/scripts/task-board.test.js +0 -83
- package/scripts/test_daemon.js +0 -1407
- package/scripts/utils.test.js +0 -192
|
@@ -61,7 +61,7 @@ function createOpsCommandHandler(deps) {
|
|
|
61
61
|
return true;
|
|
62
62
|
}
|
|
63
63
|
let isGitRepo = false;
|
|
64
|
-
try { execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'ignore', timeout: 3000 }); isGitRepo = true; } catch { }
|
|
64
|
+
try { execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'ignore', timeout: 3000, ...(process.platform === 'win32' ? { windowsHide: true } : {}) }); isGitRepo = true; } catch { }
|
|
65
65
|
const checkpoints = isGitRepo ? listCheckpoints(cwd) : [];
|
|
66
66
|
const match = checkpoints.find(cp => cp.hash.startsWith(arg));
|
|
67
67
|
if (!match) {
|
|
@@ -70,8 +70,9 @@ function createOpsCommandHandler(deps) {
|
|
|
70
70
|
}
|
|
71
71
|
try {
|
|
72
72
|
let diffFiles = '';
|
|
73
|
-
|
|
74
|
-
execSync(`git
|
|
73
|
+
const _wh = process.platform === 'win32' ? { windowsHide: true } : {};
|
|
74
|
+
try { diffFiles = execSync(`git diff --name-only HEAD ${match.hash}`, { cwd, encoding: 'utf8', timeout: 5000, ..._wh }).trim(); } catch { }
|
|
75
|
+
execSync(`git reset --hard ${match.hash}`, { cwd, stdio: 'ignore', timeout: 10000, ..._wh });
|
|
75
76
|
// Truncate context to checkpoint time (covers multi-turn rollback)
|
|
76
77
|
truncateSessionToCheckpoint(session.id, match.message);
|
|
77
78
|
const fileList = diffFiles ? diffFiles.split('\n').map(f => path.basename(f)).join(', ') : '';
|
|
@@ -199,7 +200,7 @@ function createOpsCommandHandler(deps) {
|
|
|
199
200
|
const cwd2 = session2.cwd;
|
|
200
201
|
if (cwd2) {
|
|
201
202
|
let isGitRepo2 = false;
|
|
202
|
-
try { execSync('git rev-parse --is-inside-work-tree', { cwd: cwd2, stdio: 'ignore', timeout: 3000 }); isGitRepo2 = true; } catch { }
|
|
203
|
+
try { execSync('git rev-parse --is-inside-work-tree', { cwd: cwd2, stdio: 'ignore', timeout: 3000, ...(process.platform === 'win32' ? { windowsHide: true } : {}) }); isGitRepo2 = true; } catch { }
|
|
203
204
|
if (isGitRepo2) {
|
|
204
205
|
// Exclude safety checkpoints from matching to avoid confusion
|
|
205
206
|
const checkpoints2 = listCheckpoints(cwd2).filter(cp => !cp.message.includes('[metame-safety]'));
|
|
@@ -208,11 +209,12 @@ function createOpsCommandHandler(deps) {
|
|
|
208
209
|
: checkpoints2[0];
|
|
209
210
|
if (cpMatch) {
|
|
210
211
|
let diffFiles2 = '';
|
|
211
|
-
|
|
212
|
+
const _wh2 = process.platform === 'win32' ? { windowsHide: true } : {};
|
|
213
|
+
try { diffFiles2 = execSync(`git diff --name-only HEAD ${cpMatch.hash}`, { cwd: cwd2, encoding: 'utf8', timeout: 5000, ..._wh2 }).trim(); } catch { }
|
|
212
214
|
if (diffFiles2) {
|
|
213
215
|
// Save current state with distinct prefix (excluded from normal /undo list)
|
|
214
216
|
gitCheckpoint(cwd2, `[metame-safety] before rollback to: ${targetMsg.slice(0, 40)}`);
|
|
215
|
-
execSync(`git reset --hard ${cpMatch.hash}`, { cwd: cwd2, stdio: 'ignore', timeout: 10000 });
|
|
217
|
+
execSync(`git reset --hard ${cpMatch.hash}`, { cwd: cwd2, stdio: 'ignore', timeout: 10000, ..._wh2 });
|
|
216
218
|
gitMsg2 = `\nπ ${diffFiles2.split('\n').length} δΈͺζδ»Άε·²ζ’ε€`;
|
|
217
219
|
cleanupCheckpoints(cwd2);
|
|
218
220
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { execSync } = require('child_process');
|
|
3
4
|
const { sleepSync } = require('./platform');
|
|
4
5
|
|
|
5
6
|
function createPidManager(deps) {
|
|
@@ -50,6 +51,7 @@ function setupRuntimeWatchers(deps) {
|
|
|
50
51
|
log,
|
|
51
52
|
notifyFn,
|
|
52
53
|
adminNotifyFn,
|
|
54
|
+
notifyPersonalFn,
|
|
53
55
|
activeProcesses,
|
|
54
56
|
getConfig,
|
|
55
57
|
setConfig,
|
|
@@ -68,7 +70,7 @@ function setupRuntimeWatchers(deps) {
|
|
|
68
70
|
refreshLogMaxSize(newConfig);
|
|
69
71
|
const timer = getHeartbeatTimer();
|
|
70
72
|
if (timer) clearInterval(timer);
|
|
71
|
-
setHeartbeatTimer(startHeartbeat(newConfig, notifyFn));
|
|
73
|
+
setHeartbeatTimer(startHeartbeat(newConfig, notifyFn, notifyPersonalFn));
|
|
72
74
|
const { general, project } = getAllTasks(newConfig);
|
|
73
75
|
const totalCount = general.length + project.length;
|
|
74
76
|
log('INFO', `Config reloaded: ${totalCount} tasks (${project.length} in projects)`);
|
|
@@ -91,6 +93,127 @@ function setupRuntimeWatchers(deps) {
|
|
|
91
93
|
}, 1000);
|
|
92
94
|
});
|
|
93
95
|
|
|
96
|
+
// ββ Pre-restart syntax validation ββββββββββββββββββββββββββββββββββββββββββ
|
|
97
|
+
// Catches the most common class of hot-reload failures: syntax errors from
|
|
98
|
+
// bad merges or careless agent edits. Runs `node -c` on all .js files in
|
|
99
|
+
// METAME_DIR before allowing the daemon to exit for restart.
|
|
100
|
+
function validateScriptsSyntax() {
|
|
101
|
+
try {
|
|
102
|
+
const jsFiles = fs.readdirSync(METAME_DIR).filter(f => f.endsWith('.js'));
|
|
103
|
+
const errors = [];
|
|
104
|
+
for (const f of jsFiles) {
|
|
105
|
+
const fp = path.join(METAME_DIR, f);
|
|
106
|
+
try {
|
|
107
|
+
execSync(`"${process.execPath}" -c "${fp}"`, {
|
|
108
|
+
timeout: 5000,
|
|
109
|
+
stdio: 'pipe',
|
|
110
|
+
windowsHide: true,
|
|
111
|
+
});
|
|
112
|
+
} catch (e) {
|
|
113
|
+
const msg = (e.stderr ? e.stderr.toString().trim() : e.message).split('\n')[0];
|
|
114
|
+
errors.push(`${f}: ${msg}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (errors.length > 0) {
|
|
118
|
+
return { ok: false, errors };
|
|
119
|
+
}
|
|
120
|
+
return { ok: true };
|
|
121
|
+
} catch (e) {
|
|
122
|
+
// If validation itself fails (e.g. can't read dir), allow restart
|
|
123
|
+
log('WARN', `Syntax validation skipped: ${e.message}`);
|
|
124
|
+
return { ok: true };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ββ Last-good backup βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
129
|
+
const LAST_GOOD_DIR = path.join(METAME_DIR, '.last-good');
|
|
130
|
+
|
|
131
|
+
function backupLastGood() {
|
|
132
|
+
try {
|
|
133
|
+
if (!fs.existsSync(LAST_GOOD_DIR)) fs.mkdirSync(LAST_GOOD_DIR, { recursive: true });
|
|
134
|
+
const jsFiles = fs.readdirSync(METAME_DIR).filter(f => f.endsWith('.js'));
|
|
135
|
+
for (const f of jsFiles) {
|
|
136
|
+
fs.copyFileSync(path.join(METAME_DIR, f), path.join(LAST_GOOD_DIR, f));
|
|
137
|
+
}
|
|
138
|
+
log('INFO', `[BACKUP] Saved ${jsFiles.length} scripts to .last-good/`);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
log('WARN', `[BACKUP] Failed: ${e.message}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function restoreFromLastGood() {
|
|
145
|
+
try {
|
|
146
|
+
if (!fs.existsSync(LAST_GOOD_DIR)) return false;
|
|
147
|
+
const files = fs.readdirSync(LAST_GOOD_DIR).filter(f => f.endsWith('.js'));
|
|
148
|
+
if (files.length === 0) return false;
|
|
149
|
+
for (const f of files) {
|
|
150
|
+
fs.copyFileSync(path.join(LAST_GOOD_DIR, f), path.join(METAME_DIR, f));
|
|
151
|
+
}
|
|
152
|
+
log('INFO', `[RESTORE] Restored ${files.length} scripts from .last-good/`);
|
|
153
|
+
return true;
|
|
154
|
+
} catch (e) {
|
|
155
|
+
log('ERROR', `[RESTORE] Failed: ${e.message}`);
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Delay initial backup: only backup after daemon has been running stably for 60s.
|
|
161
|
+
// This prevents backing up broken code that passed syntax check but fails at runtime.
|
|
162
|
+
const STABLE_BACKUP_DELAY_MS = 60 * 1000;
|
|
163
|
+
const stableBackupTimer = setTimeout(() => {
|
|
164
|
+
backupLastGood();
|
|
165
|
+
}, STABLE_BACKUP_DELAY_MS);
|
|
166
|
+
|
|
167
|
+
// ββ Crash-loop detection βββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
168
|
+
// Uses a consecutive crash counter (not just single boot timestamp) to avoid
|
|
169
|
+
// false positives from one-off crashes caused by user input rather than bad code.
|
|
170
|
+
const restartFromPid = process.env.METAME_RESTART_FROM_PID;
|
|
171
|
+
const bootFile = path.join(METAME_DIR, '.last-boot-ts');
|
|
172
|
+
const crashCountFile = path.join(METAME_DIR, '.crash-count');
|
|
173
|
+
if (restartFromPid) {
|
|
174
|
+
try {
|
|
175
|
+
if (fs.existsSync(bootFile)) {
|
|
176
|
+
const lastBoot = Number(fs.readFileSync(bootFile, 'utf8').trim());
|
|
177
|
+
const elapsed = Date.now() - lastBoot;
|
|
178
|
+
if (elapsed > 0 && elapsed < 30000) {
|
|
179
|
+
// Increment crash counter
|
|
180
|
+
let crashCount = 1;
|
|
181
|
+
try { crashCount = Number(fs.readFileSync(crashCountFile, 'utf8').trim()) + 1; } catch { /* first crash */ }
|
|
182
|
+
fs.writeFileSync(crashCountFile, String(crashCount), 'utf8');
|
|
183
|
+
log('FATAL', `[CRASH-LOOP] Previous daemon lived only ${Math.round(elapsed / 1000)}s (consecutive: ${crashCount})`);
|
|
184
|
+
if (crashCount >= 2) {
|
|
185
|
+
log('FATAL', `[CRASH-LOOP] ${crashCount} consecutive fast crashes β restoring from .last-good`);
|
|
186
|
+
const restored = restoreFromLastGood();
|
|
187
|
+
if (restored) {
|
|
188
|
+
adminNotifyFn('β οΈ ζ£ζ΅ε° daemon θΏη»ε΄©ζΊοΌε·²δ»δΈδΈδΈͺζ£εΈΈηζ¬ζ’ε€γθ―·ζ£ζ₯ζθΏη代η ζΉε¨γ').catch(() => {});
|
|
189
|
+
try { fs.writeFileSync(crashCountFile, '0', 'utf8'); } catch { /* non-fatal */ }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
// Previous daemon ran long enough β reset crash counter
|
|
194
|
+
try { fs.writeFileSync(crashCountFile, '0', 'utf8'); } catch { /* non-fatal */ }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} catch { /* non-fatal */ }
|
|
198
|
+
}
|
|
199
|
+
// Record boot timestamp for next crash-loop check
|
|
200
|
+
try { fs.writeFileSync(bootFile, String(Date.now()), 'utf8'); } catch { /* non-fatal */ }
|
|
201
|
+
|
|
202
|
+
// ββ Safe restart: validate then proceed ββββββββββββββββββββββββββββββββββ
|
|
203
|
+
function safeRestart() {
|
|
204
|
+
const validation = validateScriptsSyntax();
|
|
205
|
+
if (!validation.ok) {
|
|
206
|
+
const errSummary = validation.errors.slice(0, 3).join('\n');
|
|
207
|
+
log('ERROR', `[RESTART BLOCKED] Syntax errors detected:\n${errSummary}`);
|
|
208
|
+
adminNotifyFn(`π« Daemon ηιθ½½ε·²ι»ζ’ β ζ°δ»£η ζθ―ζ³ιθ――:\n${errSummary}\n\nε½ε daemon η»§η»θΏθ‘γ`).catch(() => {});
|
|
209
|
+
pendingRestart = false;
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
// Backup current known-good set before restarting with new code
|
|
213
|
+
backupLastGood();
|
|
214
|
+
onRestartRequested();
|
|
215
|
+
}
|
|
216
|
+
|
|
94
217
|
const daemonScript = path.join(METAME_DIR, 'daemon.js');
|
|
95
218
|
const startTime = Date.now();
|
|
96
219
|
let restartDebounce = null;
|
|
@@ -117,8 +240,8 @@ function setupRuntimeWatchers(deps) {
|
|
|
117
240
|
pendingRestart = true;
|
|
118
241
|
return;
|
|
119
242
|
}
|
|
120
|
-
log('INFO', 'daemon.js changed on disk β
|
|
121
|
-
|
|
243
|
+
log('INFO', 'daemon.js changed on disk β validating before restart...');
|
|
244
|
+
safeRestart();
|
|
122
245
|
}, 5000);
|
|
123
246
|
}
|
|
124
247
|
}, 2000);
|
|
@@ -128,8 +251,8 @@ function setupRuntimeWatchers(deps) {
|
|
|
128
251
|
activeProcesses.delete = function (key) {
|
|
129
252
|
const result = origDelete(key);
|
|
130
253
|
if (pendingRestart && activeProcesses.size === 0 && !deferredRestartTimer) {
|
|
131
|
-
log('INFO', 'All tasks completed β
|
|
132
|
-
deferredRestartTimer = setTimeout(
|
|
254
|
+
log('INFO', 'All tasks completed β validating deferred restart in 8s...');
|
|
255
|
+
deferredRestartTimer = setTimeout(safeRestart, 8000); // η» sendMessage/deleteMessage η cleanup ηεΊθΆ³ε€ζΆι΄
|
|
133
256
|
}
|
|
134
257
|
return result;
|
|
135
258
|
};
|
|
@@ -140,6 +263,7 @@ function setupRuntimeWatchers(deps) {
|
|
|
140
263
|
if (reloadDebounce) clearTimeout(reloadDebounce);
|
|
141
264
|
if (restartDebounce) clearTimeout(restartDebounce);
|
|
142
265
|
if (deferredRestartTimer) clearTimeout(deferredRestartTimer);
|
|
266
|
+
if (stableBackupTimer) clearTimeout(stableBackupTimer);
|
|
143
267
|
activeProcesses.delete = origDelete;
|
|
144
268
|
}
|
|
145
269
|
|
|
@@ -14,6 +14,7 @@ function createSessionCommandHandler(deps) {
|
|
|
14
14
|
sendBrowse,
|
|
15
15
|
sendDirPicker,
|
|
16
16
|
createSession,
|
|
17
|
+
getSessionForEngine,
|
|
17
18
|
getCachedFile,
|
|
18
19
|
getSession,
|
|
19
20
|
listRecentSessions,
|
|
@@ -26,8 +27,35 @@ function createSessionCommandHandler(deps) {
|
|
|
26
27
|
sessionRichLabel,
|
|
27
28
|
buildSessionCardElements,
|
|
28
29
|
sessionLabel,
|
|
30
|
+
getDefaultEngine = () => 'claude',
|
|
29
31
|
} = deps;
|
|
30
32
|
|
|
33
|
+
function normalizeEngineName(name) {
|
|
34
|
+
const n = String(name || '').trim().toLowerCase();
|
|
35
|
+
return n === 'codex' ? 'codex' : getDefaultEngine();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Write per-engine session slot, preserving cwd and other engine slots.
|
|
39
|
+
function attachEngineSession(state, chatId, engine, sessionId, cwd) {
|
|
40
|
+
const existing = state.sessions[chatId] || {};
|
|
41
|
+
const existingEngines = existing.engines || {};
|
|
42
|
+
state.sessions[chatId] = {
|
|
43
|
+
...existing,
|
|
44
|
+
cwd: cwd || existing.cwd || HOME,
|
|
45
|
+
engines: { ...existingEngines, [engine]: { id: sessionId, started: true } },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function inferEngineByCwd(cfg, cwd) {
|
|
50
|
+
if (!cfg || !cfg.projects || !cwd) return null;
|
|
51
|
+
const target = normalizeCwd(cwd);
|
|
52
|
+
for (const proj of Object.values(cfg.projects || {})) {
|
|
53
|
+
if (!proj || !proj.cwd) continue;
|
|
54
|
+
if (normalizeCwd(proj.cwd) === target) return normalizeEngineName(proj.engine);
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
31
59
|
async function handleSessionCommand(ctx) {
|
|
32
60
|
const { bot, chatId, text } = ctx;
|
|
33
61
|
|
|
@@ -61,7 +89,7 @@ function createSessionCommandHandler(deps) {
|
|
|
61
89
|
const boundProj = boundKey && newCfg.projects && newCfg.projects[boundKey];
|
|
62
90
|
if (boundProj && boundProj.cwd) {
|
|
63
91
|
const boundCwd = normalizeCwd(boundProj.cwd);
|
|
64
|
-
const session = createSession(chatId, boundCwd, '');
|
|
92
|
+
const session = createSession(chatId, boundCwd, '', normalizeEngineName(boundProj.engine));
|
|
65
93
|
await bot.sendMessage(chatId, `β
ζ°δΌθ―ε·²εε»Ί\nWorkdir: ${session.cwd}`);
|
|
66
94
|
return true;
|
|
67
95
|
}
|
|
@@ -87,7 +115,13 @@ function createSessionCommandHandler(deps) {
|
|
|
87
115
|
return true;
|
|
88
116
|
}
|
|
89
117
|
}
|
|
90
|
-
const
|
|
118
|
+
const cfgForEngine = loadConfig();
|
|
119
|
+
const mapForEngine = { ...(cfgForEngine.telegram ? cfgForEngine.telegram.chat_agent_map : {}), ...(cfgForEngine.feishu ? cfgForEngine.feishu.chat_agent_map : {}) };
|
|
120
|
+
const mappedKeyForEngine = mapForEngine[String(chatId)];
|
|
121
|
+
const mappedProjForEngine = mappedKeyForEngine && cfgForEngine.projects ? cfgForEngine.projects[mappedKeyForEngine] : null;
|
|
122
|
+
const currentEngine = getDefaultEngine();
|
|
123
|
+
const sessionEngine = normalizeEngineName((mappedProjForEngine && mappedProjForEngine.engine) || currentEngine);
|
|
124
|
+
const session = createSession(chatId, dirPath, sessionName || '', sessionEngine);
|
|
91
125
|
const label = sessionName ? `[${sessionName}]` : '';
|
|
92
126
|
await bot.sendMessage(chatId, `New session ${label}\nWorkdir: ${session.cwd}`);
|
|
93
127
|
return true;
|
|
@@ -142,23 +176,18 @@ function createSessionCommandHandler(deps) {
|
|
|
142
176
|
if (!s) {
|
|
143
177
|
// Last resort: use __continue__ to resume whatever Claude thinks is last
|
|
144
178
|
const state2 = loadState();
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
created: new Date().toISOString(),
|
|
149
|
-
started: true,
|
|
150
|
-
};
|
|
179
|
+
const cfgForEngine = loadConfig();
|
|
180
|
+
const engineByCwd = inferEngineByCwd(cfgForEngine, curCwd || HOME) || getDefaultEngine();
|
|
181
|
+
attachEngineSession(state2, chatId, engineByCwd, '__continue__', curCwd || HOME);
|
|
151
182
|
saveState(state2);
|
|
152
183
|
await bot.sendMessage(chatId, `β‘ Resuming last session in ${path.basename(curCwd || HOME)}`);
|
|
153
184
|
return true;
|
|
154
185
|
}
|
|
155
186
|
|
|
156
187
|
const state2 = loadState();
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
started: true,
|
|
161
|
-
};
|
|
188
|
+
const cfgForEngine = loadConfig();
|
|
189
|
+
const engineByCwd = inferEngineByCwd(cfgForEngine, s.projectPath || HOME) || getDefaultEngine();
|
|
190
|
+
attachEngineSession(state2, chatId, engineByCwd, s.sessionId, s.projectPath || HOME);
|
|
162
191
|
saveState(state2);
|
|
163
192
|
// Display: name/summary + id on separate lines
|
|
164
193
|
const name = s.customTitle;
|
|
@@ -223,9 +252,12 @@ function createSessionCommandHandler(deps) {
|
|
|
223
252
|
|
|
224
253
|
// /sessions β compact list, tap to see details, then tap to switch
|
|
225
254
|
if (text === '/sessions') {
|
|
255
|
+
const currentEngine = getDefaultEngine();
|
|
256
|
+
const codexLimitTip = 'β οΈ ε½εδΈΊ Codex δΌθ―οΌ`/sessions` ε葨ζδ»
ε±η€Ί Claude ζ¬ε°δΌθ―οΌCodex δΌθ―ζδΈε―θ§γ';
|
|
226
257
|
const allSessions = listRecentSessions(15);
|
|
227
258
|
if (allSessions.length === 0) {
|
|
228
|
-
|
|
259
|
+
const base = 'No sessions found. Try /new first.';
|
|
260
|
+
await bot.sendMessage(chatId, currentEngine === 'codex' ? `${base}\n\n${codexLimitTip}` : base);
|
|
229
261
|
return true;
|
|
230
262
|
}
|
|
231
263
|
if (bot.sendButtons) {
|
|
@@ -236,8 +268,12 @@ function createSessionCommandHandler(deps) {
|
|
|
236
268
|
allSessions.forEach((s, i) => {
|
|
237
269
|
msg += sessionRichLabel(s, i + 1, _tags1) + '\n';
|
|
238
270
|
});
|
|
271
|
+
if (currentEngine === 'codex') msg += `\n${codexLimitTip}\n`;
|
|
239
272
|
await bot.sendMessage(chatId, msg);
|
|
240
273
|
}
|
|
274
|
+
if (bot.sendButtons && currentEngine === 'codex') {
|
|
275
|
+
await bot.sendMessage(chatId, codexLimitTip);
|
|
276
|
+
}
|
|
241
277
|
return true;
|
|
242
278
|
}
|
|
243
279
|
|
|
@@ -348,11 +384,9 @@ function createSessionCommandHandler(deps) {
|
|
|
348
384
|
const target = candidates[0];
|
|
349
385
|
// Switch to that session (like /resume) AND its directory
|
|
350
386
|
const state2 = loadState();
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
started: true,
|
|
355
|
-
};
|
|
387
|
+
const cfgForEngine = loadConfig();
|
|
388
|
+
const engineByCwd = inferEngineByCwd(cfgForEngine, target.projectPath) || getDefaultEngine();
|
|
389
|
+
attachEngineSession(state2, chatId, engineByCwd, target.sessionId, target.projectPath);
|
|
356
390
|
saveState(state2);
|
|
357
391
|
const name = target.customTitle || target.summary || '';
|
|
358
392
|
const label = name ? name.slice(0, 40) : target.sessionId.slice(0, 8);
|
|
@@ -378,16 +412,17 @@ function createSessionCommandHandler(deps) {
|
|
|
378
412
|
if (recentInDir.length > 0 && recentInDir[0].sessionId) {
|
|
379
413
|
// Attach to existing session in this directory
|
|
380
414
|
const target = recentInDir[0];
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
started: true,
|
|
385
|
-
};
|
|
415
|
+
const cfgForEngine = loadConfig();
|
|
416
|
+
const engineByCwd = inferEngineByCwd(cfgForEngine, newCwd) || getDefaultEngine();
|
|
417
|
+
attachEngineSession(state2, chatId, engineByCwd, target.sessionId, newCwd);
|
|
386
418
|
saveState(state2);
|
|
387
419
|
const label = target.customTitle || target.summary?.slice(0, 30) || target.sessionId.slice(0, 8);
|
|
388
420
|
await bot.sendMessage(chatId, `π ${path.basename(newCwd)}\nπ Attached: ${label}`);
|
|
389
421
|
} else if (!state2.sessions[chatId]) {
|
|
390
|
-
|
|
422
|
+
const cfgForEngine = loadConfig();
|
|
423
|
+
const engineByCwd = inferEngineByCwd(cfgForEngine, newCwd);
|
|
424
|
+
const currentEngine = getDefaultEngine();
|
|
425
|
+
createSession(chatId, newCwd, '', engineByCwd || currentEngine);
|
|
391
426
|
await bot.sendMessage(chatId, `π ${path.basename(newCwd)} (new session)`);
|
|
392
427
|
} else {
|
|
393
428
|
state2.sessions[chatId].cwd = newCwd;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
|
|
5
|
+
|
|
5
6
|
function createSessionStore(deps) {
|
|
6
7
|
const {
|
|
7
8
|
fs,
|
|
@@ -18,7 +19,7 @@ function createSessionStore(deps) {
|
|
|
18
19
|
const _sessionFileCache = new Map(); // sessionId -> { path, ts }
|
|
19
20
|
let _sessionCache = null;
|
|
20
21
|
let _sessionCacheTime = 0;
|
|
21
|
-
const SESSION_CACHE_TTL =
|
|
22
|
+
const SESSION_CACHE_TTL = 30000; // 30s β scan is expensive, 10s was too frequent
|
|
22
23
|
|
|
23
24
|
function findSessionFile(sessionId) {
|
|
24
25
|
if (!sessionId || !fs.existsSync(CLAUDE_PROJECTS_DIR)) return null;
|
|
@@ -192,7 +193,8 @@ function createSessionStore(deps) {
|
|
|
192
193
|
const fileMtime = stat.mtimeMs;
|
|
193
194
|
const existing = sessionMap.get(sessionId);
|
|
194
195
|
if (!existing || fileMtime > (existing.fileMtime || 0)) {
|
|
195
|
-
const projectPath = projPathCache.get(proj)
|
|
196
|
+
const projectPath = projPathCache.get(proj);
|
|
197
|
+
if (!projectPath) continue;
|
|
196
198
|
sessionMap.set(sessionId, {
|
|
197
199
|
sessionId, projectPath, fileMtime,
|
|
198
200
|
modified: new Date(fileMtime).toISOString(),
|
|
@@ -427,24 +429,46 @@ function createSessionStore(deps) {
|
|
|
427
429
|
}
|
|
428
430
|
}
|
|
429
431
|
|
|
432
|
+
function sanitizeCwd(cwd) {
|
|
433
|
+
try {
|
|
434
|
+
const resolved = path.resolve(String(cwd || HOME));
|
|
435
|
+
if (process.platform === 'win32' && !/^[A-Za-z]:[\\\/]/i.test(resolved)) return HOME;
|
|
436
|
+
const stat = fs.statSync(resolved, { throwIfNoEntry: false });
|
|
437
|
+
if (!stat || !stat.isDirectory()) return HOME;
|
|
438
|
+
return resolved;
|
|
439
|
+
} catch { return HOME; }
|
|
440
|
+
}
|
|
441
|
+
|
|
430
442
|
function getSession(chatId) {
|
|
431
443
|
const state = loadState();
|
|
432
444
|
return state.sessions[chatId] || null;
|
|
433
445
|
}
|
|
434
446
|
|
|
435
|
-
function
|
|
447
|
+
function getSessionForEngine(chatId, engine) {
|
|
448
|
+
const raw = getSession(chatId);
|
|
449
|
+
if (!raw) return null;
|
|
450
|
+
const safeEngine = String(engine || 'claude').trim().toLowerCase() === 'codex' ? 'codex' : 'claude';
|
|
451
|
+
if (!raw.engines) return { cwd: raw.cwd, engine: safeEngine, id: raw.id || null, started: !!raw.started };
|
|
452
|
+
const slot = raw.engines[safeEngine] || {};
|
|
453
|
+
return { cwd: raw.cwd, engine: safeEngine, id: slot.id || null, started: !!slot.started };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function createSession(chatId, cwd, name, engine = 'claude') {
|
|
436
457
|
const state = loadState();
|
|
458
|
+
const safeEngine = String(engine || 'claude').trim().toLowerCase() === 'codex' ? 'codex' : 'claude';
|
|
459
|
+
const safeCwd = sanitizeCwd(cwd);
|
|
437
460
|
const sessionId = crypto.randomUUID();
|
|
461
|
+
const existing = state.sessions[chatId] || {};
|
|
462
|
+
const existingEngines = existing.engines || {};
|
|
438
463
|
state.sessions[chatId] = {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
started: false,
|
|
464
|
+
cwd: safeCwd,
|
|
465
|
+
engines: { ...existingEngines, [safeEngine]: { id: sessionId, started: false } },
|
|
442
466
|
};
|
|
443
467
|
saveState(state);
|
|
444
468
|
invalidateSessionCache();
|
|
445
|
-
if (name) writeSessionName(sessionId,
|
|
446
|
-
log('INFO', `New session for ${chatId}: ${sessionId}${name ? ' [' + name + ']' : ''} (cwd: ${
|
|
447
|
-
return
|
|
469
|
+
if (name) writeSessionName(sessionId, safeCwd, name);
|
|
470
|
+
log('INFO', `New session for ${chatId}: ${sessionId}${name ? ' [' + name + ']' : ''} (cwd: ${safeCwd}) [${safeEngine}]`);
|
|
471
|
+
return getSessionForEngine(chatId, safeEngine);
|
|
448
472
|
}
|
|
449
473
|
|
|
450
474
|
function getSessionName(sessionId) {
|
|
@@ -525,12 +549,94 @@ function createSessionStore(deps) {
|
|
|
525
549
|
} catch { return null; }
|
|
526
550
|
}
|
|
527
551
|
|
|
528
|
-
function markSessionStarted(chatId) {
|
|
552
|
+
function markSessionStarted(chatId, engine) {
|
|
529
553
|
const state = loadState();
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
554
|
+
const s = state.sessions[chatId];
|
|
555
|
+
if (!s) return;
|
|
556
|
+
if (s.engines) {
|
|
557
|
+
const safeEngine = String(engine || 'claude').trim().toLowerCase() === 'codex' ? 'codex' : 'claude';
|
|
558
|
+
if (!s.engines[safeEngine]) s.engines[safeEngine] = {};
|
|
559
|
+
s.engines[safeEngine].started = true;
|
|
560
|
+
} else {
|
|
561
|
+
s.started = true; // old flat format
|
|
533
562
|
}
|
|
563
|
+
saveState(state);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Codex session validation via ~/.codex/state_5.sqlite
|
|
567
|
+
// βββ Unified session validation ββββββββββββββββββββββββββββββββββββββββββ
|
|
568
|
+
// Both engines store sessions locally; only the backend differs.
|
|
569
|
+
// Single entry point: isEngineSessionValid(engine, sessionId, cwd)
|
|
570
|
+
|
|
571
|
+
const SESSION_VALIDATE_TTL = 30000;
|
|
572
|
+
const _validateCache = new Map(); // `${engine}@@${sessionId}@@${cwd}` -> { valid, ts }
|
|
573
|
+
|
|
574
|
+
function _cacheValidation(key, valid) {
|
|
575
|
+
_validateCache.set(key, { valid: !!valid, ts: Date.now() });
|
|
576
|
+
if (_validateCache.size > 512) _validateCache.delete(_validateCache.keys().next().value);
|
|
577
|
+
return !!valid;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Claude backend: JSONL files under ~/.claude/projects/<hash>/
|
|
581
|
+
function _isClaudeSessionValid(sessionId, normCwd) {
|
|
582
|
+
try {
|
|
583
|
+
const sessionFile = findSessionFile(sessionId);
|
|
584
|
+
if (!sessionFile) return false;
|
|
585
|
+
const projectDir = path.dirname(sessionFile);
|
|
586
|
+
const indexFile = path.join(projectDir, 'sessions-index.json');
|
|
587
|
+
if (fs.existsSync(indexFile)) {
|
|
588
|
+
const data = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
|
|
589
|
+
const entries = Array.isArray(data && data.entries) ? data.entries : [];
|
|
590
|
+
const entry = entries.find(e => e && e.sessionId === sessionId);
|
|
591
|
+
if (entry && entry.projectPath) return path.resolve(entry.projectPath) === normCwd;
|
|
592
|
+
const anyPath = (entries.find(e => e && e.projectPath) || {}).projectPath;
|
|
593
|
+
if (anyPath) return path.resolve(anyPath) === normCwd;
|
|
594
|
+
}
|
|
595
|
+
// Weak fallback: Claude encodes cwd in dir name; only trust a positive match.
|
|
596
|
+
// Unix: /home/user/project β -home-user-project
|
|
597
|
+
// Windows: D:\MetaMe β D--MetaMe (replaces : and \ with -)
|
|
598
|
+
const actualDir = path.basename(projectDir).toLowerCase();
|
|
599
|
+
const expectedDir = process.platform === 'win32'
|
|
600
|
+
? normCwd.replace(/[:\\\/_ ]/g, '-').toLowerCase()
|
|
601
|
+
: ('-' + normCwd.replace(/^\//, '').replace(/[\/_ ]/g, '-')).toLowerCase();
|
|
602
|
+
if (actualDir === expectedDir) return true;
|
|
603
|
+
return false; // dir name mismatch β session belongs to a different project
|
|
604
|
+
} catch {
|
|
605
|
+
return true; // conservative: infra failure β invalid session
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Codex backend: SQLite index at ~/.codex/state_5.sqlite
|
|
610
|
+
const CODEX_DB = path.join(HOME, '.codex', 'state_5.sqlite');
|
|
611
|
+
function _isCodexSessionValid(sessionId, normCwd) {
|
|
612
|
+
let db = null;
|
|
613
|
+
try {
|
|
614
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
615
|
+
db = new DatabaseSync(CODEX_DB, { readonly: true });
|
|
616
|
+
const row = db.prepare('SELECT cwd FROM threads WHERE id = ?').get(sessionId);
|
|
617
|
+
db.close();
|
|
618
|
+
db = null;
|
|
619
|
+
return !!row && path.resolve(row.cwd) === normCwd;
|
|
620
|
+
} catch (e) {
|
|
621
|
+
if (db) { try { db.close(); } catch { /* ignore */ } }
|
|
622
|
+
// Transient errors (DB locked, busy) should not invalidate a live session.
|
|
623
|
+
// Only treat "session truly not found" as invalid; infra failures are conservative.
|
|
624
|
+
const msg = (e && e.message) || '';
|
|
625
|
+
if (msg.includes('SQLITE_BUSY') || msg.includes('SQLITE_LOCKED')) return true;
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function isEngineSessionValid(engine, sessionId, cwd) {
|
|
631
|
+
if (!sessionId || !cwd || sessionId === '__continue__') return true;
|
|
632
|
+
const normCwd = path.resolve(cwd);
|
|
633
|
+
const key = `${engine}@@${sessionId}@@${normCwd}`;
|
|
634
|
+
const cached = _validateCache.get(key);
|
|
635
|
+
if (cached && Date.now() - cached.ts < SESSION_VALIDATE_TTL) return cached.valid;
|
|
636
|
+
const valid = engine === 'codex'
|
|
637
|
+
? _isCodexSessionValid(sessionId, normCwd)
|
|
638
|
+
: _isClaudeSessionValid(sessionId, normCwd);
|
|
639
|
+
return _cacheValidation(key, valid);
|
|
534
640
|
}
|
|
535
641
|
|
|
536
642
|
return {
|
|
@@ -547,11 +653,13 @@ function createSessionStore(deps) {
|
|
|
547
653
|
buildSessionCardElements,
|
|
548
654
|
listProjectDirs,
|
|
549
655
|
getSession,
|
|
656
|
+
getSessionForEngine,
|
|
550
657
|
createSession,
|
|
551
658
|
getSessionName,
|
|
552
659
|
writeSessionName,
|
|
553
660
|
markSessionStarted,
|
|
554
661
|
getSessionRecentContext,
|
|
662
|
+
isEngineSessionValid,
|
|
555
663
|
};
|
|
556
664
|
}
|
|
557
665
|
|