metame-cli 1.4.33 → 1.5.0

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 (44) hide show
  1. package/README.md +187 -48
  2. package/index.js +148 -9
  3. package/package.json +6 -3
  4. package/scripts/daemon-admin-commands.js +254 -9
  5. package/scripts/daemon-agent-commands.js +64 -6
  6. package/scripts/daemon-agent-tools.js +26 -5
  7. package/scripts/daemon-bridges.js +110 -20
  8. package/scripts/daemon-claude-engine.js +704 -268
  9. package/scripts/daemon-command-router.js +24 -8
  10. package/scripts/daemon-default.yaml +28 -4
  11. package/scripts/daemon-engine-runtime.js +275 -0
  12. package/scripts/daemon-exec-commands.js +10 -4
  13. package/scripts/daemon-notify.js +37 -1
  14. package/scripts/daemon-runtime-lifecycle.js +2 -1
  15. package/scripts/daemon-session-commands.js +52 -4
  16. package/scripts/daemon-session-store.js +2 -1
  17. package/scripts/daemon-task-scheduler.js +87 -28
  18. package/scripts/daemon-user-acl.js +26 -9
  19. package/scripts/daemon.js +81 -17
  20. package/scripts/distill.js +323 -18
  21. package/scripts/docs/agent-guide.md +12 -0
  22. package/scripts/docs/maintenance-manual.md +119 -0
  23. package/scripts/docs/pointer-map.md +88 -0
  24. package/scripts/feishu-adapter.js +6 -1
  25. package/scripts/hooks/stop-session-capture.js +243 -0
  26. package/scripts/memory-extract.js +100 -5
  27. package/scripts/memory-nightly-reflect.js +196 -11
  28. package/scripts/memory.js +134 -3
  29. package/scripts/mentor-engine.js +405 -0
  30. package/scripts/platform.js +2 -0
  31. package/scripts/providers.js +169 -21
  32. package/scripts/schema.js +12 -0
  33. package/scripts/session-analytics.js +245 -12
  34. package/scripts/skill-changelog.js +245 -0
  35. package/scripts/skill-evolution.js +288 -5
  36. package/scripts/usage-classifier.js +1 -1
  37. package/scripts/daemon-admin-commands.test.js +0 -333
  38. package/scripts/daemon-task-envelope.test.js +0 -59
  39. package/scripts/daemon-task-scheduler.test.js +0 -106
  40. package/scripts/reliability-core.test.js +0 -280
  41. package/scripts/skill-evolution.test.js +0 -113
  42. package/scripts/task-board.test.js +0 -83
  43. package/scripts/test_daemon.js +0 -1407
  44. package/scripts/utils.test.js +0 -192
@@ -1,6 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const { classifyChatUsage } = require('./usage-classifier');
4
+ const { deriveProjectInfo } = require('./utils');
5
+ const { createEngineRuntimeFactory, normalizeEngineName } = require('./daemon-engine-runtime');
4
6
 
