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.
Files changed (48) hide show
  1. package/README.md +136 -94
  2. package/index.js +312 -57
  3. package/package.json +8 -4
  4. package/scripts/agent-layer.js +320 -0
  5. package/scripts/daemon-admin-commands.js +328 -28
  6. package/scripts/daemon-agent-commands.js +145 -6
  7. package/scripts/daemon-agent-tools.js +163 -7
  8. package/scripts/daemon-bridges.js +110 -20
  9. package/scripts/daemon-checkpoints.js +36 -7
  10. package/scripts/daemon-claude-engine.js +849 -358
  11. package/scripts/daemon-command-router.js +31 -10
  12. package/scripts/daemon-default.yaml +28 -4
  13. package/scripts/daemon-engine-runtime.js +328 -0
  14. package/scripts/daemon-exec-commands.js +15 -7
  15. package/scripts/daemon-notify.js +37 -1
  16. package/scripts/daemon-ops-commands.js +8 -6
  17. package/scripts/daemon-runtime-lifecycle.js +129 -5
  18. package/scripts/daemon-session-commands.js +60 -25
  19. package/scripts/daemon-session-store.js +121 -13
  20. package/scripts/daemon-task-scheduler.js +129 -49
  21. package/scripts/daemon-user-acl.js +35 -9
  22. package/scripts/daemon.js +268 -33
  23. package/scripts/distill.js +327 -18
  24. package/scripts/docs/agent-guide.md +12 -0
  25. package/scripts/docs/maintenance-manual.md +155 -0
  26. package/scripts/docs/pointer-map.md +110 -0
  27. package/scripts/feishu-adapter.js +42 -13
  28. package/scripts/hooks/stop-session-capture.js +243 -0
  29. package/scripts/memory-extract.js +105 -6
  30. package/scripts/memory-nightly-reflect.js +199 -11
  31. package/scripts/memory.js +134 -3
  32. package/scripts/mentor-engine.js +405 -0
  33. package/scripts/platform.js +24 -0
  34. package/scripts/providers.js +182 -22
  35. package/scripts/schema.js +12 -0
  36. package/scripts/session-analytics.js +245 -12
  37. package/scripts/skill-changelog.js +245 -0
  38. package/scripts/skill-evolution.js +288 -5
  39. package/scripts/telegram-adapter.js +12 -8
  40. package/scripts/usage-classifier.js +1 -1
  41. package/scripts/daemon-admin-commands.test.js +0 -333
  42. package/scripts/daemon-task-envelope.test.js +0 -59
  43. package/scripts/daemon-task-scheduler.test.js +0 -106
  44. package/scripts/reliability-core.test.js +0 -280
  45. package/scripts/skill-evolution.test.js +0 -113
  46. package/scripts/task-board.test.js +0 -83
  47. package/scripts/test_daemon.js +0 -1407
  48. 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
- try { diffFiles = execSync(`git diff --name-only HEAD ${match.hash}`, { cwd, encoding: 'utf8', timeout: 5000 }).trim(); } catch { }
74
- execSync(`git reset --hard ${match.hash}`, { cwd, stdio: 'ignore', timeout: 10000 });
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
- try { diffFiles2 = execSync(`git diff --name-only HEAD ${cpMatch.hash}`, { cwd: cwd2, encoding: 'utf8', timeout: 5000 }).trim(); } catch { }
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 β€” exiting for restart...');
121
- onRestartRequested();
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 β€” executing deferred restart in 8s...');
132
- deferredRestartTimer = setTimeout(onRestartRequested, 8000); // η»™ sendMessage/deleteMessage η­‰ cleanup η•™ε‡ΊθΆ³ε€Ÿζ—Άι—΄
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 session = createSession(chatId, dirPath, sessionName || '');
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
- state2.sessions[chatId] = {
146
- id: '__continue__',
147
- cwd: curCwd || HOME,
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
- state2.sessions[chatId] = {
158
- id: s.sessionId,
159
- cwd: s.projectPath || HOME,
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
- await bot.sendMessage(chatId, 'No sessions found. Try /new first.');
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
- state2.sessions[chatId] = {
352
- id: target.sessionId,
353
- cwd: target.projectPath,
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
- state2.sessions[chatId] = {
382
- id: target.sessionId,
383
- cwd: newCwd,
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
- createSession(chatId, newCwd);
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 = 10000; // 10s
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) || proj.slice(1).replace(/-/g, '/');
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 createSession(chatId, cwd, name) {
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
- id: sessionId,
440
- cwd: cwd || HOME,
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, cwd || HOME, name);
446
- log('INFO', `New session for ${chatId}: ${sessionId}${name ? ' [' + name + ']' : ''} (cwd: ${state.sessions[chatId].cwd})`);
447
- return { ...state.sessions[chatId], id: sessionId };
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
- if (state.sessions[chatId]) {
531
- state.sessions[chatId].started = true;
532
- saveState(state);
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