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
@@ -1,6 +1,9 @@
1
1
  'use strict';
2
2
 
3
3
  const { classifyChatUsage } = require('./usage-classifier');
4
+ const { deriveProjectInfo } = require('./utils');
5
+ const { createEngineRuntimeFactory, normalizeEngineName, ENGINE_MODEL_CONFIG } = require('./daemon-engine-runtime');
6
+ const { buildAgentContextForEngine, buildMemorySnapshotContent, refreshMemorySnapshot } = require('./agent-layer');
4
7
 
5
8
  function createClaudeEngine(deps) {
6
9
  const {
@@ -30,93 +33,158 @@ function createClaudeEngine(deps) {
30
33
  findSessionFile,
31
34
  listRecentSessions,
32
35
  getSession,
36
+ getSessionForEngine,
33
37
  createSession,
34
38
  getSessionName,
35
39
  writeSessionName,
36
40
  markSessionStarted,
41
+ isEngineSessionValid,
37
42
  gitCheckpoint,
43
+ gitCheckpointAsync,
38
44
  recordTokens,
39
45
  skillEvolution,
40
46
  touchInteraction,
41
47
  statusThrottleMs = 3000,
42
48
  fallbackThrottleMs = 8000,
49
+ getEngineRuntime: injectedGetEngineRuntime,
50
+ getDefaultEngine: _getDefaultEngine,
43
51
  } = deps;
52
+ function getDefaultEngine() {
53
+ return (typeof _getDefaultEngine === 'function') ? _getDefaultEngine() : 'claude';
54
+ }
55
+ let mentorEngine = null;
56
+ try { mentorEngine = require('./mentor-engine'); } catch { /* optional */ }
57
+ let sessionAnalytics = null;
58
+ try { sessionAnalytics = require('./session-analytics'); } catch { /* optional */ }
59
+
60
+ const getEngineRuntime = typeof injectedGetEngineRuntime === 'function'
61
+ ? injectedGetEngineRuntime
62
+ : createEngineRuntimeFactory({ fs, path, HOME, CLAUDE_BIN, getActiveProviderEnv });
63
+
64
+ // On Windows, spawning .cmd files via shell:true causes cmd.exe to flash briefly.
65
+ // Instead, read the .cmd wrapper, extract the real Node.js entry point, and spawn
66
+ // `node <entry.js> <args>` directly — completely bypasses cmd.exe, zero flash.
67
+ function resolveNodeEntry(cmdPath) {
68
+ try {
69
+ const content = fs.readFileSync(cmdPath, 'utf8');
70
+ // Match the quoted .js path just before %* at end of last exec line
71
+ const m = content.match(/"([^"]+\.js)"\s*%\*\s*$/m);
72
+ if (m) {
73
+ // Substitute %dp0% (batch var for the cmd file's own directory)
74
+ const entry = m[1].replace(/%dp0%/gi, path.dirname(cmdPath) + path.sep);
75
+ if (fs.existsSync(entry)) return entry;
76
+ }
77
+ } catch { /* ignore */ }
78
+ return null;
79
+ }
44
80
 
45
- // On Windows, .cmd files need shell to spawn; use COMSPEC to avoid conda PATH issues
46
- function spawn(cmd, args, options) {
47
- if (process.platform === 'win32' && cmd === CLAUDE_BIN) {
48
- return _spawn(cmd, args, { ...options, shell: process.env.COMSPEC || true });
81
+ // Cache resolved entries so we only read .cmd files once
82
+ const _nodeEntryCache = new Map();
83
+ function resolveNodeEntryForCmd(cmd) {
84
+ if (_nodeEntryCache.has(cmd)) return _nodeEntryCache.get(cmd);
85
+ let cmdPath = cmd;
86
+ const lowerCmd = String(cmd || '').toLowerCase();
87
+ // If bare name (not a file path), find the .cmd via where
88
+ if (lowerCmd === 'claude' || lowerCmd === 'codex') {
89
+ try {
90
+ const { execSync: _es } = require('child_process');
91
+ const lines = _es(`where ${cmd}`, { encoding: 'utf8', timeout: 3000 })
92
+ .split('\n').map(l => l.trim()).filter(Boolean);
93
+ cmdPath = lines.find(l => l.toLowerCase().endsWith(`${lowerCmd}.cmd`)) || lines[0] || cmd;
94
+ } catch { /* ignore */ }
49
95
  }
50
- return _spawn(cmd, args, options);
96
+ const entry = resolveNodeEntry(cmdPath);
97
+ _nodeEntryCache.set(cmd, entry);
98
+ return entry;
51
99
  }
52
100
 
53
- const SESSION_CWD_VALIDATION_TTL_MS = 30 * 1000;
54
- const _sessionCwdValidationCache = new Map(); // key: `${sessionId}@@${cwd}` -> { inCwd, ts }
101
+ function spawn(cmd, args, options) {
102
+ if (process.platform !== 'win32') return _spawn(cmd, args, options);
55
103
 
56
- function cacheSessionCwdValidation(cacheKey, inCwd) {
57
- _sessionCwdValidationCache.set(cacheKey, { inCwd: !!inCwd, ts: Date.now() });
58
- if (_sessionCwdValidationCache.size > 512) {
59
- const firstKey = _sessionCwdValidationCache.keys().next().value;
60
- if (firstKey) _sessionCwdValidationCache.delete(firstKey);
104
+ const lowerCmd = String(cmd || '').toLowerCase();
105
+ const isCmdLike = lowerCmd.endsWith('.cmd') || lowerCmd.endsWith('.bat')
106
+ || cmd === CLAUDE_BIN || lowerCmd === 'claude' || lowerCmd === 'codex';
107
+
108
+ if (isCmdLike) {
109
+ const entry = resolveNodeEntryForCmd(cmd);
110
+ if (entry) {
111
+ // Run node directly — no cmd.exe, no flash
112
+ return _spawn(process.execPath, [entry, ...args], { ...options, windowsHide: true });
113
+ }
114
+ // Fallback: shell with windowsHide
115
+ return _spawn(cmd, args, { ...options, shell: process.env.COMSPEC || true, windowsHide: true });
61
116
  }
62
- return !!inCwd;
117
+ return _spawn(cmd, args, { ...options, windowsHide: true });
63
118
  }
64
119
 
65
- function isSessionInCwd(sessionId, cwd) {
66
- const safeSessionId = String(sessionId || '').trim();
67
- if (!safeSessionId || !cwd) return false;
120
+ // Per-chatId patch queues: Agent A's writes never block Agent B.
121
+ const _patchQueues = new Map(); // chatId -> Promise
122
+ function patchSessionSerialized(chatId, patchFn) {
123
+ const prev = _patchQueues.get(chatId) || Promise.resolve();
124
+ const next = prev.then(() => {
125
+ const state = loadState();
126
+ if (!state.sessions) state.sessions = {};
127
+ const cur = state.sessions[chatId] || {};
128
+ const patched = typeof patchFn === 'function' ? patchFn(cur) : cur;
129
+ state.sessions[chatId] = patched && typeof patched === 'object' ? patched : cur;
130
+ saveState(state);
131
+ }).catch((e) => {
132
+ log('WARN', `patchSessionSerialized failed for ${chatId}: ${e.message}`);
133
+ });
134
+ _patchQueues.set(chatId, next);
135
+ // GC: remove resolved entries to prevent unbounded Map growth
136
+ next.then(() => { if (_patchQueues.get(chatId) === next) _patchQueues.delete(chatId); });
137
+ return next;
138
+ }
68
139
 
69
- const normCwd = normalizeCwd(cwd);
70
- const cacheKey = `${safeSessionId}@@${normCwd}`;
71
- const cached = _sessionCwdValidationCache.get(cacheKey);
72
- if (cached && (Date.now() - cached.ts) < SESSION_CWD_VALIDATION_TTL_MS) {
73
- return !!cached.inCwd;
74
- }
140
+ const CODEX_RESUME_RETRY_WINDOW_MS = 10 * 60 * 1000;
141
+ const _codexResumeRetryTs = new Map(); // chatId -> last retry ts
75
142
 
76
- try {
77
- // Fast path: locate the exact session file, then validate its indexed projectPath.
78
- if (typeof findSessionFile === 'function') {
79
- const sessionFile = findSessionFile(safeSessionId);
80
- if (!sessionFile) return cacheSessionCwdValidation(cacheKey, false);
81
-
82
- const projectDir = path.dirname(sessionFile);
83
- const indexFile = path.join(projectDir, 'sessions-index.json');
84
- if (fs.existsSync(indexFile)) {
85
- const data = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
86
- const entries = Array.isArray(data && data.entries) ? data.entries : [];
87
- const entry = entries.find(e => e && e.sessionId === safeSessionId);
88
- if (entry && entry.projectPath) {
89
- return cacheSessionCwdValidation(cacheKey, normalizeCwd(entry.projectPath) === normCwd);
90
- }
91
- // sessions-index may lag behind new sessions; use project-level path from any entry.
92
- const anyProjectPath = (entries.find(e => e && e.projectPath) || {}).projectPath;
93
- if (anyProjectPath) {
94
- return cacheSessionCwdValidation(cacheKey, normalizeCwd(anyProjectPath) === normCwd);
95
- }
96
- }
143
+ function canRetryCodexResume(chatId) {
144
+ const key = String(chatId || '');
145
+ if (!key) return false;
146
+ const last = Number(_codexResumeRetryTs.get(key) || 0);
147
+ if (!last) return true;
148
+ return (Date.now() - last) > CODEX_RESUME_RETRY_WINDOW_MS;
149
+ }
97
150
 
98
- // Weak fallback: encode normCwd using Claude's folder convention and accept
99
- // only positive match. If it doesn't match, keep current session to avoid
100
- // false mismatches for paths with non-ASCII/special characters.
101
- const expectedDirName = '-' + normCwd.replace(/^\//, '').replace(/[\/_ ]/g, '-');
102
- const actualDirName = path.basename(projectDir);
103
- if (actualDirName === expectedDirName) {
104
- return cacheSessionCwdValidation(cacheKey, true);
105
- }
106
- // Unable to prove mismatch safely.
107
- return cacheSessionCwdValidation(cacheKey, true);
108
- }
151
+ function markCodexResumeRetried(chatId) {
152
+ const key = String(chatId || '');
153
+ if (!key) return;
154
+ _codexResumeRetryTs.set(key, Date.now());
155
+ }
109
156
 
110
- // Ultimate fallback (legacy path): scoped scan in target cwd.
111
- const recentInCwd = listRecentSessions(1000, normCwd);
112
- const existsInCwd = recentInCwd.some(s => s.sessionId === safeSessionId);
113
- return cacheSessionCwdValidation(cacheKey, existsInCwd);
114
- } catch {
115
- // Conservative fallback: if validation infra fails, avoid false negatives by preserving current session.
116
- return cacheSessionCwdValidation(cacheKey, true);
157
+ function shouldRetryCodexResumeFallback({ runtimeName, wasResumeAttempt, output, error, errorCode, canRetry }) {
158
+ return runtimeName === 'codex'
159
+ && !!wasResumeAttempt
160
+ && !!error
161
+ && (!output || !!errorCode)
162
+ && !!canRetry;
163
+ }
164
+
165
+ function formatEngineSpawnError(err, runtime) {
166
+ if (!err) return 'Unknown spawn error';
167
+ const rt = runtime || { name: getDefaultEngine() };
168
+ if (err.code === 'ENOENT') {
169
+ if (rt.name === 'codex') {
170
+ return 'Codex CLI 未安装。请先运行: npm install -g @openai/codex';
171
+ }
172
+ return 'Claude CLI 未安装或不在 PATH。请先确认 `claude` 可执行。';
117
173
  }
174
+ return err.message || String(err);
118
175
  }
119
176
 
177
+ function adaptDaemonHintForEngine(daemonHint, engineName) {
178
+ if (normalizeEngineName(engineName) === 'claude') return daemonHint;
179
+ let out = String(daemonHint || '');
180
+ // Keep this replacement conservative: only unwrap the known outer wrapper.
181
+ out = out.replace('[System hints - DO NOT mention these to user:', 'System hints (internal, do not mention to user):');
182
+ // The current daemonHint template ends with a single trailing `]`.
183
+ out = out.replace(/\]\s*$/, '');
184
+ return out;
185
+ }
186
+
187
+
120
188
  /**
121
189
  * Parse [[FILE:...]] markers from Claude output.
122
190
  * Returns { markedFiles, cleanOutput }
@@ -192,6 +260,82 @@ function createClaudeEngine(deps) {
192
260
  return null;
193
261
  }
194
262
 
263
+ function resolveMentorMode(cfg = {}) {
264
+ const mode = String(cfg.mode || '').trim().toLowerCase();
265
+ if (mode === 'gentle' || mode === 'active' || mode === 'intense') return mode;
266
+ const level = Number(cfg.friction_level);
267
+ if (Number.isFinite(level)) {
268
+ if (level >= 8) return 'intense';
269
+ if (level >= 4) return 'active';
270
+ }
271
+ return 'gentle';
272
+ }
273
+
274
+ function extractUserText(content) {
275
+ if (typeof content === 'string') return content;
276
+ if (!Array.isArray(content)) return '';
277
+ for (const item of content) {
278
+ if (item && item.type === 'text' && item.text) return item.text;
279
+ }
280
+ return '';
281
+ }
282
+
283
+ function collectRecentSessionSignals(sessionId, limit = 6) {
284
+ const out = { recentMessages: [], sessionStartTime: null };
285
+ if (!sessionId || typeof findSessionFile !== 'function') return out;
286
+ const file = findSessionFile(sessionId);
287
+ if (!file || !fs.existsSync(file)) return out;
288
+
289
+ try {
290
+ const raw = fs.readFileSync(file, 'utf8');
291
+ const lines = raw.split('\n').filter(Boolean).slice(-800);
292
+ let current = null;
293
+ for (const line of lines) {
294
+ let entry;
295
+ try { entry = JSON.parse(line); } catch { continue; }
296
+ if (!out.sessionStartTime && entry.timestamp) out.sessionStartTime = entry.timestamp;
297
+
298
+ if (entry.type === 'user' && entry.message) {
299
+ if (current) out.recentMessages.push(current);
300
+ current = {
301
+ text: extractUserText(entry.message.content),
302
+ tool_calls: 0,
303
+ };
304
+ } else if (entry.type === 'assistant' && current && entry.message && Array.isArray(entry.message.content)) {
305
+ for (const item of entry.message.content) {
306
+ if (item && item.type === 'tool_use') current.tool_calls++;
307
+ }
308
+ }
309
+ }
310
+ if (current) out.recentMessages.push(current);
311
+ if (out.recentMessages.length > limit) {
312
+ out.recentMessages = out.recentMessages.slice(-limit);
313
+ }
314
+ } catch {
315
+ return out;
316
+ }
317
+ return out;
318
+ }
319
+
320
+ function countCodeLines(output) {
321
+ const text = String(output || '');
322
+ if (!text.trim()) return 0;
323
+ const lines = text.split('\n');
324
+ let inFence = false;
325
+ let count = 0;
326
+ let sawFence = false;
327
+ for (const line of lines) {
328
+ if (/^\s*```/.test(line)) {
329
+ sawFence = true;
330
+ inFence = !inFence;
331
+ continue;
332
+ }
333
+ if (inFence && line.trim()) count++;
334
+ }
335
+ if (!sawFence) return 0;
336
+ return count;
337
+ }
338
+
195
339
  function isMacAutomationIntent(prompt) {
196
340
  const text = String(prompt || '').trim();
197
341
  if (!text) return false;
@@ -243,21 +387,24 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
243
387
  }
244
388
 
245
389
  /**
246
- * Spawn claude as async child process (non-blocking).
390
+ * Spawn Claude as async child process (non-blocking).
391
+ * Intentionally Claude-only: used by naming/fallback helper paths that
392
+ * should not depend on project runtime adapter selection.
247
393
  * Returns { output, error } after process exits.
248
394
  */
249
395
  function spawnClaudeAsync(args, input, cwd, timeoutMs = 300000, metameProject = '') {
250
396
  return new Promise((resolve) => {
397
+ const env = {
398
+ ...process.env,
399
+ ...getActiveProviderEnv(),
400
+ METAME_INTERNAL_PROMPT: '1',
401
+ METAME_PROJECT: metameProject || '',
402
+ };
403
+ delete env.CLAUDECODE;
251
404
  const child = spawn(CLAUDE_BIN, args, {
252
405
  cwd,
253
406
  stdio: ['pipe', 'pipe', 'pipe'],
254
- env: {
255
- ...process.env,
256
- ...getActiveProviderEnv(),
257
- CLAUDECODE: undefined,
258
- METAME_INTERNAL_PROMPT: '1',
259
- METAME_PROJECT: metameProject || ''
260
- },
407
+ env,
261
408
  });
262
409
 
263
410
  let stdout = '';
@@ -288,7 +435,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
288
435
 
289
436
  child.on('error', (err) => {
290
437
  clearTimeout(timer);
291
- resolve({ output: null, error: err.message });
438
+ resolve({ output: null, error: formatEngineSpawnError(err, { name: getDefaultEngine() }) });
292
439
  });
293
440
 
294
441
  // Write input and close stdin
@@ -317,55 +464,80 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
317
464
  };
318
465
 
319
466
  /**
320
- * Spawn claude with streaming output (stream-json mode).
321
- * Calls onStatus callback when tool usage is detected.
322
- * Returns { output, error } after process exits.
467
+ * Spawn engine with streaming output. Parser comes from runtime adapter.
468
+ * Returns { output, error, files, toolUsageLog, usage, sessionId }.
323
469
  */
324
- function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, chatId = null, metameProject = '') {
470
+ function spawnClaudeStreaming(
471
+ args,
472
+ input,
473
+ cwd,
474
+ onStatus,
475
+ timeoutMs = 600000,
476
+ chatId = null,
477
+ metameProject = '',
478
+ runtime = null,
479
+ onSession = null,
480
+ ) {
325
481
  return new Promise((resolve) => {
326
- // Add stream-json output format (requires --verbose)
327
- const streamArgs = [...args, '--output-format', 'stream-json', '--verbose'];
328
-
329
- const child = spawn(CLAUDE_BIN, streamArgs, {
482
+ let settled = false;
483
+ const finalize = (payload) => {
484
+ if (settled) return;
485
+ settled = true;
486
+ resolve(payload);
487
+ };
488
+ const rt = runtime || getEngineRuntime(getDefaultEngine());
489
+ const streamArgs = rt.name === 'claude'
490
+ ? [...args, '--output-format', 'stream-json', '--verbose']
491
+ : args;
492
+ const _spawnAt = Date.now();
493
+ const child = spawn(rt.binary, streamArgs, {
330
494
  cwd,
331
495
  stdio: ['pipe', 'pipe', 'pipe'],
332
- detached: process.platform !== 'win32', // process groups are POSIX-only
333
- env: {
334
- ...process.env,
335
- ...getActiveProviderEnv(),
336
- CLAUDECODE: undefined,
337
- METAME_PROJECT: metameProject || ''
338
- },
496
+ detached: process.platform !== 'win32',
497
+ env: rt.buildEnv({ metameProject }),
339
498
  });
499
+ log('INFO', `[TIMING:${chatId}] spawned ${rt.name} pid=${child.pid}`);
340
500
 
341
- // Track active process for /stop
342
501
  if (chatId) {
343
- activeProcesses.set(chatId, { child, aborted: false, startedAt: Date.now() });
344
- saveActivePids(); // Fix3: persist PID to disk
502
+ activeProcesses.set(chatId, {
503
+ child,
504
+ aborted: false,
505
+ startedAt: _spawnAt,
506
+ engine: rt.name,
507
+ killSignal: rt.killSignal || 'SIGTERM',
508
+ });
509
+ saveActivePids();
345
510
  }
346
511
 
347
512
  let buffer = '';
348
513
  let stderr = '';
349
514
  let killed = false;
350
- let killedReason = 'idle'; // 'idle' | 'ceiling'
515
+ let killedReason = 'idle';
351
516
  let finalResult = '';
517
+ let finalUsage = null;
518
+ let observedSessionId = '';
519
+ let _firstOutputLogged = false;
520
+ let classifiedError = null;
352
521
  let lastStatusTime = 0;
353
522
  const STATUS_THROTTLE = statusThrottleMs;
354
- const writtenFiles = []; // Track files created/modified by Write tool
355
- const toolUsageLog = []; // Track all tool invocations for skill evolution
523
+ const writtenFiles = [];
524
+ const toolUsageLog = [];
356
525
 
357
- // ── 自适应超时:5min 无输出判卡死 + 1h 绝对上限 ──
358
- const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
359
- const HARD_CEILING_MS = 60 * 60 * 1000;
526
+ const engineTimeouts = rt.timeouts || {};
527
+ const IDLE_TIMEOUT_MS = engineTimeouts.idleMs || (5 * 60 * 1000);
528
+ const TOOL_EXEC_TIMEOUT_MS = engineTimeouts.toolMs || (25 * 60 * 1000);
529
+ const HARD_CEILING_MS = engineTimeouts.ceilingMs || (60 * 60 * 1000);
360
530
  const startTime = Date.now();
531
+ let waitingForTool = false;
361
532
 
362
533
  let sigkillTimer = null;
363
534
  function killChild(reason) {
364
535
  if (killed) return;
365
536
  killed = true;
366
537
  killedReason = reason;
367
- log('WARN', `Claude ${reason} timeout for chatId ${chatId} — killing process group`);
368
- try { process.kill(-child.pid, 'SIGTERM'); } catch { child.kill('SIGTERM'); }
538
+ log('WARN', `[${rt.name}] ${reason} timeout for chatId ${chatId} — killing process group`);
539
+ const sig = rt.killSignal || 'SIGTERM';
540
+ try { process.kill(-child.pid, sig); } catch { child.kill(sig); }
369
541
  sigkillTimer = setTimeout(() => {
370
542
  try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { } }
371
543
  }, 5000);
@@ -376,10 +548,10 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
376
548
 
377
549
  function resetIdleTimer() {
378
550
  clearTimeout(idleTimer);
379
- idleTimer = setTimeout(() => killChild('idle'), IDLE_TIMEOUT_MS);
551
+ const timeout = waitingForTool ? TOOL_EXEC_TIMEOUT_MS : IDLE_TIMEOUT_MS;
552
+ idleTimer = setTimeout(() => killChild('idle'), timeout);
380
553
  }
381
554
 
382
- // ── 进度里程碑:2min 首报,之后每 5min 一次 ──
383
555
  let toolCallCount = 0;
384
556
  let lastMilestoneMin = 0;
385
557
  const milestoneTimer = setInterval(() => {
@@ -400,161 +572,182 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
400
572
  }
401
573
  }, 30000);
402
574
 
575
+ function parseEventsFromLine(line) {
576
+ try {
577
+ return rt.parseStreamEvent(line) || [];
578
+ } catch {
579
+ return [];
580
+ }
581
+ }
582
+
403
583
  child.stdout.on('data', (data) => {
404
584
  resetIdleTimer();
405
585
  buffer += data.toString();
406
-
407
- // Process complete JSON lines
408
586
  const lines = buffer.split('\n');
409
- buffer = lines.pop() || ''; // Keep incomplete line in buffer
587
+ buffer = lines.pop() || '';
410
588
 
411
589
  for (const line of lines) {
412
590
  if (!line.trim()) continue;
413
- try {
414
- const event = JSON.parse(line);
415
-
416
- // Extract final result text
417
- if (event.type === 'assistant' && event.message?.content) {
418
- const textBlocks = event.message.content.filter(b => b.type === 'text');
419
- if (textBlocks.length > 0) {
420
- finalResult = textBlocks.map(b => b.text).join('\n');
591
+ if (!_firstOutputLogged) {
592
+ _firstOutputLogged = true;
593
+ log('INFO', `[TIMING:${chatId}] first-line +${Date.now() - _spawnAt}ms`);
594
+ }
595
+ const events = parseEventsFromLine(line);
596
+ for (const event of events) {
597
+ if (!event || !event.type) continue;
598
+ if (event.type === 'session' && event.sessionId) {
599
+ observedSessionId = String(event.sessionId);
600
+ if (typeof onSession === 'function') {
601
+ Promise.resolve(onSession(observedSessionId)).catch(() => { });
421
602
  }
603
+ continue;
422
604
  }
423
-
424
- // Detect tool usage and send status
425
- if (event.type === 'assistant' && event.message?.content) {
426
- for (const block of event.message.content) {
427
- if (block.type === 'tool_use') {
428
- toolCallCount++;
429
- const toolName = block.name || 'Tool';
430
-
431
- // Track tool usage for skill evolution
432
- const toolEntry = { tool: toolName };
433
- if (toolName === 'Skill' && block.input?.skill) toolEntry.skill = block.input.skill;
434
- else if (block.input?.command) toolEntry.context = block.input.command.slice(0, 50);
435
- else if (block.input?.file_path) toolEntry.context = path.basename(block.input.file_path);
436
- if (toolUsageLog.length < 50) toolUsageLog.push(toolEntry);
437
-
438
- // Track files written by Write tool
439
- if (toolName === 'Write' && block.input?.file_path) {
440
- const filePath = block.input.file_path;
441
- if (!writtenFiles.includes(filePath)) {
442
- writtenFiles.push(filePath);
443
- }
444
- }
445
-
446
- const now = Date.now();
447
- if (now - lastStatusTime >= STATUS_THROTTLE) {
448
- lastStatusTime = now;
449
- const emoji = TOOL_EMOJI[toolName] || TOOL_EMOJI.default;
450
-
451
- // Resolve display name and context for MCP/Skill/Task tools
452
- let displayName = toolName;
453
- let displayEmoji = emoji;
454
- let context = '';
455
-
456
- if (toolName === 'Skill' && block.input?.skill) {
457
- // Skill invocation: show skill name
458
- context = block.input.skill;
459
- } else if (toolName === 'Task' && block.input?.description) {
460
- // Agent task: show description
461
- context = block.input.description.slice(0, 30);
462
- } else if (toolName.startsWith('mcp__')) {
463
- // MCP tool: mcp__server__action → "MCP server: action"
464
- const parts = toolName.split('__');
465
- const server = parts[1] || 'unknown';
466
- const action = parts.slice(2).join('_') || '';
467
- if (server === 'playwright') {
468
- displayEmoji = '🌐';
469
- displayName = 'Browser';
470
- context = action.replace(/_/g, ' ');
471
- } else {
472
- displayEmoji = '🔗';
473
- displayName = `MCP:${server}`;
474
- context = action.replace(/_/g, ' ').slice(0, 25);
475
- }
476
- } else if (block.input) {
477
- // Standard tools: extract brief context
478
- if (block.input.file_path) {
479
- // Insert zero-width space before extension to prevent link parsing
480
- const basename = path.basename(block.input.file_path);
481
- const dotIdx = basename.lastIndexOf('.');
482
- context = dotIdx > 0 ? basename.slice(0, dotIdx) + '\u200B' + basename.slice(dotIdx) : basename;
483
- } else if (block.input.command) {
484
- context = block.input.command.slice(0, 30);
485
- if (block.input.command.length > 30) context += '...';
486
- } else if (block.input.pattern) {
487
- context = block.input.pattern.slice(0, 20);
488
- } else if (block.input.query) {
489
- context = block.input.query.slice(0, 25);
490
- } else if (block.input.url) {
491
- try {
492
- context = new URL(block.input.url).hostname;
493
- } catch { context = 'web'; }
494
- }
495
- }
496
-
497
- const status = context
498
- ? `${displayEmoji} ${displayName}: 「${context}」`
499
- : `${displayEmoji} ${displayName}...`;
500
-
501
- if (onStatus) {
502
- onStatus(status).catch(() => { });
503
- }
504
- }
505
- }
605
+ if (event.type === 'error') {
606
+ classifiedError = event;
607
+ continue;
608
+ }
609
+ if (event.type === 'text' && event.text) {
610
+ finalResult = String(event.text);
611
+ if (waitingForTool) {
612
+ waitingForTool = false;
613
+ resetIdleTimer();
614
+ }
615
+ continue;
616
+ }
617
+ if (event.type === 'done') {
618
+ finalUsage = event.usage || null;
619
+ if (waitingForTool) {
620
+ waitingForTool = false;
621
+ resetIdleTimer();
622
+ }
623
+ continue;
624
+ }
625
+ if (event.type === 'tool_result') {
626
+ if (waitingForTool) {
627
+ waitingForTool = false;
628
+ resetIdleTimer();
506
629
  }
630
+ continue;
631
+ }
632
+ if (event.type !== 'tool_use') continue;
633
+
634
+ toolCallCount++;
635
+ waitingForTool = true;
636
+ resetIdleTimer();
637
+ const toolName = event.toolName || 'Tool';
638
+ const toolInput = event.toolInput || {};
639
+
640
+ const toolEntry = { tool: toolName };
641
+ if (toolName === 'Skill' && toolInput.skill) toolEntry.skill = toolInput.skill;
642
+ else if (toolInput.command) toolEntry.context = String(toolInput.command).slice(0, 50);
643
+ else if (toolInput.file_path) toolEntry.context = path.basename(String(toolInput.file_path));
644
+ if (toolUsageLog.length < 50) toolUsageLog.push(toolEntry);
645
+
646
+ if (toolName === 'Write' && toolInput.file_path) {
647
+ const filePath = String(toolInput.file_path);
648
+ if (!writtenFiles.includes(filePath)) writtenFiles.push(filePath);
507
649
  }
508
650
 
509
- // Also check for result message type
510
- if (event.type === 'result' && event.result) {
511
- finalResult = event.result;
651
+ const now = Date.now();
652
+ if (now - lastStatusTime < STATUS_THROTTLE) continue;
653
+ lastStatusTime = now;
654
+
655
+ const emoji = TOOL_EMOJI[toolName] || TOOL_EMOJI.default;
656
+ let displayName = toolName;
657
+ let displayEmoji = emoji;
658
+ let context = '';
659
+
660
+ if (toolName === 'Skill' && toolInput.skill) {
661
+ context = toolInput.skill;
662
+ } else if (toolName === 'Task' && toolInput.description) {
663
+ context = String(toolInput.description).slice(0, 30);
664
+ } else if (toolName.startsWith('mcp__')) {
665
+ const parts = toolName.split('__');
666
+ const server = parts[1] || 'unknown';
667
+ const action = parts.slice(2).join('_') || '';
668
+ if (server === 'playwright') {
669
+ displayEmoji = '🌐';
670
+ displayName = 'Browser';
671
+ context = action.replace(/_/g, ' ');
672
+ } else {
673
+ displayEmoji = '🔗';
674
+ displayName = `MCP:${server}`;
675
+ context = action.replace(/_/g, ' ').slice(0, 25);
676
+ }
677
+ } else if (toolInput.file_path) {
678
+ const basename = path.basename(String(toolInput.file_path));
679
+ const dotIdx = basename.lastIndexOf('.');
680
+ context = dotIdx > 0 ? basename.slice(0, dotIdx) + '\u200B' + basename.slice(dotIdx) : basename;
681
+ } else if (toolInput.command) {
682
+ context = String(toolInput.command).slice(0, 30);
683
+ if (String(toolInput.command).length > 30) context += '...';
684
+ } else if (toolInput.pattern) {
685
+ context = String(toolInput.pattern).slice(0, 20);
686
+ } else if (toolInput.query) {
687
+ context = String(toolInput.query).slice(0, 25);
688
+ } else if (toolInput.url) {
689
+ try { context = new URL(toolInput.url).hostname; } catch { context = 'web'; }
512
690
  }
513
- } catch {
514
- // Not valid JSON, ignore
691
+
692
+ const status = context
693
+ ? `${displayEmoji} ${displayName}: 「${context}」`
694
+ : `${displayEmoji} ${displayName}...`;
695
+ if (onStatus) onStatus(status).catch(() => { });
515
696
  }
516
697
  }
517
698
  });
518
699
 
519
700
  child.stderr.on('data', (data) => {
520
701
  resetIdleTimer();
521
- stderr += data.toString();
702
+ const chunk = data.toString();
703
+ stderr += chunk;
704
+ if (!classifiedError && typeof rt.classifyError === 'function') {
705
+ classifiedError = rt.classifyError(chunk);
706
+ }
522
707
  });
523
708
 
524
709
  child.on('close', (code) => {
710
+ log('INFO', `[TIMING:${chatId}] process-close code=${code} total=${Date.now() - _spawnAt}ms`);
525
711
  clearTimeout(idleTimer);
526
712
  clearTimeout(ceilingTimer);
527
713
  clearTimeout(sigkillTimer);
528
714
  clearInterval(milestoneTimer);
529
715
 
530
- // Process any remaining buffer
531
716
  if (buffer.trim()) {
532
- try {
533
- const event = JSON.parse(buffer);
534
- if (event.type === 'result' && event.result) {
535
- finalResult = event.result;
536
- }
537
- } catch { /* ignore */ }
717
+ const events = parseEventsFromLine(buffer.trim());
718
+ for (const event of events) {
719
+ if (event.type === 'text' && event.text) finalResult = String(event.text);
720
+ if (event.type === 'done') finalUsage = event.usage || null;
721
+ if (event.type === 'session' && event.sessionId) observedSessionId = String(event.sessionId);
722
+ if (event.type === 'error') classifiedError = event;
723
+ }
538
724
  }
539
725
 
540
- // Clean up active process tracking
541
726
  const proc = chatId ? activeProcesses.get(chatId) : null;
542
727
  const wasAborted = proc && proc.aborted;
543
- if (chatId) { activeProcesses.delete(chatId); saveActivePids(); } // Fix3
728
+ if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
544
729
 
545
730
  if (wasAborted) {
546
- resolve({ output: finalResult || null, error: 'Stopped by user', files: writtenFiles, toolUsageLog });
547
- } else if (killed) {
731
+ finalize({ output: finalResult || null, error: 'Stopped by user', files: writtenFiles, toolUsageLog, usage: finalUsage, sessionId: observedSessionId || '' });
732
+ return;
733
+ }
734
+ if (killed) {
548
735
  const elapsed = Math.round((Date.now() - startTime) / 60000);
736
+ const idleMin = Math.max(1, Math.round(IDLE_TIMEOUT_MS / 60000));
549
737
  const reason = killedReason === 'ceiling'
550
- ? `⏱ 已运行 ${elapsed} 分钟,达到上限(1 小时)`
551
- : `⏱ 已 5 分钟无输出,判定卡死(共运行 ${elapsed} 分钟)`;
552
- resolve({ output: finalResult || null, error: reason, timedOut: true, files: writtenFiles, toolUsageLog });
553
- } else if (code !== 0) {
554
- resolve({ output: finalResult || null, error: stderr || `Exit code ${code}`, files: writtenFiles, toolUsageLog });
555
- } else {
556
- resolve({ output: finalResult || '', error: null, files: writtenFiles, toolUsageLog });
738
+ ? `⏱ 已运行 ${elapsed} 分钟,达到上限(${Math.round(HARD_CEILING_MS / 60000)} 分钟)`
739
+ : `⏱ 已 ${idleMin} 分钟无输出,判定卡死(共运行 ${elapsed} 分钟)`;
740
+ finalize({ output: finalResult || null, error: reason, timedOut: true, files: writtenFiles, toolUsageLog, usage: finalUsage, sessionId: observedSessionId || '' });
741
+ return;
742
+ }
743
+ if (code !== 0) {
744
+ const engineErr = classifiedError && classifiedError.message
745
+ ? classifiedError.message
746
+ : (stderr || `Exit code ${code}`);
747
+ finalize({ output: finalResult || null, error: engineErr, errorCode: classifiedError ? classifiedError.code : undefined, files: writtenFiles, toolUsageLog, usage: finalUsage, sessionId: observedSessionId || '' });
748
+ return;
557
749
  }
750
+ finalize({ output: finalResult || '', error: null, files: writtenFiles, toolUsageLog, usage: finalUsage, sessionId: observedSessionId || '' });
558
751
  });
559
752
 
560
753
  child.on('error', (err) => {
@@ -562,13 +755,28 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
562
755
  clearTimeout(ceilingTimer);
563
756
  clearTimeout(sigkillTimer);
564
757
  clearInterval(milestoneTimer);
565
- if (chatId) { activeProcesses.delete(chatId); saveActivePids(); } // Fix3
566
- resolve({ output: null, error: err.message, files: [], toolUsageLog: [] });
758
+ if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
759
+ finalize({ output: null, error: formatEngineSpawnError(err, rt), files: [], toolUsageLog: [], usage: null, sessionId: '' });
567
760
  });
568
761
 
569
- // Write input and close stdin
570
- child.stdin.write(input);
571
- child.stdin.end();
762
+ try {
763
+ child.stdin.write(input);
764
+ child.stdin.end();
765
+ } catch (e) {
766
+ clearTimeout(idleTimer);
767
+ clearTimeout(ceilingTimer);
768
+ clearTimeout(sigkillTimer);
769
+ clearInterval(milestoneTimer);
770
+ if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
771
+ try { child.stdin.destroy(); } catch { /* ignore */ }
772
+ try {
773
+ const sig = rt.killSignal || 'SIGTERM';
774
+ process.kill(-child.pid, sig);
775
+ } catch {
776
+ try { child.kill(rt.killSignal || 'SIGTERM'); } catch { /* ignore */ }
777
+ }
778
+ finalize({ output: null, error: e.message, files: [], toolUsageLog: [], usage: null, sessionId: '' });
779
+ }
572
780
  });
573
781
  }
574
782
 
@@ -578,7 +786,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
578
786
  if (!messageId || !session || !session.id) return;
579
787
  const st = loadState();
580
788
  if (!st.msg_sessions) st.msg_sessions = {};
581
- st.msg_sessions[messageId] = { id: session.id, cwd: session.cwd };
789
+ st.msg_sessions[messageId] = { id: session.id, cwd: session.cwd, engine: session.engine || getDefaultEngine() };
582
790
  const keys = Object.keys(st.msg_sessions);
583
791
  if (keys.length > 200) {
584
792
  for (const k of keys.slice(0, keys.length - 200)) delete st.msg_sessions[k];
@@ -608,6 +816,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
608
816
  }
609
817
 
610
818
  async function askClaude(bot, chatId, prompt, config, readOnly = false) {
819
+ const _t0 = Date.now();
611
820
  log('INFO', `askClaude for ${chatId}: ${prompt.slice(0, 50)}`);
612
821
  // Track interaction time for idle/sleep detection
613
822
  if (touchInteraction) touchInteraction();
@@ -619,19 +828,24 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
619
828
  saveState(_st);
620
829
  }
621
830
  } catch { /* non-critical */ }
622
- // Send a single status message, updated in-place, deleted on completion
831
+ // Send 🤔 ack and start typing — fire-and-forget so we don't block spawn on Telegram RTT.
832
+ // statusMsgId is resolved via a promise; it will be ready well before the first model output.
623
833
  let statusMsgId = null;
624
- try {
625
- const msg = await (bot.sendMarkdown ? bot.sendMarkdown(chatId, '🤔') : bot.sendMessage(chatId, '🤔'));
626
- if (msg && msg.message_id) statusMsgId = msg.message_id;
627
- } catch (e) {
628
- log('ERROR', `Failed to send ack to ${chatId}: ${e.message}`);
629
- }
630
- await bot.sendTyping(chatId).catch(() => { });
834
+ // Fire-and-forget: don't await Telegram RTT before spawning the engine process.
835
+ // statusMsgId will be populated well before the first model output (~5s for codex).
836
+ (bot.sendMarkdown ? bot.sendMarkdown(chatId, '🤔') : bot.sendMessage(chatId, '🤔'))
837
+ .then(msg => { if (msg && msg.message_id) statusMsgId = msg.message_id; })
838
+ .catch(e => log('ERROR', `Failed to send ack to ${chatId}: ${e.message}`));
839
+ bot.sendTyping(chatId).catch(() => { });
631
840
  const typingTimer = setInterval(() => {
632
841
  bot.sendTyping(chatId).catch(() => { });
633
842
  }, 4000);
634
843
 
844
+ // Top-level safety net: any uncaught error inside askClaude MUST clean up timers and notify user.
845
+ // Without this, a ReferenceError / TypeError in the routing or injection code would silently
846
+ // kill the handler, leaving the typing indicator spinning forever.
847
+ try { // ── safety-net-start ──
848
+
635
849
  // Agent nickname routing: "贾维斯" / "小美,帮我..." → switch project session
636
850
  // Strict chats (chat_agent_map bound groups) must NOT switch agents via nickname
637
851
  const _strictAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
@@ -640,7 +854,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
640
854
  if (agentMatch) {
641
855
  const { key, proj, rest } = agentMatch;
642
856
  const projCwd = normalizeCwd(proj.cwd);
643
- attachOrCreateSession(chatId, projCwd, proj.name || key);
857
+ attachOrCreateSession(chatId, projCwd, proj.name || key, proj.engine ? normalizeEngineName(proj.engine) : getDefaultEngine());
644
858
  log('INFO', `Agent switch via nickname: ${key} (${projCwd})`);
645
859
  if (!rest) {
646
860
  // Pure nickname call — confirm switch and stop
@@ -655,100 +869,140 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
655
869
  // Skill routing: detect skill first, then decide session
656
870
  // BUT: skip skill routing if agent addressed by nickname OR chat already has an active session
657
871
  // (active conversation should never be hijacked by keyword-based skill matching)
658
- let session = getSession(chatId);
659
- const hasActiveSession = session && session.started;
660
- const skill = (agentMatch || hasActiveSession) ? null : routeSkill(prompt);
872
+ const sessionRaw = getSession(chatId);
661
873
  const chatIdStr = String(chatId);
662
874
  const chatAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
663
875
  const boundProjectKey = chatAgentMap[chatIdStr] || projectKeyFromVirtualChatId(chatIdStr);
664
876
  const boundProject = boundProjectKey && config.projects ? config.projects[boundProjectKey] : null;
665
877
  const boundCwd = (boundProject && boundProject.cwd) ? normalizeCwd(boundProject.cwd) : null;
878
+ const boundEngineName = (boundProject && boundProject.engine) ? normalizeEngineName(boundProject.engine) : getDefaultEngine();
879
+
880
+ // Engine is determined from config only — bound agent config wins, then global default.
881
+ const engineName = normalizeEngineName(
882
+ (boundProject && boundProject.engine) || getDefaultEngine()
883
+ );
884
+ const runtime = getEngineRuntime(engineName);
885
+
886
+ // hasActiveSession: does the current engine have an ongoing conversation?
887
+ const hasActiveSession = sessionRaw && (
888
+ sessionRaw.engines ? !!(sessionRaw.engines[engineName]?.started) : !!sessionRaw.started
889
+ );
890
+ const skill = (agentMatch || hasActiveSession) ? null : routeSkill(prompt);
666
891
 
667
- if (!session) {
668
- if (boundCwd) {
669
- // Agent-bound chats must stay in their own workspace: never attach to another project's session.
670
- const recentInBound = listRecentSessions(1, boundCwd);
671
- if (recentInBound.length > 0 && recentInBound[0].sessionId) {
672
- const target = recentInBound[0];
673
- const state = loadState();
674
- state.sessions[chatId] = {
675
- id: target.sessionId,
676
- cwd: boundCwd,
677
- started: true,
678
- };
679
- saveState(state);
680
- session = state.sessions[chatId];
681
- log('INFO', `Auto-attached ${chatId} to bound-session: ${target.sessionId.slice(0, 8)} (${path.basename(boundCwd)})`);
682
- } else {
683
- session = createSession(chatId, boundCwd, boundProject && boundProject.name ? boundProject.name : '');
684
- log('INFO', `Created fresh session for bound workspace: ${path.basename(boundCwd)}`);
685
- }
686
- } else {
687
- // Non-bound chats keep legacy behavior: attach global recent, else create.
688
- const recent = listRecentSessions(1);
689
- if (recent.length > 0 && recent[0].sessionId && recent[0].projectPath) {
690
- const target = recent[0];
691
- const state = loadState();
692
- state.sessions[chatId] = {
693
- id: target.sessionId,
694
- cwd: target.projectPath,
695
- started: true,
696
- };
697
- saveState(state);
698
- session = state.sessions[chatId];
699
- log('INFO', `Auto-attached ${chatId} to recent session: ${target.sessionId.slice(0, 8)} (${path.basename(target.projectPath)})`);
700
- } else {
701
- session = createSession(chatId);
702
- }
892
+ if (!sessionRaw) {
893
+ // No saved state for this chatId: start a fresh session.
894
+ // Note: daemon_state.json persists across restarts, so this only happens on truly first use
895
+ // or after an explicit /new command.
896
+ createSession(chatId, boundCwd || undefined, boundProject && boundProject.name ? boundProject.name : '', boundEngineName);
897
+ }
898
+
899
+ // Resolve flat view for current engine (id + started are engine-specific; cwd is shared)
900
+ let session = getSessionForEngine(chatId, engineName) || { cwd: boundCwd || HOME, engine: engineName, id: null, started: false };
901
+ session.engine = engineName; // keep local copy for Codex resume detection below
902
+
903
+ // Pre-spawn session validation: unified for all engines.
904
+ // Claude checks JSONL file existence; Codex checks SQLite. Same interface, different backend.
905
+ if (session.started && session.id && session.id !== '__continue__' && session.cwd) {
906
+ const valid = isEngineSessionValid(engineName, session.id, session.cwd);
907
+ if (!valid) {
908
+ log('WARN', `${engineName} session ${session.id.slice(0, 8)} invalid for ${chatId}; starting fresh ${engineName} session`);
909
+ await bot.sendMessage(chatId, '⚠️ 上次 session 已失效,已自动开启新 session。').catch(() => {});
910
+ session = createSession(chatId, session.cwd, boundProject && boundProject.name ? boundProject.name : '', engineName);
703
911
  }
704
912
  }
705
913
 
706
- // Safety guard: prevent stale state from resuming another workspace's session.
707
- if (!usePinnedSkillSession && session && session.started && session.id && session.id !== '__continue__' && session.cwd) {
708
- const sessionCwd = normalizeCwd(session.cwd);
709
- const existsInCwd = isSessionInCwd(session.id, sessionCwd);
710
- if (!existsInCwd) {
711
- log('WARN', `Session mismatch detected for ${chatId}: ${session.id.slice(0, 8)} not found in ${sessionCwd}; creating fresh session`);
712
- session = createSession(chatId, sessionCwd, boundProject && boundProject.name ? boundProject.name : '');
914
+ const daemonCfg = (config && config.daemon) || {};
915
+ const mentorCfg = (daemonCfg.mentor && typeof daemonCfg.mentor === 'object') ? daemonCfg.mentor : {};
916
+ const mentorEnabled = !!(mentorEngine && mentorCfg.enabled);
917
+ const excludeAgents = new Set(
918
+ (Array.isArray(mentorCfg.exclude_agents) ? mentorCfg.exclude_agents : [])
919
+ .map(x => String(x || '').trim())
920
+ .filter(Boolean)
921
+ );
922
+ const chatAgentKey = boundProjectKey || 'personal';
923
+ const mentorExcluded = excludeAgents.has(chatAgentKey);
924
+ let mentorSuppressed = false;
925
+
926
+ // Mentor pre-flight breaker: first hit sends a short reassurance; cooldown does not block normal answers.
927
+ if (mentorEnabled && !mentorExcluded) {
928
+ try {
929
+ const breaker = mentorEngine.checkEmotionBreaker(prompt, mentorCfg);
930
+ if (breaker && breaker.tripped) {
931
+ mentorSuppressed = true;
932
+ if (breaker.reason !== 'cooldown_active' && breaker.response) {
933
+ await bot.sendMessage(chatId, breaker.response).catch(() => { });
934
+ }
935
+ }
936
+ } catch (e) {
937
+ log('WARN', `Mentor breaker failed: ${e.message}`);
713
938
  }
714
939
  }
715
940
 
716
- // Build claude command
717
- const args = ['-p'];
718
- const daemonCfg = loadConfig().daemon || {};
719
- const model = daemonCfg.model || 'opus';
720
- args.push('--model', model);
721
- if (readOnly) {
722
- // Read-only mode for non-operator users: query/chat only, no write/edit/execute
723
- const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task'];
724
- for (const tool of READ_ONLY_TOOLS) args.push('--allowedTools', tool);
725
- } else if (daemonCfg.dangerously_skip_permissions) {
726
- args.push('--dangerously-skip-permissions');
727
- } else {
728
- const sessionAllowed = daemonCfg.session_allowed_tools || [];
729
- for (const tool of sessionAllowed) args.push('--allowedTools', tool);
941
+ // Build engine command — prefer per-engine model, fall back to legacy daemon.model
942
+ const engineModels = daemonCfg.models || {};
943
+ const engineModel = engineModels[runtime.name] || daemonCfg.model || runtime.defaultModel;
944
+ const model = (boundProject && boundProject.model) || engineModel;
945
+ const args = runtime.buildArgs({
946
+ model,
947
+ readOnly,
948
+ daemonCfg,
949
+ session,
950
+ cwd: session.cwd,
951
+ });
952
+
953
+ // Codex: write/refresh AGENTS.md = CLAUDE.md + SOUL.md on every new session.
954
+ // Written as a real file (not a symlink) for Windows compatibility.
955
+ // Refreshed each session so edits to CLAUDE.md or SOUL.md are always picked up.
956
+ // Codex auto-loads AGENTS.md from cwd and all parent dirs up to ~.
957
+ if (engineName === 'codex' && session.cwd && !session.started) {
958
+ try {
959
+ const parts = [];
960
+ const claudeMd = path.join(session.cwd, 'CLAUDE.md');
961
+ const soulMd = path.join(session.cwd, 'SOUL.md');
962
+ if (fs.existsSync(claudeMd)) parts.push(fs.readFileSync(claudeMd, 'utf8').trim());
963
+ if (fs.existsSync(soulMd)) {
964
+ const soulContent = fs.readFileSync(soulMd, 'utf8').trim();
965
+ if (soulContent) parts.push(soulContent);
966
+ }
967
+ if (parts.length > 0) {
968
+ fs.writeFileSync(path.join(session.cwd, 'AGENTS.md'), parts.join('\n\n'), 'utf8');
969
+ log('INFO', `Refreshed AGENTS.md (${parts.length} section(s)) in ${session.cwd}`);
970
+ }
971
+ } catch (e) {
972
+ log('WARN', `AGENTS.md refresh failed: ${e.message}`);
973
+ }
730
974
  }
731
- if (session.id === '__continue__') {
732
- args.push('--continue');
733
- } else if (session.started) {
734
- args.push('--resume', session.id);
735
- } else {
736
- args.push('--session-id', session.id);
975
+
976
+ let agentHint = '';
977
+ if (!session.started && (boundProject || (session && session.cwd))) {
978
+ try {
979
+ // Engine-aware: Codex gets memory only (soul is already in AGENTS.md);
980
+ // Claude gets soul + memory (SOUL.md is not auto-loaded by Claude).
981
+ agentHint = buildAgentContextForEngine(
982
+ boundProject || { cwd: session.cwd },
983
+ engineName,
984
+ HOME,
985
+ ).hint || '';
986
+ } catch (e) {
987
+ log('WARN', `Agent context injection failed: ${e.message}`);
988
+ }
737
989
  }
738
990
 
739
991
  // Memory & Knowledge Injection (RAG)
740
992
  let memoryHint = '';
993
+ // projectKey must be declared outside the try block so the daemonHint template below can reference it.
994
+ const _cid0 = String(chatId);
995
+ const _agentMap0 = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
996
+ const projectKey = _agentMap0[_cid0] || projectKeyFromVirtualChatId(_cid0);
741
997
  try {
742
998
  const memory = require('./memory');
743
- const _cid = String(chatId);
744
- const _cfg = loadConfig();
745
- const _agentMap = { ...(_cfg.telegram ? _cfg.telegram.chat_agent_map : {}), ...(_cfg.feishu ? _cfg.feishu.chat_agent_map : {}) };
746
- const projectKey = _agentMap[_cid] || projectKeyFromVirtualChatId(_cid);
747
999
 
748
- // L1: NOW.md shared whiteboard injection
1000
+ // L1: NOW.md per-agent whiteboard injection(按 projectKey 隔离,防并发冲突)
749
1001
  if (!session.started) {
750
1002
  try {
751
- const nowPath = path.join(HOME, '.metame', 'memory', 'NOW.md');
1003
+ const nowDir = path.join(HOME, '.metame', 'memory', 'now');
1004
+ const nowKey = projectKey || 'default';
1005
+ const nowPath = path.join(nowDir, `${nowKey}.md`);
752
1006
  if (fs.existsSync(nowPath)) {
753
1007
  const nowContent = fs.readFileSync(nowPath, 'utf8').trim();
754
1008
  if (nowContent) {
@@ -788,11 +1042,13 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
788
1042
 
789
1043
  // ZPD: build competence hint from brain profile
790
1044
  let zdpHint = '';
1045
+ let brainDoc = null;
791
1046
  if (!session.started) {
792
1047
  try {
793
1048
  const brainPath = path.join(HOME, '.claude_profile.yaml');
794
1049
  if (fs.existsSync(brainPath)) {
795
1050
  const brain = yaml.load(fs.readFileSync(brainPath, 'utf8'));
1051
+ brainDoc = brain;
796
1052
  const cmap = brain && brain.user_competence_map;
797
1053
  if (cmap && typeof cmap === 'object' && Object.keys(cmap).length > 0) {
798
1054
  const lines = Object.entries(cmap)
@@ -803,6 +1059,12 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
803
1059
  }
804
1060
  } catch { /* non-critical */ }
805
1061
  }
1062
+ if (!brainDoc) {
1063
+ try {
1064
+ const brainPath = path.join(HOME, '.claude_profile.yaml');
1065
+ if (fs.existsSync(brainPath)) brainDoc = yaml.load(fs.readFileSync(brainPath, 'utf8')) || {};
1066
+ } catch { /* ignore */ }
1067
+ }
806
1068
 
807
1069
  // Inject daemon hints only on first message of a session
808
1070
  // Task-specific rules (3-5) are injected only when isTaskIntent() returns true (~250 token saving for casual chat)
@@ -818,9 +1080,9 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
818
1080
  Valid relations: tech_decision, bug_lesson, arch_convention, config_fact, config_change, workflow_rule, project_milestone
819
1081
  Only write verified facts. Do not write speculative or process-description entries.
820
1082
  When you observe the user is clearly expert or beginner in a domain, note it in your response and suggest: "要不要把你的 {domain} 水平 ({level}) 记录到能力雷达?"
821
- 5. Task handoff: When suspending a multi-step task or handing off to another agent, write current status to ~/.metame/memory/NOW.md using:
822
- \`printf '%s\\n' "## Current Task" "{task}" "" "## Progress" "{progress}" "" "## Next Step" "{next}" > ~/.metame/memory/NOW.md\`
823
- Keep it under 200 words. Clear it when the task is fully complete by running: \`> ~/.metame/memory/NOW.md\`` : '';
1083
+ 5. Task handoff: When suspending a multi-step task or handing off to another agent, write current status to ~/.metame/memory/now/${projectKey || 'default'}.md using:
1084
+ \`mkdir -p ~/.metame/memory/now && printf '%s\\n' "## Current Task" "{task}" "" "## Progress" "{progress}" "" "## Next Step" "{next}" > ~/.metame/memory/now/${projectKey || 'default'}.md\`
1085
+ Keep it under 200 words. Clear it when the task is fully complete by running: \`> ~/.metame/memory/now/${projectKey || 'default'}.md\`` : '';
824
1086
  daemonHint = `\n\n[System hints - DO NOT mention these to user:
825
1087
  1. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
826
1088
  2. File sending: User is on MOBILE. When they ask to see/download a file:
@@ -829,7 +1091,9 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
829
1091
  - Add at END of response: [[FILE:/absolute/path/to/file]]
830
1092
  - Keep response brief: "请查收~! [[FILE:/path/to/file]]"
831
1093
  - Multiple files: use multiple [[FILE:...]] tags${zdpHint ? '\n Explanation depth (ZPD):\n' + zdpHint : ''}${taskRules}]`;
832
- }
1094
+ }
1095
+
1096
+ daemonHint = adaptDaemonHintForEngine(daemonHint, runtime.name);
833
1097
 
834
1098
  const routedPrompt = skill ? `/${skill} ${prompt}` : prompt;
835
1099
 
@@ -865,13 +1129,71 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
865
1129
  } catch { /* non-critical */ }
866
1130
  }
867
1131
 
868
- // Always append a compact language guard to prevent accidental Korean/Japanese responses
869
- const langGuard = '\n\n[Respond in Simplified Chinese (简体中文) only. NEVER switch to Korean, Japanese, or other languages regardless of tool output or context language.]';
870
- const fullPrompt = routedPrompt + daemonHint + macAutomationHint + summaryHint + memoryHint + langGuard;
1132
+ // Mentor context hook: inject after memoryHint, before langGuard.
1133
+ let mentorHint = '';
1134
+ if (mentorEnabled && !mentorExcluded && !mentorSuppressed) {
1135
+ try {
1136
+ const signals = collectRecentSessionSignals(session.id, 6);
1137
+ let skeleton = null;
1138
+ if (sessionAnalytics && typeof sessionAnalytics.extractSkeleton === 'function') {
1139
+ const file = findSessionFile(session.id);
1140
+ if (file && fs.existsSync(file)) {
1141
+ const st = fs.statSync(file);
1142
+ if (st.size <= 2 * 1024 * 1024) {
1143
+ skeleton = sessionAnalytics.extractSkeleton(file);
1144
+ }
1145
+ }
1146
+ }
1147
+ const zone = skeleton && mentorEngine.computeZone
1148
+ ? mentorEngine.computeZone(skeleton).zone
1149
+ : 'stretch';
1150
+ const sessionState = {
1151
+ zone,
1152
+ recentMessages: signals.recentMessages,
1153
+ cwd: session.cwd,
1154
+ skeleton,
1155
+ sessionStartTime: signals.sessionStartTime || new Date().toISOString(),
1156
+ topic: String(prompt || '').slice(0, 120),
1157
+ currentTopic: String(prompt || '').slice(0, 120),
1158
+ lastUserMessage: String(prompt || '').slice(0, 200),
1159
+ };
1160
+ const built = mentorEngine.buildMentorPrompt(sessionState, brainDoc || {}, mentorCfg);
1161
+ if (built && String(built).trim()) mentorHint = `\n\n${String(built).trim()}`;
1162
+
1163
+ // Collect reflection debt: if user returns to same project+topic, inject recall prompt.
1164
+ // Suppressed by quiet_until (user explicitly asked for silence), but NOT by expert skip
1165
+ // (even experts may not have reviewed AI-generated code).
1166
+ const quietUntil = brainDoc && brainDoc.growth ? brainDoc.growth.quiet_until : null;
1167
+ const quietMs = quietUntil ? new Date(quietUntil).getTime() : 0;
1168
+ const isQuiet = quietMs && quietMs > Date.now();
1169
+ if (!isQuiet && mentorEngine.collectDebt) {
1170
+ const info = deriveProjectInfo(session && session.cwd ? session.cwd : '');
1171
+ const projectId = info && info.project_id ? info.project_id : '';
1172
+ if (projectId) {
1173
+ const debt = mentorEngine.collectDebt(projectId, String(prompt || '').slice(0, 120));
1174
+ if (debt && debt.prompt) {
1175
+ mentorHint += `\n\n[Reflection debt] ${debt.prompt}`;
1176
+ }
1177
+ }
1178
+ }
1179
+ } catch (e) {
1180
+ log('WARN', `Mentor prompt build failed: ${e.message}`);
1181
+ }
1182
+ }
1183
+
1184
+ // Language guard: only inject on first message of a new session to avoid
1185
+ // linearly growing token cost on every turn in long conversations.
1186
+ // Claude Code preserves session context, so the guard persists after initial injection.
1187
+ const langGuard = session.started
1188
+ ? ''
1189
+ : '\n\n[Respond in Simplified Chinese (简体中文) only. NEVER switch to Korean, Japanese, or other languages regardless of tool output or context language.]';
1190
+ const fullPrompt = routedPrompt + daemonHint + agentHint + macAutomationHint + summaryHint + memoryHint + mentorHint + langGuard;
871
1191
 
872
- // Git checkpoint before Claude modifies files (for /undo)
873
- // Pass the user prompt as label so checkpoint list is human-readable
874
- gitCheckpoint(session.cwd, prompt);
1192
+ // Git checkpoint before Claude modifies files (for /undo).
1193
+ // Run async (fire-and-forget) to avoid blocking Claude spawn by ~600ms.
1194
+ // Completes well before Claude's first file write (~2s after spawn).
1195
+ (gitCheckpointAsync || gitCheckpoint)(session.cwd, prompt).catch?.(() => {});
1196
+ log('INFO', `[TIMING:${chatId}] pre-spawn +${Date.now() - _t0}ms (engine:${runtime.name} started:${session.started})`);
875
1197
 
876
1198
  // Use streaming mode to show progress
877
1199
  // Telegram: edit status msg in-place; Feishu: edit or fallback to new messages
@@ -893,9 +1215,103 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
893
1215
  } catch { /* ignore status update failures */ }
894
1216
  };
895
1217
 
896
- let output, error, files, toolUsageLog, timedOut;
1218
+ const wasCodexResumeAttempt = runtime.name === 'codex'
1219
+ && !!(session && session.started && session.id && session.id !== '__continue__');
1220
+ const onSession = async (nextSessionId) => {
1221
+ const safeNextId = String(nextSessionId || '').trim();
1222
+ if (!safeNextId) return;
1223
+ const prevSessionId = session && session.id ? String(session.id) : '';
1224
+ const wasStarted = !!(session && session.started);
1225
+ session = {
1226
+ ...session,
1227
+ id: safeNextId,
1228
+ engine: runtime.name,
1229
+ started: true,
1230
+ };
1231
+ await patchSessionSerialized(chatId, (cur) => {
1232
+ const engines = { ...(cur.engines || {}) };
1233
+ engines[runtime.name] = { ...(engines[runtime.name] || {}), id: safeNextId, started: true };
1234
+ return { ...cur, cwd: session.cwd || cur.cwd || HOME, engines };
1235
+ });
1236
+ if (runtime.name === 'codex' && wasStarted && prevSessionId && prevSessionId !== safeNextId && prevSessionId !== '__continue__') {
1237
+ log('WARN', `Codex thread migrated for ${chatId}: ${prevSessionId.slice(0, 8)} -> ${safeNextId.slice(0, 8)}`);
1238
+ }
1239
+ };
1240
+
1241
+ let output, error, errorCode, files, toolUsageLog, timedOut, usage, sessionId;
897
1242
  try {
898
- ({ output, error, timedOut, files, toolUsageLog } = await spawnClaudeStreaming(args, fullPrompt, session.cwd, onStatus, 600000, chatId, boundProjectKey || ''));
1243
+ ({
1244
+ output,
1245
+ error,
1246
+ errorCode,
1247
+ timedOut,
1248
+ files,
1249
+ toolUsageLog,
1250
+ usage,
1251
+ sessionId,
1252
+ } = await spawnClaudeStreaming(
1253
+ args,
1254
+ fullPrompt,
1255
+ session.cwd,
1256
+ onStatus,
1257
+ 600000,
1258
+ chatId,
1259
+ boundProjectKey || '',
1260
+ runtime,
1261
+ onSession,
1262
+ ));
1263
+
1264
+ if (sessionId) await onSession(sessionId);
1265
+
1266
+ if (shouldRetryCodexResumeFallback({
1267
+ runtimeName: runtime.name,
1268
+ wasResumeAttempt: wasCodexResumeAttempt,
1269
+ output,
1270
+ error,
1271
+ errorCode,
1272
+ canRetry: canRetryCodexResume(chatId),
1273
+ })) {
1274
+ markCodexResumeRetried(chatId);
1275
+ log('WARN', `Codex resume failed for ${chatId}, retrying once with fresh exec: ${String(error).slice(0, 120)}`);
1276
+ // Notify user explicitly — silent context loss is worse than a visible warning.
1277
+ await bot.sendMessage(chatId, '⚠️ Codex session 已过期,上下文丢失。正在以全新 session 重试,请在回复后补充必要背景。').catch(() => {});
1278
+ session = createSession(
1279
+ chatId,
1280
+ session.cwd,
1281
+ boundProject && boundProject.name ? boundProject.name : '',
1282
+ 'codex'
1283
+ );
1284
+ const retryArgs = runtime.buildArgs({
1285
+ model,
1286
+ readOnly,
1287
+ daemonCfg,
1288
+ session,
1289
+ cwd: session.cwd,
1290
+ });
1291
+ // Prepend a context-loss marker so Codex knows this is a fresh session mid-conversation.
1292
+ const retryPrompt = `[Note: previous Codex session expired and could not be resumed. Treating this as a new session. User message follows:]\n\n${fullPrompt}`;
1293
+ ({
1294
+ output,
1295
+ error,
1296
+ errorCode,
1297
+ timedOut,
1298
+ files,
1299
+ toolUsageLog,
1300
+ usage,
1301
+ sessionId,
1302
+ } = await spawnClaudeStreaming(
1303
+ retryArgs,
1304
+ retryPrompt,
1305
+ session.cwd,
1306
+ onStatus,
1307
+ 600000,
1308
+ chatId,
1309
+ boundProjectKey || '',
1310
+ runtime,
1311
+ onSession,
1312
+ ));
1313
+ if (sessionId) await onSession(sessionId);
1314
+ }
899
1315
  } catch (spawnErr) {
900
1316
  clearInterval(typingTimer);
901
1317
  if (statusMsgId && bot.deleteMessage) bot.deleteMessage(chatId, statusMsgId).catch(() => { });
@@ -921,6 +1337,24 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
921
1337
  bot.deleteMessage(chatId, statusMsgId).catch(() => { });
922
1338
  }
923
1339
 
1340
+ // Mentor post-flight debt registration (intense mode only).
1341
+ if (mentorEnabled && !mentorExcluded && !mentorSuppressed && mentorEngine && typeof mentorEngine.registerDebt === 'function' && output) {
1342
+ try {
1343
+ const mode = resolveMentorMode(mentorCfg);
1344
+ if (mode === 'intense') {
1345
+ const codeLines = countCodeLines(output);
1346
+ if (codeLines > 30) {
1347
+ const info = deriveProjectInfo(session && session.cwd ? session.cwd : '');
1348
+ const projectId = info && info.project_id ? info.project_id : 'proj_default';
1349
+ mentorEngine.registerDebt(projectId, String(prompt || '').slice(0, 120), codeLines);
1350
+ log('INFO', `[MENTOR] Registered reflection debt (${projectId}, lines=${codeLines})`);
1351
+ }
1352
+ }
1353
+ } catch (e) {
1354
+ log('WARN', `Mentor post-flight failed: ${e.message}`);
1355
+ }
1356
+ }
1357
+
924
1358
  // When Claude completes with no text output (pure tool work), send a done notice
925
1359
  if (output === '' && !error) {
926
1360
  // Special case: if dispatch_to was called, send a "forwarded" confirmation
@@ -934,36 +1368,39 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
934
1368
  const doneMsg = await bot.sendMessage(chatId, `✉️ 已转达给 ${names},处理中…`);
935
1369
  if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session);
936
1370
  const wasNew = !session.started;
937
- if (wasNew) markSessionStarted(chatId);
1371
+ if (wasNew) markSessionStarted(chatId, engineName);
938
1372
  return { ok: true };
939
1373
  }
940
1374
  const filesDesc = files && files.length > 0 ? `\n修改了 ${files.length} 个文件` : '';
941
1375
  const doneMsg = await bot.sendMessage(chatId, `✅ 完成${filesDesc}`);
942
1376
  if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session);
943
1377
  const wasNew = !session.started;
944
- if (wasNew) markSessionStarted(chatId);
1378
+ if (wasNew) markSessionStarted(chatId, engineName);
945
1379
  return { ok: true };
946
1380
  }
947
1381
 
948
1382
  if (output) {
1383
+ if (runtime.name === 'codex') _codexResumeRetryTs.delete(String(chatId));
949
1384
  // Detect provider/model errors disguised as output (e.g., "model not found", API errors)
950
- const activeProvCheck = providerMod ? providerMod.getActiveName() : 'anthropic';
951
- const builtinModelsCheck = ['sonnet', 'opus', 'haiku'];
952
- const looksLikeError = output.length < 300 && /\b(not found|invalid model|unauthorized|401|403|404|error|failed)\b/i.test(output);
953
- if (looksLikeError && (activeProvCheck !== 'anthropic' || !builtinModelsCheck.includes(model))) {
954
- try {
955
- config = fallbackToDefaultProvider(`output looks like error for ${activeProvCheck}/${model}`);
956
- await bot.sendMessage(chatId, `⚠️ ${activeProvCheck}/${model} 疑似失败,已回退到 anthropic/opus\n输出: ${output.slice(0, 150)}`);
957
- } catch (fbErr) {
958
- log('ERROR', `Fallback failed: ${fbErr.message}`);
959
- await bot.sendMarkdown(chatId, output);
1385
+ if (runtime.name === 'claude') {
1386
+ const activeProvCheck = providerMod ? providerMod.getActiveName() : 'anthropic';
1387
+ const builtinModelsCheck = ['sonnet', 'opus', 'haiku'];
1388
+ const looksLikeError = output.length < 300 && /\b(not found|invalid model|unauthorized|401|403|404|error|failed)\b/i.test(output);
1389
+ if (looksLikeError && (activeProvCheck !== 'anthropic' || !builtinModelsCheck.includes(model))) {
1390
+ try {
1391
+ config = fallbackToDefaultProvider(`output looks like error for ${activeProvCheck}/${model}`);
1392
+ await bot.sendMessage(chatId, `⚠️ ${activeProvCheck}/${model} 疑似失败,已回退到 anthropic/opus\n输出: ${output.slice(0, 150)}`);
1393
+ } catch (fbErr) {
1394
+ log('ERROR', `Fallback failed: ${fbErr.message}`);
1395
+ await bot.sendMarkdown(chatId, output);
1396
+ }
1397
+ return { ok: false, error: output };
960
1398
  }
961
- return { ok: false, error: output };
962
1399
  }
963
1400
 
964
1401
  // Mark session as started after first successful call
965
1402
  const wasNew = !session.started;
966
- if (wasNew) markSessionStarted(chatId);
1403
+ if (wasNew) markSessionStarted(chatId, engineName);
967
1404
 
968
1405
  const estimated = Math.ceil((prompt.length + output.length) / 4);
969
1406
  const chatCategory = classifyChatUsage(chatId, {
@@ -1019,57 +1456,102 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1019
1456
  }
1020
1457
 
1021
1458
  // Auto-name: if this was the first message and session has no name, generate one
1022
- if (wasNew && !getSessionName(session.id)) {
1459
+ if (runtime.name === 'claude' && wasNew && !getSessionName(session.id)) {
1023
1460
  autoNameSession(chatId, session.id, prompt, session.cwd).catch(() => { });
1024
1461
  }
1462
+
1463
+ // Auto-refresh memory-snapshot.md for this agent on first session message (fire-and-forget)
1464
+ if (wasNew && boundProject && boundProject.agent_id) {
1465
+ setImmediate(async () => {
1466
+ try {
1467
+ const memory = require('./memory');
1468
+ const pKey = boundProjectKey || '';
1469
+ const sessions = memory.recentSessions({ limit: 5, project: pKey });
1470
+ const factsRaw = memory.searchFacts('', { limit: 10, project: pKey });
1471
+ const facts = Array.isArray(factsRaw) ? factsRaw : [];
1472
+ memory.close();
1473
+ const snapshotContent = buildMemorySnapshotContent(sessions, facts);
1474
+ const agentId = boundProject.agent_id;
1475
+ if (refreshMemorySnapshot(agentId, snapshotContent, HOME)) {
1476
+ log('DEBUG', `[AGENT] Memory snapshot refreshed for ${agentId}`);
1477
+ }
1478
+ } catch { /* non-critical — memory module may not be available */ }
1479
+ });
1480
+ }
1025
1481
  return { ok: !timedOut };
1026
1482
  } else {
1027
1483
  const errMsg = error || 'Unknown error';
1028
- log('ERROR', `askClaude failed for ${chatId}: ${errMsg.slice(0, 300)}`);
1484
+ const userErrMsg = (errorCode === 'AUTH_REQUIRED' || errorCode === 'RATE_LIMIT')
1485
+ ? errMsg
1486
+ : `Error: ${errMsg.slice(0, 200)}`;
1487
+ log('ERROR', `ask${runtime.name === 'codex' ? 'Codex' : 'Claude'} failed for ${chatId}: ${errMsg.slice(0, 300)} (${errorCode || 'NO_CODE'})`);
1029
1488
 
1030
- // If session not found (expired/deleted), create new and retry once
1031
- if (errMsg.includes('not found') || errMsg.includes('No session') || errMsg.includes('already in use')) {
1489
+ // If session not found (expired/deleted), create new and retry once (Claude path)
1490
+ if (runtime.name === 'claude' && (errMsg.includes('not found') || errMsg.includes('No session') || errMsg.includes('already in use'))) {
1032
1491
  log('WARN', `Session ${session.id} unusable (${errMsg.includes('already in use') ? 'locked' : 'not found'}), creating new`);
1033
- session = createSession(chatId, session.cwd);
1034
-
1035
- const retryArgs = ['-p', '--session-id', session.id];
1036
- if (daemonCfg.dangerously_skip_permissions) {
1037
- retryArgs.push('--dangerously-skip-permissions');
1038
- } else {
1039
- const sessionAllowed = daemonCfg.session_allowed_tools || [];
1040
- for (const tool of sessionAllowed) retryArgs.push('--allowedTools', tool);
1041
- }
1042
-
1043
- const retry = await spawnClaudeStreaming(retryArgs, prompt, session.cwd, onStatus);
1492
+ session = createSession(chatId, session.cwd, '', runtime.name);
1493
+
1494
+ const retryArgs = runtime.buildArgs({
1495
+ model,
1496
+ readOnly,
1497
+ daemonCfg,
1498
+ session,
1499
+ cwd: session.cwd,
1500
+ });
1501
+
1502
+ const retry = await spawnClaudeStreaming(
1503
+ retryArgs,
1504
+ fullPrompt,
1505
+ session.cwd,
1506
+ onStatus,
1507
+ 600000,
1508
+ chatId,
1509
+ boundProjectKey || '',
1510
+ runtime,
1511
+ onSession,
1512
+ );
1513
+ if (retry.sessionId) await onSession(retry.sessionId);
1044
1514
  if (retry.output) {
1045
- markSessionStarted(chatId);
1515
+ markSessionStarted(chatId, runtime.name);
1046
1516
  const { markedFiles: retryMarked, cleanOutput: retryClean } = parseFileMarkers(retry.output);
1047
1517
  await bot.sendMarkdown(chatId, retryClean);
1048
1518
  await sendFileButtons(bot, chatId, mergeFileCollections(retryMarked, retry.files));
1049
1519
  return { ok: true };
1050
1520
  } else {
1051
1521
  log('ERROR', `askClaude retry failed: ${(retry.error || '').slice(0, 200)}`);
1052
- try { await bot.sendMessage(chatId, `Error: ${(retry.error || '').slice(0, 200)}`); } catch { /* */ }
1522
+ try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
1053
1523
  return { ok: false, error: retry.error || errMsg };
1054
1524
  }
1055
1525
  } else {
1056
- // Auto-fallback: if custom provider/model fails, revert to anthropic + opus
1057
- const activeProv = providerMod ? providerMod.getActiveName() : 'anthropic';
1058
- const builtinModels = ['sonnet', 'opus', 'haiku'];
1059
- if (activeProv !== 'anthropic' || !builtinModels.includes(model)) {
1060
- try {
1061
- config = fallbackToDefaultProvider(`${activeProv}/${model} error: ${errMsg.slice(0, 100)}`);
1062
- await bot.sendMessage(chatId, `⚠️ ${activeProv}/${model} 失败,已回退到 anthropic/opus\n原因: ${errMsg.slice(0, 100)}`);
1063
- } catch (fallbackErr) {
1064
- log('ERROR', `Fallback failed: ${fallbackErr.message}`);
1065
- try { await bot.sendMessage(chatId, `Error: ${errMsg.slice(0, 200)}`); } catch { /* */ }
1526
+ // Auto-fallback: if custom provider/model fails, revert to anthropic + opus (Claude path only)
1527
+ if (runtime.name === 'claude') {
1528
+ const activeProv = providerMod ? providerMod.getActiveName() : 'anthropic';
1529
+ const builtinModels = ENGINE_MODEL_CONFIG.claude.options;
1530
+ if (activeProv !== 'anthropic' || !builtinModels.includes(model)) {
1531
+ try {
1532
+ config = fallbackToDefaultProvider(`${activeProv}/${model} error: ${errMsg.slice(0, 100)}`);
1533
+ await bot.sendMessage(chatId, `⚠️ ${activeProv}/${model} 失败,已回退到 anthropic/opus\n原因: ${errMsg.slice(0, 100)}`);
1534
+ } catch (fallbackErr) {
1535
+ log('ERROR', `Fallback failed: ${fallbackErr.message}`);
1536
+ try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
1537
+ }
1538
+ } else {
1539
+ try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
1066
1540
  }
1067
1541
  } else {
1068
- try { await bot.sendMessage(chatId, `Error: ${errMsg.slice(0, 200)}`); } catch { /* */ }
1542
+ try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
1069
1543
  }
1070
- return { ok: false, error: errMsg };
1544
+ return { ok: false, error: errMsg, errorCode };
1071
1545
  }
1072
1546
  }
1547
+
1548
+ } catch (fatalErr) { // ── safety-net-catch ──
1549
+ clearInterval(typingTimer);
1550
+ if (statusMsgId && bot.deleteMessage) await bot.deleteMessage(chatId, statusMsgId).catch(() => { });
1551
+ log('FATAL', `[askClaude] Uncaught error for ${chatId}: ${fatalErr.message}\n${fatalErr.stack}`);
1552
+ try { await bot.sendMessage(chatId, `❌ 内部错误: ${fatalErr.message}`); } catch { /* */ }
1553
+ return { ok: false, error: fatalErr.message };
1554
+ }
1073
1555
  }
1074
1556
 
1075
1557
  return {
@@ -1079,6 +1561,15 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1079
1561
  spawnClaudeStreaming,
1080
1562
  trackMsgSession,
1081
1563
  askClaude,
1564
+ _private: {
1565
+ patchSessionSerialized,
1566
+ shouldRetryCodexResumeFallback,
1567
+ formatEngineSpawnError,
1568
+ adaptDaemonHintForEngine,
1569
+ canRetryCodexResume,
1570
+ markCodexResumeRetried,
1571
+ CODEX_RESUME_RETRY_WINDOW_MS,
1572
+ },
1082
1573
  };
1083
1574
  }
1084
1575