5
7
  function createClaudeEngine(deps) {
6
8
  const {
@@ -40,16 +42,92 @@ function createClaudeEngine(deps) {
40
42
  touchInteraction,
41
43
  statusThrottleMs = 3000,
42
44
  fallbackThrottleMs = 8000,
45
+ getEngineRuntime: injectedGetEngineRuntime,
46
+ getDefaultEngine: _getDefaultEngine,
43
47
  } = deps;
48
+ function getDefaultEngine() {
49
+ return (typeof _getDefaultEngine === 'function') ? _getDefaultEngine() : 'claude';
50
+ }
51
+ let mentorEngine = null;
52
+ try { mentorEngine = require('./mentor-engine'); } catch { /* optional */ }
53
+ let sessionAnalytics = null;
54
+ try { sessionAnalytics = require('./session-analytics'); } catch { /* optional */ }
55
+
56
+ const getEngineRuntime = typeof injectedGetEngineRuntime === 'function'
57
+ ? injectedGetEngineRuntime
58
+ : createEngineRuntimeFactory({ fs, path, HOME, CLAUDE_BIN, getActiveProviderEnv });
44
59
 
45
60
  // On Windows, .cmd files need shell to spawn; use COMSPEC to avoid conda PATH issues
46
61
  function spawn(cmd, args, options) {
47
- if (process.platform === 'win32' && cmd === CLAUDE_BIN) {
62
+ const lowerCmd = String(cmd || '').toLowerCase();
63
+ if (process.platform === 'win32' && (cmd === CLAUDE_BIN || lowerCmd.endsWith('\\claude.cmd') || lowerCmd.endsWith('\\codex.cmd'))) {
48
64
  return _spawn(cmd, args, { ...options, shell: process.env.COMSPEC || true });
49
65
  }
50
66
  return _spawn(cmd, args, options);
51
67
  }
52
68
 
69
+ let _sessionPatchQueue = Promise.resolve();
70
+ function patchSessionSerialized(chatId, patchFn) {
71
+ _sessionPatchQueue = _sessionPatchQueue.then(() => {
72
+ const state = loadState();
73
+ if (!state.sessions) state.sessions = {};
74
+ const cur = state.sessions[chatId] || {};
75
+ const next = typeof patchFn === 'function' ? patchFn(cur) : cur;
76
+ state.sessions[chatId] = next && typeof next === 'object' ? next : cur;
77
+ saveState(state);
78
+ }).catch((e) => {
79
+ log('WARN', `patchSessionSerialized failed: ${e.message}`);
80
+ });
81
+ return _sessionPatchQueue;
82
+ }
83
+
84
+ const CODEX_RESUME_RETRY_WINDOW_MS = 10 * 60 * 1000;
85
+ const _codexResumeRetryTs = new Map(); // chatId -> last retry ts
86
+
87
+ function canRetryCodexResume(chatId) {
88
+ const key = String(chatId || '');
89
+ if (!key) return false;
90
+ const last = Number(_codexResumeRetryTs.get(key) || 0);
91
+ if (!last) return true;
92
+ return (Date.now() - last) > CODEX_RESUME_RETRY_WINDOW_MS;
93
+ }
94
+
95
+ function markCodexResumeRetried(chatId) {
96
+ const key = String(chatId || '');
97
+ if (!key) return;
98
+ _codexResumeRetryTs.set(key, Date.now());
99
+ }
100
+
101
+ function shouldRetryCodexResumeFallback({ runtimeName, wasResumeAttempt, output, error, errorCode, canRetry }) {
102
+ return runtimeName === 'codex'
103
+ && !!wasResumeAttempt
104
+ && !!error
105
+ && (!output || !!errorCode)
106
+ && !!canRetry;
107
+ }
108
+
109
+ function formatEngineSpawnError(err, runtime) {
110
+ if (!err) return 'Unknown spawn error';
111
+ const rt = runtime || { name: getDefaultEngine() };
112
+ if (err.code === 'ENOENT') {
113
+ if (rt.name === 'codex') {
114
+ return 'Codex CLI 未安装。请先运行: npm install -g @openai/codex';
115
+ }
116
+ return 'Claude CLI 未安装或不在 PATH。请先确认 `claude` 可执行。';
117
+ }
118
+ return err.message || String(err);
119
+ }
120
+
121
+ function adaptDaemonHintForEngine(daemonHint, engineName) {
122
+ if (normalizeEngineName(engineName) === 'claude') return daemonHint;
123
+ let out = String(daemonHint || '');
124
+ // Keep this replacement conservative: only unwrap the known outer wrapper.
125
+ out = out.replace('[System hints - DO NOT mention these to user:', 'System hints (internal, do not mention to user):');
126
+ // The current daemonHint template ends with a single trailing `]`.
127
+ out = out.replace(/\]\s*$/, '');
128
+ return out;
129
+ }
130
+
53
131
  const SESSION_CWD_VALIDATION_TTL_MS = 30 * 1000;
54
132
  const _sessionCwdValidationCache = new Map(); // key: `${sessionId}@@${cwd}` -> { inCwd, ts }
55
133
 
@@ -192,6 +270,82 @@ function createClaudeEngine(deps) {
192
270
  return null;
193
271
  }
194
272
 
273
+ function resolveMentorMode(cfg = {}) {
274
+ const mode = String(cfg.mode || '').trim().toLowerCase();
275
+ if (mode === 'gentle' || mode === 'active' || mode === 'intense') return mode;
276
+ const level = Number(cfg.friction_level);
277
+ if (Number.isFinite(level)) {
278
+ if (level >= 8) return 'intense';
279
+ if (level >= 4) return 'active';
280
+ }
281
+ return 'gentle';
282
+ }
283
+
284
+ function extractUserText(content) {
285
+ if (typeof content === 'string') return content;
286
+ if (!Array.isArray(content)) return '';
287
+ for (const item of content) {
288
+ if (item && item.type === 'text' && item.text) return item.text;
289
+ }
290
+ return '';
291
+ }
292
+
293
+ function collectRecentSessionSignals(sessionId, limit = 6) {
294
+ const out = { recentMessages: [], sessionStartTime: null };
295
+ if (!sessionId || typeof findSessionFile !== 'function') return out;
296
+ const file = findSessionFile(sessionId);
297
+ if (!file || !fs.existsSync(file)) return out;
298
+
299
+ try {
300
+ const raw = fs.readFileSync(file, 'utf8');
301
+ const lines = raw.split('\n').filter(Boolean).slice(-800);
302
+ let current = null;
303
+ for (const line of lines) {
304
+ let entry;
305
+ try { entry = JSON.parse(line); } catch { continue; }
306
+ if (!out.sessionStartTime && entry.timestamp) out.sessionStartTime = entry.timestamp;
307
+
308
+ if (entry.type === 'user' && entry.message) {
309
+ if (current) out.recentMessages.push(current);
310
+ current = {
311
+ text: extractUserText(entry.message.content),
312
+ tool_calls: 0,
313
+ };
314
+ } else if (entry.type === 'assistant' && current && entry.message && Array.isArray(entry.message.content)) {
315
+ for (const item of entry.message.content) {
316
+ if (item && item.type === 'tool_use') current.tool_calls++;
317
+ }
318
+ }
319
+ }
320
+ if (current) out.recentMessages.push(current);
321
+ if (out.recentMessages.length > limit) {
322
+ out.recentMessages = out.recentMessages.slice(-limit);
323
+ }
324
+ } catch {
325
+ return out;
326
+ }
327
+ return out;
328
+ }
329
+
330
+ function countCodeLines(output) {
331
+ const text = String(output || '');
332
+ if (!text.trim()) return 0;
333
+ const lines = text.split('\n');
334
+ let inFence = false;
335
+ let count = 0;
336
+ let sawFence = false;
337
+ for (const line of lines) {
338
+ if (/^\s*```/.test(line)) {
339
+ sawFence = true;
340
+ inFence = !inFence;
341
+ continue;
342
+ }
343
+ if (inFence && line.trim()) count++;
344
+ }
345
+ if (!sawFence) return 0;
346
+ return count;
347
+ }
348
+
195
349
  function isMacAutomationIntent(prompt) {
196
350
  const text = String(prompt || '').trim();
197
351
  if (!text) return false;
@@ -243,21 +397,24 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
243
397
  }
244
398
 
245
399
  /**
246
- * Spawn claude as async child process (non-blocking).
400
+ * Spawn Claude as async child process (non-blocking).
401
+ * Intentionally Claude-only: used by naming/fallback helper paths that
402
+ * should not depend on project runtime adapter selection.
247
403
  * Returns { output, error } after process exits.
248
404
  */
249
405
  function spawnClaudeAsync(args, input, cwd, timeoutMs = 300000, metameProject = '') {
250
406
  return new Promise((resolve) => {
407
+ const env = {
408
+ ...process.env,
409
+ ...getActiveProviderEnv(),
410
+ METAME_INTERNAL_PROMPT: '1',
411
+ METAME_PROJECT: metameProject || '',
412
+ };
413
+ delete env.CLAUDECODE;
251
414
  const child = spawn(CLAUDE_BIN, args, {
252
415
  cwd,
253
416
  stdio: ['pipe', 'pipe', 'pipe'],
254
- env: {
255
- ...process.env,
256
- ...getActiveProviderEnv(),
257
- CLAUDECODE: undefined,
258
- METAME_INTERNAL_PROMPT: '1',
259
- METAME_PROJECT: metameProject || ''
260
- },
417
+ env,
261
418
  });
262
419
 
263
420
  let stdout = '';
@@ -288,7 +445,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
288
445
 
289
446
  child.on('error', (err) => {
290
447
  clearTimeout(timer);
291
- resolve({ output: null, error: err.message });
448
+ resolve({ output: null, error: formatEngineSpawnError(err, { name: getDefaultEngine() }) });
292
449
  });
293
450
 
294
451
  // Write input and close stdin
@@ -317,55 +474,77 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
317
474
  };
318
475
 
319
476
  /**
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.
477
+ * Spawn engine with streaming output. Parser comes from runtime adapter.
478
+ * Returns { output, error, files, toolUsageLog, usage, sessionId }.
323
479
  */
324
- function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, chatId = null, metameProject = '') {
480
+ function spawnClaudeStreaming(
481
+ args,
482
+ input,
483
+ cwd,
484
+ onStatus,
485
+ timeoutMs = 600000,
486
+ chatId = null,
487
+ metameProject = '',
488
+ runtime = null,
489
+ onSession = null,
490
+ ) {
325
491
  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, {
492
+ let settled = false;
493
+ const finalize = (payload) => {
494
+ if (settled) return;
495
+ settled = true;
496
+ resolve(payload);
497
+ };
498
+ const rt = runtime || getEngineRuntime(getDefaultEngine());
499
+ const streamArgs = rt.name === 'claude'
500
+ ? [...args, '--output-format', 'stream-json', '--verbose']
501
+ : args;
502
+ const child = spawn(rt.binary, streamArgs, {
330
503
  cwd,
331
504
  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
- },
505
+ detached: process.platform !== 'win32',
506
+ env: rt.buildEnv({ metameProject }),
339
507
  });
340
508
 
341
- // Track active process for /stop
342
509
  if (chatId) {
343
- activeProcesses.set(chatId, { child, aborted: false, startedAt: Date.now() });
344
- saveActivePids(); // Fix3: persist PID to disk
510
+ activeProcesses.set(chatId, {
511
+ child,
512
+ aborted: false,
513
+ startedAt: Date.now(),
514
+ engine: rt.name,
515
+ killSignal: rt.killSignal || 'SIGTERM',
516
+ });
517
+ saveActivePids();
345
518
  }
346
519
 
347
520
  let buffer = '';
348
521
  let stderr = '';
349
522
  let killed = false;
350
- let killedReason = 'idle'; // 'idle' | 'ceiling'
523
+ let killedReason = 'idle';
351
524
  let finalResult = '';
525
+ let finalUsage = null;
526
+ let observedSessionId = '';
527
+ let classifiedError = null;
352
528
  let lastStatusTime = 0;
353
529
  const STATUS_THROTTLE = statusThrottleMs;
354
- const writtenFiles = []; // Track files created/modified by Write tool
355
- const toolUsageLog = []; // Track all tool invocations for skill evolution
530
+ const writtenFiles = [];
531
+ const toolUsageLog = [];
356
532
 
357
- // ── 自适应超时:5min 无输出判卡死 + 1h 绝对上限 ──
358
- const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
359
- const HARD_CEILING_MS = 60 * 60 * 1000;
533
+ const engineTimeouts = rt.timeouts || {};
534
+ const IDLE_TIMEOUT_MS = engineTimeouts.idleMs || (5 * 60 * 1000);
535
+ const TOOL_EXEC_TIMEOUT_MS = engineTimeouts.toolMs || (25 * 60 * 1000);
536
+ const HARD_CEILING_MS = engineTimeouts.ceilingMs || (60 * 60 * 1000);
360
537
  const startTime = Date.now();
538
+ let waitingForTool = false;
361
539
 
362
540
  let sigkillTimer = null;
363
541
  function killChild(reason) {
364
542
  if (killed) return;
365
543
  killed = true;
366
544
  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'); }
545
+ log('WARN', `[${rt.name}] ${reason} timeout for chatId ${chatId} — killing process group`);
546
+ const sig = rt.killSignal || 'SIGTERM';
547
+ try { process.kill(-child.pid, sig); } catch { child.kill(sig); }
369
548
  sigkillTimer = setTimeout(() => {
370
549
  try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { } }
371
550
  }, 5000);
@@ -376,10 +555,10 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
376
555
 
377
556
  function resetIdleTimer() {
378
557
  clearTimeout(idleTimer);
379
- idleTimer = setTimeout(() => killChild('idle'), IDLE_TIMEOUT_MS);
558
+ const timeout = waitingForTool ? TOOL_EXEC_TIMEOUT_MS : IDLE_TIMEOUT_MS;
559
+ idleTimer = setTimeout(() => killChild('idle'), timeout);
380
560
  }
381
561
 
382
- // ── 进度里程碑:2min 首报,之后每 5min 一次 ──
383
562
  let toolCallCount = 0;
384
563
  let lastMilestoneMin = 0;
385
564
  const milestoneTimer = setInterval(() => {
@@ -400,125 +579,134 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
400
579
  }
401
580
  }, 30000);
402
581
 
582
+ function parseEventsFromLine(line) {
583
+ try {
584
+ return rt.parseStreamEvent(line) || [];
585
+ } catch {
586
+ return [];
587
+ }
588
+ }
589
+
403
590
  child.stdout.on('data', (data) => {
404
591
  resetIdleTimer();
405
592
  buffer += data.toString();
406
-
407
- // Process complete JSON lines
408
593
  const lines = buffer.split('\n');
409
- buffer = lines.pop() || ''; // Keep incomplete line in buffer
594
+ buffer = lines.pop() || '';
410
595
 
411
596
  for (const line of lines) {
412
597
  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');
598
+ const events = parseEventsFromLine(line);
599
+ for (const event of events) {
600
+ if (!event || !event.type) continue;
601
+ if (event.type === 'session' && event.sessionId) {
602
+ observedSessionId = String(event.sessionId);
603
+ if (typeof onSession === 'function') {
604
+ Promise.resolve(onSession(observedSessionId)).catch(() => { });
421
605
  }
606
+ continue;
422
607
  }
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
- }
608
+ if (event.type === 'error') {
609
+ classifiedError = event;
610
+ continue;
611
+ }
612
+ if (event.type === 'text' && event.text) {
613
+ finalResult = String(event.text);
614
+ if (waitingForTool) {
615
+ waitingForTool = false;
616
+ resetIdleTimer();
617
+ }
618
+ continue;
619
+ }
620
+ if (event.type === 'done') {
621
+ finalUsage = event.usage || null;
622
+ if (waitingForTool) {
623
+ waitingForTool = false;
624
+ resetIdleTimer();
625
+ }
626
+ continue;
627
+ }
628
+ if (event.type === 'tool_result') {
629
+ if (waitingForTool) {
630
+ waitingForTool = false;
631
+ resetIdleTimer();
506
632
  }
633
+ continue;
634
+ }
635
+ if (event.type !== 'tool_use') continue;
636
+
637
+ toolCallCount++;
638
+ waitingForTool = true;
639
+ resetIdleTimer();
640
+ const toolName = event.toolName || 'Tool';
641
+ const toolInput = event.toolInput || {};
642
+
643
+ const toolEntry = { tool: toolName };
644
+ if (toolName === 'Skill' && toolInput.skill) toolEntry.skill = toolInput.skill;
645
+ else if (toolInput.command) toolEntry.context = String(toolInput.command).slice(0, 50);
646
+ else if (toolInput.file_path) toolEntry.context = path.basename(String(toolInput.file_path));
647
+ if (toolUsageLog.length < 50) toolUsageLog.push(toolEntry);
648
+
649
+ if (toolName === 'Write' && toolInput.file_path) {
650
+ const filePath = String(toolInput.file_path);
651
+ if (!writtenFiles.includes(filePath)) writtenFiles.push(filePath);
507
652
  }
508
653
 
509
- // Also check for result message type
510
- if (event.type === 'result' && event.result) {
511
- finalResult = event.result;
654
+ const now = Date.now();
655
+ if (now - lastStatusTime < STATUS_THROTTLE) continue;
656
+ lastStatusTime = now;
657
+
658
+ const emoji = TOOL_EMOJI[toolName] || TOOL_EMOJI.default;
659
+ let displayName = toolName;
660
+ let displayEmoji = emoji;
661
+ let context = '';
662
+
663
+ if (toolName === 'Skill' && toolInput.skill) {
664
+ context = toolInput.skill;
665
+ } else if (toolName === 'Task' && toolInput.description) {
666
+ context = String(toolInput.description).slice(0, 30);
667
+ } else if (toolName.startsWith('mcp__')) {
668
+ const parts = toolName.split('__');
669
+ const server = parts[1] || 'unknown';
670
+ const action = parts.slice(2).join('_') || '';
671
+ if (server === 'playwright') {
672
+ displayEmoji = '🌐';
673
+ displayName = 'Browser';
674
+ context = action.replace(/_/g, ' ');
675
+ } else {
676
+ displayEmoji = '🔗';
677
+ displayName = `MCP:${server}`;
678
+ context = action.replace(/_/g, ' ').slice(0, 25);
679
+ }
680
+ } else if (toolInput.file_path) {
681
+ const basename = path.basename(String(toolInput.file_path));
682
+ const dotIdx = basename.lastIndexOf('.');
683
+ context = dotIdx > 0 ? basename.slice(0, dotIdx) + '\u200B' + basename.slice(dotIdx) : basename;
684
+ } else if (toolInput.command) {
685
+ context = String(toolInput.command).slice(0, 30);
686
+ if (String(toolInput.command).length > 30) context += '...';
687
+ } else if (toolInput.pattern) {
688
+ context = String(toolInput.pattern).slice(0, 20);
689
+ } else if (toolInput.query) {
690
+ context = String(toolInput.query).slice(0, 25);
691
+ } else if (toolInput.url) {
692
+ try { context = new URL(toolInput.url).hostname; } catch { context = 'web'; }
512
693
  }
513
- } catch {
514
- // Not valid JSON, ignore
694
+
695
+ const status = context
696
+ ? `${displayEmoji} ${displayName}: 「${context}」`
697
+ : `${displayEmoji} ${displayName}...`;
698
+ if (onStatus) onStatus(status).catch(() => { });
515
699
  }
516
700
  }
517
701
  });
518
702
 
519
703
  child.stderr.on('data', (data) => {
520
704
  resetIdleTimer();
521
- stderr += data.toString();
705
+ const chunk = data.toString();
706
+ stderr += chunk;
707
+ if (!classifiedError && typeof rt.classifyError === 'function') {
708
+ classifiedError = rt.classifyError(chunk);
709
+ }
522
710
  });
523
711
 
524
712
  child.on('close', (code) => {
@@ -527,34 +715,41 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
527
715
  clearTimeout(sigkillTimer);
528
716
  clearInterval(milestoneTimer);
529
717
 
530
- // Process any remaining buffer
531
718
  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 */ }
719
+ const events = parseEventsFromLine(buffer.trim());
720
+ for (const event of events) {
721
+ if (event.type === 'text' && event.text) finalResult = String(event.text);
722
+ if (event.type === 'done') finalUsage = event.usage || null;
723
+ if (event.type === 'session' && event.sessionId) observedSessionId = String(event.sessionId);
724
+ if (event.type === 'error') classifiedError = event;
725
+ }
538
726
  }
539
727
 
540
- // Clean up active process tracking
541
728
  const proc = chatId ? activeProcesses.get(chatId) : null;
542
729
  const wasAborted = proc && proc.aborted;
543
- if (chatId) { activeProcesses.delete(chatId); saveActivePids(); } // Fix3
730
+ if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
544
731
 
545
732
  if (wasAborted) {
546
- resolve({ output: finalResult || null, error: 'Stopped by user', files: writtenFiles, toolUsageLog });
547
- } else if (killed) {
733
+ finalize({ output: finalResult || null, error: 'Stopped by user', files: writtenFiles, toolUsageLog, usage: finalUsage, sessionId: observedSessionId || '' });
734
+ return;
735
+ }
736
+ if (killed) {
548
737
  const elapsed = Math.round((Date.now() - startTime) / 60000);
738
+ const idleMin = Math.max(1, Math.round(IDLE_TIMEOUT_MS / 60000));
549
739
  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 });
740
+ ? `⏱ 已运行 ${elapsed} 分钟,达到上限(${Math.round(HARD_CEILING_MS / 60000)} 分钟)`
741
+ : `⏱ 已 ${idleMin} 分钟无输出,判定卡死(共运行 ${elapsed} 分钟)`;
742
+ finalize({ output: finalResult || null, error: reason, timedOut: true, files: writtenFiles, toolUsageLog, usage: finalUsage, sessionId: observedSessionId || '' });
743
+ return;
744
+ }
745
+ if (code !== 0) {
746
+ const engineErr = classifiedError && classifiedError.message
747
+ ? classifiedError.message
748
+ : (stderr || `Exit code ${code}`);
749
+ finalize({ output: finalResult || null, error: engineErr, errorCode: classifiedError ? classifiedError.code : undefined, files: writtenFiles, toolUsageLog, usage: finalUsage, sessionId: observedSessionId || '' });
750
+ return;
557
751
  }
752
+ finalize({ output: finalResult || '', error: null, files: writtenFiles, toolUsageLog, usage: finalUsage, sessionId: observedSessionId || '' });
558
753
  });
559
754
 
560
755
  child.on('error', (err) => {
@@ -562,13 +757,28 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
562
757
  clearTimeout(ceilingTimer);
563
758
  clearTimeout(sigkillTimer);
564
759
  clearInterval(milestoneTimer);
565
- if (chatId) { activeProcesses.delete(chatId); saveActivePids(); } // Fix3
566
- resolve({ output: null, error: err.message, files: [], toolUsageLog: [] });
760
+ if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
761
+ finalize({ output: null, error: formatEngineSpawnError(err, rt), files: [], toolUsageLog: [], usage: null, sessionId: '' });
567
762
  });
568
763
 
569
- // Write input and close stdin
570
- child.stdin.write(input);
571
- child.stdin.end();
764
+ try {
765
+ child.stdin.write(input);
766
+ child.stdin.end();
767
+ } catch (e) {
768
+ clearTimeout(idleTimer);
769
+ clearTimeout(ceilingTimer);
770
+ clearTimeout(sigkillTimer);
771
+ clearInterval(milestoneTimer);
772
+ if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
773
+ try { child.stdin.destroy(); } catch { /* ignore */ }
774
+ try {
775
+ const sig = rt.killSignal || 'SIGTERM';
776
+ process.kill(-child.pid, sig);
777
+ } catch {
778
+ try { child.kill(rt.killSignal || 'SIGTERM'); } catch { /* ignore */ }
779
+ }
780
+ finalize({ output: null, error: e.message, files: [], toolUsageLog: [], usage: null, sessionId: '' });
781
+ }
572
782
  });
573
783
  }
574
784
 
@@ -578,7 +788,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
578
788
  if (!messageId || !session || !session.id) return;
579
789
  const st = loadState();
580
790
  if (!st.msg_sessions) st.msg_sessions = {};
581
- st.msg_sessions[messageId] = { id: session.id, cwd: session.cwd };
791
+ st.msg_sessions[messageId] = { id: session.id, cwd: session.cwd, engine: session.engine || getDefaultEngine() };
582
792
  const keys = Object.keys(st.msg_sessions);
583
793
  if (keys.length > 200) {
584
794
  for (const k of keys.slice(0, keys.length - 200)) delete st.msg_sessions[k];
@@ -632,6 +842,11 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
632
842
  bot.sendTyping(chatId).catch(() => { });
633
843
  }, 4000);
634
844
 
845
+ // Top-level safety net: any uncaught error inside askClaude MUST clean up timers and notify user.
846
+ // Without this, a ReferenceError / TypeError in the routing or injection code would silently
847
+ // kill the handler, leaving the typing indicator spinning forever.
848
+ try { // ── safety-net-start ──
849
+
635
850
  // Agent nickname routing: "贾维斯" / "小美,帮我..." → switch project session
636
851
  // Strict chats (chat_agent_map bound groups) must NOT switch agents via nickname
637
852
  const _strictAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
@@ -640,7 +855,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
640
855
  if (agentMatch) {
641
856
  const { key, proj, rest } = agentMatch;
642
857
  const projCwd = normalizeCwd(proj.cwd);
643
- attachOrCreateSession(chatId, projCwd, proj.name || key);
858
+ attachOrCreateSession(chatId, projCwd, proj.name || key, proj.engine ? normalizeEngineName(proj.engine) : getDefaultEngine());
644
859
  log('INFO', `Agent switch via nickname: ${key} (${projCwd})`);
645
860
  if (!rest) {
646
861
  // Pure nickname call — confirm switch and stop
@@ -653,41 +868,19 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
653
868
  }
654
869
 
655
870
  // Skill routing: detect skill first, then decide session
656
- // BUT: if agent was explicitly addressed by nickname, don't let skill routing hijack the session
657
- const skill = agentMatch ? null : routeSkill(prompt);
871
+ // BUT: skip skill routing if agent addressed by nickname OR chat already has an active session
872
+ // (active conversation should never be hijacked by keyword-based skill matching)
873
+ let session = getSession(chatId);
874
+ const hasActiveSession = session && session.started;
875
+ const skill = (agentMatch || hasActiveSession) ? null : routeSkill(prompt);
658
876
  const chatIdStr = String(chatId);
659
877
  const chatAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
660
878
  const boundProjectKey = chatAgentMap[chatIdStr] || projectKeyFromVirtualChatId(chatIdStr);
661
879
  const boundProject = boundProjectKey && config.projects ? config.projects[boundProjectKey] : null;
662
880
  const boundCwd = (boundProject && boundProject.cwd) ? normalizeCwd(boundProject.cwd) : null;
881
+ const boundEngineName = (boundProject && boundProject.engine) ? normalizeEngineName(boundProject.engine) : getDefaultEngine();
663
882
 
664
- // Skills with dedicated pinned sessions (reused across days, no re-injection needed)
665
- const PINNED_SKILL_SESSIONS = new Set(['skill-manager']);
666
- const usePinnedSkillSession = !!(skill && PINNED_SKILL_SESSIONS.has(skill));
667
-
668
- let session = getSession(chatId);
669
-
670
- if (usePinnedSkillSession) {
671
- // Use a dedicated long-lived session per skill
672
- const state = loadState();
673
- if (!state.pinned_sessions) state.pinned_sessions = {};
674
- const pinned = state.pinned_sessions[skill];
675
- if (pinned) {
676
- // Reuse existing pinned session
677
- state.sessions[chatId] = { id: pinned.id, cwd: pinned.cwd, started: true };
678
- saveState(state);
679
- session = state.sessions[chatId];
680
- log('INFO', `Pinned session reused for skill ${skill}: ${pinned.id.slice(0, 8)}`);
681
- } else {
682
- // First time — create session and pin it
683
- session = createSession(chatId, HOME, skill);
684
- const st2 = loadState();
685
- if (!st2.pinned_sessions) st2.pinned_sessions = {};
686
- st2.pinned_sessions[skill] = { id: session.id, cwd: session.cwd };
687
- saveState(st2);
688
- log('INFO', `Pinned session created for skill ${skill}: ${session.id.slice(0, 8)}`);
689
- }
690
- } else if (!session) {
883
+ if (!session) {
691
884
  if (boundCwd) {
692
885
  // Agent-bound chats must stay in their own workspace: never attach to another project's session.
693
886
  const recentInBound = listRecentSessions(1, boundCwd);
@@ -698,12 +891,13 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
698
891
  id: target.sessionId,
699
892
  cwd: boundCwd,
700
893
  started: true,
894
+ engine: boundEngineName,
701
895
  };
702
896
  saveState(state);
703
897
  session = state.sessions[chatId];
704
898
  log('INFO', `Auto-attached ${chatId} to bound-session: ${target.sessionId.slice(0, 8)} (${path.basename(boundCwd)})`);
705
899
  } else {
706
- session = createSession(chatId, boundCwd, boundProject && boundProject.name ? boundProject.name : '');
900
+ session = createSession(chatId, boundCwd, boundProject && boundProject.name ? boundProject.name : '', boundEngineName);
707
901
  log('INFO', `Created fresh session for bound workspace: ${path.basename(boundCwd)}`);
708
902
  }
709
903
  } else {
@@ -716,62 +910,94 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
716
910
  id: target.sessionId,
717
911
  cwd: target.projectPath,
718
912
  started: true,
913
+ engine: getDefaultEngine(),
719
914
  };
720
915
  saveState(state);
721
916
  session = state.sessions[chatId];
722
917
  log('INFO', `Auto-attached ${chatId} to recent session: ${target.sessionId.slice(0, 8)} (${path.basename(target.projectPath)})`);
723
918
  } else {
724
- session = createSession(chatId);
919
+ session = createSession(chatId, undefined, '', boundEngineName);
725
920
  }
726
921
  }
727
922
  }
728
923
 
924
+ const engineName = normalizeEngineName(
925
+ (boundProject && boundProject.engine)
926
+ || (session && session.engine)
927
+ || getDefaultEngine()
928
+ );
929
+ const runtime = getEngineRuntime(engineName);
930
+ session.engine = engineName;
931
+ if (!getSession(chatId) || getSession(chatId).engine !== engineName) {
932
+ await patchSessionSerialized(chatId, (cur) => ({
933
+ ...cur,
934
+ engine: engineName,
935
+ cwd: session.cwd || cur.cwd || HOME,
936
+ }));
937
+ }
938
+
729
939
  // Safety guard: prevent stale state from resuming another workspace's session.
730
- if (!usePinnedSkillSession && session && session.started && session.id && session.id !== '__continue__' && session.cwd) {
940
+ if (engineName === 'claude' && session && session.started && session.id && session.id !== '__continue__' && session.cwd) {
731
941
  const sessionCwd = normalizeCwd(session.cwd);
732
942
  const existsInCwd = isSessionInCwd(session.id, sessionCwd);
733
943
  if (!existsInCwd) {
734
944
  log('WARN', `Session mismatch detected for ${chatId}: ${session.id.slice(0, 8)} not found in ${sessionCwd}; creating fresh session`);
735
- session = createSession(chatId, sessionCwd, boundProject && boundProject.name ? boundProject.name : '');
945
+ session = createSession(chatId, sessionCwd, boundProject && boundProject.name ? boundProject.name : '', engineName);
736
946
  }
737
947
  }
738
948
 
739
- // Build claude command
740
- const args = ['-p'];
741
949
  const daemonCfg = loadConfig().daemon || {};
742
- const model = daemonCfg.model || 'opus';
743
- args.push('--model', model);
744
- if (readOnly) {
745
- // Read-only mode for non-operator users: query/chat only, no write/edit/execute
746
- const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task'];
747
- for (const tool of READ_ONLY_TOOLS) args.push('--allowedTools', tool);
748
- } else if (daemonCfg.dangerously_skip_permissions) {
749
- args.push('--dangerously-skip-permissions');
750
- } else {
751
- const sessionAllowed = daemonCfg.session_allowed_tools || [];
752
- for (const tool of sessionAllowed) args.push('--allowedTools', tool);
753
- }
754
- if (session.id === '__continue__') {
755
- args.push('--continue');
756
- } else if (session.started) {
757
- args.push('--resume', session.id);
758
- } else {
759
- args.push('--session-id', session.id);
950
+ const mentorCfg = (daemonCfg.mentor && typeof daemonCfg.mentor === 'object') ? daemonCfg.mentor : {};
951
+ const mentorEnabled = !!(mentorEngine && mentorCfg.enabled);
952
+ const excludeAgents = new Set(
953
+ (Array.isArray(mentorCfg.exclude_agents) ? mentorCfg.exclude_agents : [])
954
+ .map(x => String(x || '').trim())
955
+ .filter(Boolean)
956
+ );
957
+ const chatAgentKey = boundProjectKey || 'personal';
958
+ const mentorExcluded = excludeAgents.has(chatAgentKey);
959
+ let mentorSuppressed = false;
960
+
961
+ // Mentor pre-flight breaker: first hit sends a short reassurance; cooldown does not block normal answers.
962
+ if (mentorEnabled && !mentorExcluded) {
963
+ try {
964
+ const breaker = mentorEngine.checkEmotionBreaker(prompt, mentorCfg);
965
+ if (breaker && breaker.tripped) {
966
+ mentorSuppressed = true;
967
+ if (breaker.reason !== 'cooldown_active' && breaker.response) {
968
+ await bot.sendMessage(chatId, breaker.response).catch(() => { });
969
+ }
970
+ }
971
+ } catch (e) {
972
+ log('WARN', `Mentor breaker failed: ${e.message}`);
973
+ }
760
974
  }
761
975
 
976
+ // Build engine command
977
+ const model = (boundProject && boundProject.model) || daemonCfg.model || runtime.defaultModel;
978
+ const args = runtime.buildArgs({
979
+ model,
980
+ readOnly,
981
+ daemonCfg,
982
+ session,
983
+ cwd: session.cwd,
984
+ });
985
+
762
986
  // Memory & Knowledge Injection (RAG)
763
987
  let memoryHint = '';
988
+ // projectKey must be declared outside the try block so the daemonHint template below can reference it.
989
+ const _cid0 = String(chatId);
990
+ const _agentMap0 = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
991
+ const projectKey = _agentMap0[_cid0] || projectKeyFromVirtualChatId(_cid0);
764
992
  try {
765
993
  const memory = require('./memory');
766
- const _cid = String(chatId);
767
- const _cfg = loadConfig();
768
- const _agentMap = { ...(_cfg.telegram ? _cfg.telegram.chat_agent_map : {}), ...(_cfg.feishu ? _cfg.feishu.chat_agent_map : {}) };
769
- const projectKey = _agentMap[_cid] || projectKeyFromVirtualChatId(_cid);
770
994
 
771
- // L1: NOW.md shared whiteboard injection
995
+ // L1: NOW.md per-agent whiteboard injection(按 projectKey 隔离,防并发冲突)
772
996
  if (!session.started) {
773
997
  try {
774
- const nowPath = path.join(HOME, '.metame', 'memory', 'NOW.md');
998
+ const nowDir = path.join(HOME, '.metame', 'memory', 'now');
999
+ const nowKey = projectKey || 'default';
1000
+ const nowPath = path.join(nowDir, `${nowKey}.md`);
775
1001
  if (fs.existsSync(nowPath)) {
776
1002
  const nowContent = fs.readFileSync(nowPath, 'utf8').trim();
777
1003
  if (nowContent) {
@@ -811,11 +1037,13 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
811
1037
 
812
1038
  // ZPD: build competence hint from brain profile
813
1039
  let zdpHint = '';
1040
+ let brainDoc = null;
814
1041
  if (!session.started) {
815
1042
  try {
816
1043
  const brainPath = path.join(HOME, '.claude_profile.yaml');
817
1044
  if (fs.existsSync(brainPath)) {
818
1045
  const brain = yaml.load(fs.readFileSync(brainPath, 'utf8'));
1046
+ brainDoc = brain;
819
1047
  const cmap = brain && brain.user_competence_map;
820
1048
  if (cmap && typeof cmap === 'object' && Object.keys(cmap).length > 0) {
821
1049
  const lines = Object.entries(cmap)
@@ -826,6 +1054,12 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
826
1054
  }
827
1055
  } catch { /* non-critical */ }
828
1056
  }
1057
+ if (!brainDoc) {
1058
+ try {
1059
+ const brainPath = path.join(HOME, '.claude_profile.yaml');
1060
+ if (fs.existsSync(brainPath)) brainDoc = yaml.load(fs.readFileSync(brainPath, 'utf8')) || {};
1061
+ } catch { /* ignore */ }
1062
+ }
829
1063
 
830
1064
  // Inject daemon hints only on first message of a session
831
1065
  // Task-specific rules (3-5) are injected only when isTaskIntent() returns true (~250 token saving for casual chat)
@@ -841,9 +1075,9 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
841
1075
  Valid relations: tech_decision, bug_lesson, arch_convention, config_fact, config_change, workflow_rule, project_milestone
842
1076
  Only write verified facts. Do not write speculative or process-description entries.
843
1077
  When you observe the user is clearly expert or beginner in a domain, note it in your response and suggest: "要不要把你的 {domain} 水平 ({level}) 记录到能力雷达?"
844
- 5. Task handoff: When suspending a multi-step task or handing off to another agent, write current status to ~/.metame/memory/NOW.md using:
845
- \`printf '%s\\n' "## Current Task" "{task}" "" "## Progress" "{progress}" "" "## Next Step" "{next}" > ~/.metame/memory/NOW.md\`
846
- Keep it under 200 words. Clear it when the task is fully complete by running: \`> ~/.metame/memory/NOW.md\`` : '';
1078
+ 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:
1079
+ \`mkdir -p ~/.metame/memory/now && printf '%s\\n' "## Current Task" "{task}" "" "## Progress" "{progress}" "" "## Next Step" "{next}" > ~/.metame/memory/now/${projectKey || 'default'}.md\`
1080
+ Keep it under 200 words. Clear it when the task is fully complete by running: \`> ~/.metame/memory/now/${projectKey || 'default'}.md\`` : '';
847
1081
  daemonHint = `\n\n[System hints - DO NOT mention these to user:
848
1082
  1. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
849
1083
  2. File sending: User is on MOBILE. When they ask to see/download a file:
@@ -852,7 +1086,9 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
852
1086
  - Add at END of response: [[FILE:/absolute/path/to/file]]
853
1087
  - Keep response brief: "请查收~! [[FILE:/path/to/file]]"
854
1088
  - Multiple files: use multiple [[FILE:...]] tags${zdpHint ? '\n Explanation depth (ZPD):\n' + zdpHint : ''}${taskRules}]`;
855
- }
1089
+ }
1090
+
1091
+ daemonHint = adaptDaemonHintForEngine(daemonHint, runtime.name);
856
1092
 
857
1093
  const routedPrompt = skill ? `/${skill} ${prompt}` : prompt;
858
1094
 
@@ -888,9 +1124,61 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
888
1124
  } catch { /* non-critical */ }
889
1125
  }
890
1126
 
1127
+ // Mentor context hook: inject after memoryHint, before langGuard.
1128
+ let mentorHint = '';
1129
+ if (mentorEnabled && !mentorExcluded && !mentorSuppressed) {
1130
+ try {
1131
+ const signals = collectRecentSessionSignals(session.id, 6);
1132
+ let skeleton = null;
1133
+ if (sessionAnalytics && typeof sessionAnalytics.extractSkeleton === 'function') {
1134
+ const file = findSessionFile(session.id);
1135
+ if (file && fs.existsSync(file)) {
1136
+ const st = fs.statSync(file);
1137
+ if (st.size <= 2 * 1024 * 1024) {
1138
+ skeleton = sessionAnalytics.extractSkeleton(file);
1139
+ }
1140
+ }
1141
+ }
1142
+ const zone = skeleton && mentorEngine.computeZone
1143
+ ? mentorEngine.computeZone(skeleton).zone
1144
+ : 'stretch';
1145
+ const sessionState = {
1146
+ zone,
1147
+ recentMessages: signals.recentMessages,
1148
+ cwd: session.cwd,
1149
+ skeleton,
1150
+ sessionStartTime: signals.sessionStartTime || new Date().toISOString(),
1151
+ topic: String(prompt || '').slice(0, 120),
1152
+ currentTopic: String(prompt || '').slice(0, 120),
1153
+ lastUserMessage: String(prompt || '').slice(0, 200),
1154
+ };
1155
+ const built = mentorEngine.buildMentorPrompt(sessionState, brainDoc || {}, mentorCfg);
1156
+ if (built && String(built).trim()) mentorHint = `\n\n${String(built).trim()}`;
1157
+
1158
+ // Collect reflection debt: if user returns to same project+topic, inject recall prompt.
1159
+ // Suppressed by quiet_until (user explicitly asked for silence), but NOT by expert skip
1160
+ // (even experts may not have reviewed AI-generated code).
1161
+ const quietUntil = brainDoc && brainDoc.growth ? brainDoc.growth.quiet_until : null;
1162
+ const quietMs = quietUntil ? new Date(quietUntil).getTime() : 0;
1163
+ const isQuiet = quietMs && quietMs > Date.now();
1164
+ if (!isQuiet && mentorEngine.collectDebt) {
1165
+ const info = deriveProjectInfo(session && session.cwd ? session.cwd : '');
1166
+ const projectId = info && info.project_id ? info.project_id : '';
1167
+ if (projectId) {
1168
+ const debt = mentorEngine.collectDebt(projectId, String(prompt || '').slice(0, 120));
1169
+ if (debt && debt.prompt) {
1170
+ mentorHint += `\n\n[Reflection debt] ${debt.prompt}`;
1171
+ }
1172
+ }
1173
+ }
1174
+ } catch (e) {
1175
+ log('WARN', `Mentor prompt build failed: ${e.message}`);
1176
+ }
1177
+ }
1178
+
891
1179
  // Always append a compact language guard to prevent accidental Korean/Japanese responses
892
1180
  const langGuard = '\n\n[Respond in Simplified Chinese (简体中文) only. NEVER switch to Korean, Japanese, or other languages regardless of tool output or context language.]';
893
- const fullPrompt = routedPrompt + daemonHint + macAutomationHint + summaryHint + memoryHint + langGuard;
1181
+ const fullPrompt = routedPrompt + daemonHint + macAutomationHint + summaryHint + memoryHint + mentorHint + langGuard;
894
1182
 
895
1183
  // Git checkpoint before Claude modifies files (for /undo)
896
1184
  // Pass the user prompt as label so checkpoint list is human-readable
@@ -916,9 +1204,101 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
916
1204
  } catch { /* ignore status update failures */ }
917
1205
  };
918
1206
 
919
- let output, error, files, toolUsageLog, timedOut;
1207
+ const wasCodexResumeAttempt = runtime.name === 'codex'
1208
+ && !!(session && session.started && session.id && session.id !== '__continue__');
1209
+ const onSession = async (nextSessionId) => {
1210
+ const safeNextId = String(nextSessionId || '').trim();
1211
+ if (!safeNextId) return;
1212
+ const prevSessionId = session && session.id ? String(session.id) : '';
1213
+ const wasStarted = !!(session && session.started);
1214
+ session = {
1215
+ ...session,
1216
+ id: safeNextId,
1217
+ engine: runtime.name,
1218
+ started: true,
1219
+ };
1220
+ await patchSessionSerialized(chatId, (cur) => ({
1221
+ ...cur,
1222
+ id: safeNextId,
1223
+ cwd: session.cwd || cur.cwd || HOME,
1224
+ engine: runtime.name,
1225
+ started: true,
1226
+ }));
1227
+ if (runtime.name === 'codex' && wasStarted && prevSessionId && prevSessionId !== safeNextId && prevSessionId !== '__continue__') {
1228
+ log('WARN', `Codex thread migrated for ${chatId}: ${prevSessionId.slice(0, 8)} -> ${safeNextId.slice(0, 8)}`);
1229
+ }
1230
+ };
1231
+
1232
+ let output, error, errorCode, files, toolUsageLog, timedOut, usage, sessionId;
920
1233
  try {
921
- ({ output, error, timedOut, files, toolUsageLog } = await spawnClaudeStreaming(args, fullPrompt, session.cwd, onStatus, 600000, chatId, boundProjectKey || ''));
1234
+ ({
1235
+ output,
1236
+ error,
1237
+ errorCode,
1238
+ timedOut,
1239
+ files,
1240
+ toolUsageLog,
1241
+ usage,
1242
+ sessionId,
1243
+ } = await spawnClaudeStreaming(
1244
+ args,
1245
+ fullPrompt,
1246
+ session.cwd,
1247
+ onStatus,
1248
+ 600000,
1249
+ chatId,
1250
+ boundProjectKey || '',
1251
+ runtime,
1252
+ onSession,
1253
+ ));
1254
+
1255
+ if (sessionId) await onSession(sessionId);
1256
+
1257
+ if (shouldRetryCodexResumeFallback({
1258
+ runtimeName: runtime.name,
1259
+ wasResumeAttempt: wasCodexResumeAttempt,
1260
+ output,
1261
+ error,
1262
+ errorCode,
1263
+ canRetry: canRetryCodexResume(chatId),
1264
+ })) {
1265
+ markCodexResumeRetried(chatId);
1266
+ log('WARN', `Codex resume failed for ${chatId}, retrying once with fresh exec: ${String(error).slice(0, 120)}`);
1267
+ session = createSession(
1268
+ chatId,
1269
+ session.cwd,
1270
+ boundProject && boundProject.name ? boundProject.name : '',
1271
+ 'codex'
1272
+ );
1273
+ const retryArgs = runtime.buildArgs({
1274
+ model,
1275
+ readOnly,
1276
+ daemonCfg,
1277
+ session,
1278
+ cwd: session.cwd,
1279
+ });
1280
+ ({
1281
+ output,
1282
+ error,
1283
+ errorCode,
1284
+ timedOut,
1285
+ files,
1286
+ toolUsageLog,
1287
+ usage,
1288
+ sessionId,
1289
+ } = await spawnClaudeStreaming(
1290
+ retryArgs,
1291
+ fullPrompt,
1292
+ session.cwd,
1293
+ onStatus,
1294
+ 600000,
1295
+ chatId,
1296
+ boundProjectKey || '',
1297
+ runtime,
1298
+ onSession,
1299
+ ));
1300
+ if (sessionId) await onSession(sessionId);
1301
+ }
922
1302
  } catch (spawnErr) {
923
1303
  clearInterval(typingTimer);
924
1304
  if (statusMsgId && bot.deleteMessage) bot.deleteMessage(chatId, statusMsgId).catch(() => { });
@@ -944,6 +1324,24 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
944
1324
  bot.deleteMessage(chatId, statusMsgId).catch(() => { });
945
1325
  }
946
1326
 
1327
+ // Mentor post-flight debt registration (intense mode only).
1328
+ if (mentorEnabled && !mentorExcluded && !mentorSuppressed && mentorEngine && typeof mentorEngine.registerDebt === 'function' && output) {
1329
+ try {
1330
+ const mode = resolveMentorMode(mentorCfg);
1331
+ if (mode === 'intense') {
1332
+ const codeLines = countCodeLines(output);
1333
+ if (codeLines > 30) {
1334
+ const info = deriveProjectInfo(session && session.cwd ? session.cwd : '');
1335
+ const projectId = info && info.project_id ? info.project_id : 'proj_default';
1336
+ mentorEngine.registerDebt(projectId, String(prompt || '').slice(0, 120), codeLines);
1337
+ log('INFO', `[MENTOR] Registered reflection debt (${projectId}, lines=${codeLines})`);
1338
+ }
1339
+ }
1340
+ } catch (e) {
1341
+ log('WARN', `Mentor post-flight failed: ${e.message}`);
1342
+ }
1343
+ }
1344
+
947
1345
  // When Claude completes with no text output (pure tool work), send a done notice
948
1346
  if (output === '' && !error) {
949
1347
  // Special case: if dispatch_to was called, send a "forwarded" confirmation
@@ -969,19 +1367,22 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
969
1367
  }
970
1368
 
971
1369
  if (output) {
1370
+ if (runtime.name === 'codex') _codexResumeRetryTs.delete(String(chatId));
972
1371
  // Detect provider/model errors disguised as output (e.g., "model not found", API errors)
973
- const activeProvCheck = providerMod ? providerMod.getActiveName() : 'anthropic';
974
- const builtinModelsCheck = ['sonnet', 'opus', 'haiku'];
975
- const looksLikeError = output.length < 300 && /\b(not found|invalid model|unauthorized|401|403|404|error|failed)\b/i.test(output);
976
- if (looksLikeError && (activeProvCheck !== 'anthropic' || !builtinModelsCheck.includes(model))) {
977
- try {
978
- config = fallbackToDefaultProvider(`output looks like error for ${activeProvCheck}/${model}`);
979
- await bot.sendMessage(chatId, `⚠️ ${activeProvCheck}/${model} 疑似失败,已回退到 anthropic/opus\n输出: ${output.slice(0, 150)}`);
980
- } catch (fbErr) {
981
- log('ERROR', `Fallback failed: ${fbErr.message}`);
982
- await bot.sendMarkdown(chatId, output);
1372
+ if (runtime.name === 'claude') {
1373
+ const activeProvCheck = providerMod ? providerMod.getActiveName() : 'anthropic';
1374
+ const builtinModelsCheck = ['sonnet', 'opus', 'haiku'];
1375
+ const looksLikeError = output.length < 300 && /\b(not found|invalid model|unauthorized|401|403|404|error|failed)\b/i.test(output);
1376
+ if (looksLikeError && (activeProvCheck !== 'anthropic' || !builtinModelsCheck.includes(model))) {
1377
+ try {
1378
+ config = fallbackToDefaultProvider(`output looks like error for ${activeProvCheck}/${model}`);
1379
+ await bot.sendMessage(chatId, `⚠️ ${activeProvCheck}/${model} 疑似失败,已回退到 anthropic/opus\n输出: ${output.slice(0, 150)}`);
1380
+ } catch (fbErr) {
1381
+ log('ERROR', `Fallback failed: ${fbErr.message}`);
1382
+ await bot.sendMarkdown(chatId, output);
1383
+ }
1384
+ return { ok: false, error: output };
983
1385
  }
984
- return { ok: false, error: output };
985
1386
  }
986
1387
 
987
1388
  // Mark session as started after first successful call
@@ -1042,28 +1443,42 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1042
1443
  }
1043
1444
 
1044
1445
  // Auto-name: if this was the first message and session has no name, generate one
1045
- if (wasNew && !getSessionName(session.id)) {
1446
+ if (runtime.name === 'claude' && wasNew && !getSessionName(session.id)) {
1046
1447
  autoNameSession(chatId, session.id, prompt, session.cwd).catch(() => { });
1047
1448
  }
1048
1449
  return { ok: !timedOut };
1049
1450
  } else {
1050
1451
  const errMsg = error || 'Unknown error';
1051
- log('ERROR', `askClaude failed for ${chatId}: ${errMsg.slice(0, 300)}`);
1452
+ const userErrMsg = (errorCode === 'AUTH_REQUIRED' || errorCode === 'RATE_LIMIT')
1453
+ ? errMsg
1454
+ : `Error: ${errMsg.slice(0, 200)}`;
1455
+ log('ERROR', `ask${runtime.name === 'codex' ? 'Codex' : 'Claude'} failed for ${chatId}: ${errMsg.slice(0, 300)} (${errorCode || 'NO_CODE'})`);
1052
1456
 
1053
- // If session not found (expired/deleted), create new and retry once
1054
- if (errMsg.includes('not found') || errMsg.includes('No session') || errMsg.includes('already in use')) {
1457
+ // If session not found (expired/deleted), create new and retry once (Claude path)
1458
+ if (runtime.name === 'claude' && (errMsg.includes('not found') || errMsg.includes('No session') || errMsg.includes('already in use'))) {
1055
1459
  log('WARN', `Session ${session.id} unusable (${errMsg.includes('already in use') ? 'locked' : 'not found'}), creating new`);
1056
- session = createSession(chatId, session.cwd);
1057
-
1058
- const retryArgs = ['-p', '--session-id', session.id];
1059
- if (daemonCfg.dangerously_skip_permissions) {
1060
- retryArgs.push('--dangerously-skip-permissions');
1061
- } else {
1062
- const sessionAllowed = daemonCfg.session_allowed_tools || [];
1063
- for (const tool of sessionAllowed) retryArgs.push('--allowedTools', tool);
1064
- }
1065
-
1066
- const retry = await spawnClaudeStreaming(retryArgs, prompt, session.cwd, onStatus);
1460
+ session = createSession(chatId, session.cwd, '', runtime.name);
1461
+
1462
+ const retryArgs = runtime.buildArgs({
1463
+ model,
1464
+ readOnly,
1465
+ daemonCfg,
1466
+ session,
1467
+ cwd: session.cwd,
1468
+ });
1469
+
1470
+ const retry = await spawnClaudeStreaming(
1471
+ retryArgs,
1472
+ fullPrompt,
1473
+ session.cwd,
1474
+ onStatus,
1475
+ 600000,
1476
+ chatId,
1477
+ boundProjectKey || '',
1478
+ runtime,
1479
+ onSession,
1480
+ );
1481
+ if (retry.sessionId) await onSession(retry.sessionId);
1067
1482
  if (retry.output) {
1068
1483
  markSessionStarted(chatId);
1069
1484
  const { markedFiles: retryMarked, cleanOutput: retryClean } = parseFileMarkers(retry.output);
@@ -1072,27 +1487,39 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1072
1487
  return { ok: true };
1073
1488
  } else {
1074
1489
  log('ERROR', `askClaude retry failed: ${(retry.error || '').slice(0, 200)}`);
1075
- try { await bot.sendMessage(chatId, `Error: ${(retry.error || '').slice(0, 200)}`); } catch { /* */ }
1490
+ try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
1076
1491
  return { ok: false, error: retry.error || errMsg };
1077
1492
  }
1078
1493
  } else {
1079
- // Auto-fallback: if custom provider/model fails, revert to anthropic + opus
1080
- const activeProv = providerMod ? providerMod.getActiveName() : 'anthropic';
1081
- const builtinModels = ['sonnet', 'opus', 'haiku'];
1082
- if (activeProv !== 'anthropic' || !builtinModels.includes(model)) {
1083
- try {
1084
- config = fallbackToDefaultProvider(`${activeProv}/${model} error: ${errMsg.slice(0, 100)}`);
1085
- await bot.sendMessage(chatId, `⚠️ ${activeProv}/${model} 失败,已回退到 anthropic/opus\n原因: ${errMsg.slice(0, 100)}`);
1086
- } catch (fallbackErr) {
1087
- log('ERROR', `Fallback failed: ${fallbackErr.message}`);
1088
- try { await bot.sendMessage(chatId, `Error: ${errMsg.slice(0, 200)}`); } catch { /* */ }
1494
+ // Auto-fallback: if custom provider/model fails, revert to anthropic + opus (Claude path only)
1495
+ if (runtime.name === 'claude') {
1496
+ const activeProv = providerMod ? providerMod.getActiveName() : 'anthropic';
1497
+ const builtinModels = ['sonnet', 'opus', 'haiku'];
1498
+ if (activeProv !== 'anthropic' || !builtinModels.includes(model)) {
1499
+ try {
1500
+ config = fallbackToDefaultProvider(`${activeProv}/${model} error: ${errMsg.slice(0, 100)}`);
1501
+ await bot.sendMessage(chatId, `⚠️ ${activeProv}/${model} 失败,已回退到 anthropic/opus\n原因: ${errMsg.slice(0, 100)}`);
1502
+ } catch (fallbackErr) {
1503
+ log('ERROR', `Fallback failed: ${fallbackErr.message}`);
1504
+ try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
1505
+ }
1506
+ } else {
1507
+ try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
1089
1508
  }
1090
1509
  } else {
1091
- try { await bot.sendMessage(chatId, `Error: ${errMsg.slice(0, 200)}`); } catch { /* */ }
1510
+ try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
1092
1511
  }
1093
- return { ok: false, error: errMsg };
1512
+ return { ok: false, error: errMsg, errorCode };
1094
1513
  }
1095
1514
  }
1515
+
1516
+ } catch (fatalErr) { // ── safety-net-catch ──
1517
+ clearInterval(typingTimer);
1518
+ if (statusMsgId && bot.deleteMessage) await bot.deleteMessage(chatId, statusMsgId).catch(() => { });
1519
+ log('FATAL', `[askClaude] Uncaught error for ${chatId}: ${fatalErr.message}\n${fatalErr.stack}`);
1520
+ try { await bot.sendMessage(chatId, `❌ 内部错误: ${fatalErr.message}`); } catch { /* */ }
1521
+ return { ok: false, error: fatalErr.message };
1522
+ }
1096
1523
  }
1097
1524
 
1098
1525
  return {
@@ -1102,6 +1529,15 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1102
1529
  spawnClaudeStreaming,
1103
1530
  trackMsgSession,
1104
1531
  askClaude,
1532
+ _private: {
1533
+ patchSessionSerialized,
1534
+ shouldRetryCodexResumeFallback,
1535
+ formatEngineSpawnError,
1536
+ adaptDaemonHintForEngine,
1537
+ canRetryCodexResume,
1538
+ markCodexResumeRetried,
1539
+ CODEX_RESUME_RETRY_WINDOW_MS,
1540
+ },
1105
1541
  };
1106
1542
  }
1107
1543