metame-cli 1.5.3 → 1.5.5

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 (51) hide show
  1. package/README.md +60 -18
  2. package/index.js +352 -79
  3. package/package.json +2 -2
  4. package/scripts/agent-layer.js +4 -2
  5. package/scripts/bin/dispatch_to +178 -90
  6. package/scripts/daemon-admin-commands.js +353 -105
  7. package/scripts/daemon-agent-commands.js +434 -66
  8. package/scripts/daemon-bridges.js +477 -68
  9. package/scripts/daemon-claude-engine.js +1267 -674
  10. package/scripts/daemon-command-router.js +205 -27
  11. package/scripts/daemon-command-session-route.js +118 -0
  12. package/scripts/daemon-default.yaml +7 -0
  13. package/scripts/daemon-engine-runtime.js +96 -20
  14. package/scripts/daemon-exec-commands.js +108 -49
  15. package/scripts/daemon-file-browser.js +64 -7
  16. package/scripts/daemon-notify.js +18 -4
  17. package/scripts/daemon-ops-commands.js +16 -2
  18. package/scripts/daemon-remote-dispatch.js +55 -1
  19. package/scripts/daemon-runtime-lifecycle.js +87 -0
  20. package/scripts/daemon-session-commands.js +102 -45
  21. package/scripts/daemon-session-store.js +497 -66
  22. package/scripts/daemon-siri-bridge.js +234 -0
  23. package/scripts/daemon-siri-imessage.js +209 -0
  24. package/scripts/daemon-task-scheduler.js +10 -2
  25. package/scripts/daemon.js +697 -179
  26. package/scripts/daemon.yaml +7 -0
  27. package/scripts/docs/agent-guide.md +36 -3
  28. package/scripts/docs/hook-config.md +134 -0
  29. package/scripts/docs/maintenance-manual.md +162 -5
  30. package/scripts/docs/pointer-map.md +60 -5
  31. package/scripts/feishu-adapter.js +7 -15
  32. package/scripts/hooks/doc-router.js +29 -0
  33. package/scripts/hooks/hook-utils.js +61 -0
  34. package/scripts/hooks/intent-doc-router.js +54 -0
  35. package/scripts/hooks/intent-engine.js +72 -0
  36. package/scripts/hooks/intent-file-transfer.js +51 -0
  37. package/scripts/hooks/intent-memory-recall.js +35 -0
  38. package/scripts/hooks/intent-ops-assist.js +54 -0
  39. package/scripts/hooks/intent-task-create.js +35 -0
  40. package/scripts/hooks/intent-team-dispatch.js +106 -0
  41. package/scripts/hooks/team-context.js +143 -0
  42. package/scripts/intent-registry.js +59 -0
  43. package/scripts/memory-extract.js +59 -0
  44. package/scripts/memory-nightly-reflect.js +109 -43
  45. package/scripts/memory.js +55 -17
  46. package/scripts/mentor-engine.js +6 -0
  47. package/scripts/schema.js +1 -0
  48. package/scripts/self-reflect.js +110 -12
  49. package/scripts/session-analytics.js +160 -0
  50. package/scripts/signal-capture.js +1 -1
  51. package/scripts/team-dispatch.js +315 -0
@@ -2,9 +2,37 @@
2
2
 
3
3
  const { classifyChatUsage } = require('./usage-classifier');
4
4
  const { deriveProjectInfo } = require('./utils');
5
- const { createEngineRuntimeFactory, normalizeEngineName, resolveEngineModel, ENGINE_MODEL_CONFIG } = require('./daemon-engine-runtime');
5
+ const {
6
+ createEngineRuntimeFactory,
7
+ normalizeEngineName,
8
+ resolveEngineModel,
9
+ ENGINE_MODEL_CONFIG,
10
+ _private: { resolveCodexPermissionProfile },
11
+ } = require('./daemon-engine-runtime');
12
+ const { buildIntentHintBlock } = require('./intent-registry');
6
13
  const { buildAgentContextForEngine, buildMemorySnapshotContent, refreshMemorySnapshot } = require('./agent-layer');
7
14
 
15
+ /**
16
+ * Antigravity Raw Session Logging — Lossless Diary (L0)
17
+ * [PROTECTED] Append every user→AI turn to a daily markdown file.
18
+ * Isolated as a standalone function to prevent accidental deletion during edits.
19
+ */
20
+ function logRawSessionDiary(fs, path, HOME, { chatId, prompt, output, error, projectKey }) {
21
+ try {
22
+ const today = new Date().toISOString().slice(0, 10);
23
+ const ym = today.slice(0, 7); // YYYY-MM
24
+ const sessDir = path.join(HOME, '.metame', 'sessions', ym);
25
+ if (!fs.existsSync(sessDir)) fs.mkdirSync(sessDir, { recursive: true });
26
+
27
+ const diaryPath = path.join(sessDir, `${today}_${chatId}.md`);
28
+ const MAX_OUTPUT_LOG = 8000;
29
+ const outputLog = (output || error || 'No output.').slice(0, MAX_OUTPUT_LOG);
30
+ const outputTruncated = (output || '').length > MAX_OUTPUT_LOG ? '\n\n[truncated]' : '';
31
+ const entry = `\n---\ndate: ${new Date().toISOString()}\nproject: ${projectKey || 'global'}\n---\n\n## 🙋‍♂️ 用户指令\n\`\`\`text\n${prompt}\n\`\`\`\n\n## 🤖 执行实录\n${outputLog}${outputTruncated}\n`;
32
+ fs.appendFileSync(diaryPath, entry, 'utf8');
33
+ } catch (e) { console.warn(`[MetaMe] Raw session logging failed: ${e.message}`); }
34
+ }
35
+
8
36
  function createClaudeEngine(deps) {
9
37
  const {
10
38
  fs,
@@ -16,7 +44,6 @@ function createClaudeEngine(deps) {
16
44
  getActiveProviderEnv,
17
45
  activeProcesses,
18
46
  saveActivePids,
19
- messageQueue,
20
47
  log,
21
48
  yaml,
22
49
  providerMod,
@@ -31,7 +58,6 @@ function createClaudeEngine(deps) {
31
58
  isContentFile,
32
59
  sendFileButtons,
33
60
  findSessionFile,
34
- listRecentSessions,
35
61
  getSession,
36
62
  getSessionForEngine,
37
63
  createSession,
@@ -39,6 +65,9 @@ function createClaudeEngine(deps) {
39
65
  writeSessionName,
40
66
  markSessionStarted,
41
67
  isEngineSessionValid,
68
+ getCodexSessionSandboxProfile,
69
+ getCodexSessionPermissionMode,
70
+ getSessionRecentContext,
42
71
  gitCheckpoint,
43
72
  gitCheckpointAsync,
44
73
  recordTokens,
@@ -52,11 +81,42 @@ function createClaudeEngine(deps) {
52
81
  function getDefaultEngine() {
53
82
  return (typeof _getDefaultEngine === 'function') ? _getDefaultEngine() : 'claude';
54
83
  }
84
+ function resolveSessionForEngine(chatId, engineName) {
85
+ if (typeof getSessionForEngine === 'function') {
86
+ return getSessionForEngine(chatId, engineName);
87
+ }
88
+ const legacy = typeof getSession === 'function' ? getSession(chatId) : null;
89
+ if (!legacy) return null;
90
+ if (!legacy.engines) return legacy;
91
+ const slot = legacy.engines[engineName] || null;
92
+ if (!slot) return null;
93
+ return {
94
+ ...legacy,
95
+ ...slot,
96
+ cwd: legacy.cwd || HOME,
97
+ engine: engineName,
98
+ };
99
+ }
100
+ function validateEngineSession(engineName, sessionId, cwd) {
101
+ if (typeof isEngineSessionValid === 'function') {
102
+ return isEngineSessionValid(engineName, sessionId, cwd);
103
+ }
104
+ return true;
105
+ }
55
106
  let mentorEngine = null;
56
107
  try { mentorEngine = require('./mentor-engine'); } catch { /* optional */ }
57
108
  let sessionAnalytics = null;
58
109
  try { sessionAnalytics = require('./session-analytics'); } catch { /* optional */ }
59
110
 
111
+ function shouldAutoRouteSkill({ agentMatch, hasActiveSession, boundProjectKey, skillName }) {
112
+ if (agentMatch || hasActiveSession) return false;
113
+ if (
114
+ String(boundProjectKey || '').trim() === 'personal'
115
+ && String(skillName || '').trim() === 'macos-local-orchestrator'
116
+ ) return false;
117
+ return true;
118
+ }
119
+
60
120
  const getEngineRuntime = typeof injectedGetEngineRuntime === 'function'
61
121
  ? injectedGetEngineRuntime
62
122
  : createEngineRuntimeFactory({ fs, path, HOME, CLAUDE_BIN, getActiveProviderEnv });
@@ -126,7 +186,11 @@ function createClaudeEngine(deps) {
126
186
  if (!state.sessions) state.sessions = {};
127
187
  const cur = state.sessions[chatId] || {};
128
188
  const patched = typeof patchFn === 'function' ? patchFn(cur) : cur;
129
- state.sessions[chatId] = patched && typeof patched === 'object' ? patched : cur;
189
+ if (patched && typeof patched === 'object') {
190
+ state.sessions[chatId] = { ...patched, last_active: Date.now() };
191
+ } else {
192
+ state.sessions[chatId] = cur;
193
+ }
130
194
  saveState(state);
131
195
  }).catch((e) => {
132
196
  log('WARN', `patchSessionSerialized failed for ${chatId}: ${e.message}`);
@@ -138,27 +202,35 @@ function createClaudeEngine(deps) {
138
202
  }
139
203
 
140
204
  const CODEX_RESUME_RETRY_WINDOW_MS = 10 * 60 * 1000;
141
- const _codexResumeRetryTs = new Map(); // chatId -> last retry ts
205
+ const CODEX_PERMISSION_STABILIZE_MAX_RETRIES = 2;
206
+ const _codexResumeRetryTs = new Map(); // `${chatId}:${kind}` -> last retry ts
142
207
 
143
- function canRetryCodexResume(chatId) {
144
- const key = String(chatId || '');
208
+ function getCodexResumeRetryKey(chatId, kind = 'default') {
209
+ const base = String(chatId || '').trim();
210
+ const mode = String(kind || 'default').trim();
211
+ return base && mode ? `${base}:${mode}` : '';
212
+ }
213
+
214
+ function canRetryCodexResume(chatId, kind = 'default') {
215
+ const key = getCodexResumeRetryKey(chatId, kind);
145
216
  if (!key) return false;
146
217
  const last = Number(_codexResumeRetryTs.get(key) || 0);
147
218
  if (!last) return true;
148
219
  return (Date.now() - last) > CODEX_RESUME_RETRY_WINDOW_MS;
149
220
  }
150
221
 
151
- function markCodexResumeRetried(chatId) {
152
- const key = String(chatId || '');
222
+ function markCodexResumeRetried(chatId, kind = 'default') {
223
+ const key = getCodexResumeRetryKey(chatId, kind);
153
224
  if (!key) return;
154
225
  _codexResumeRetryTs.set(key, Date.now());
155
226
  }
156
227
 
157
- function shouldRetryCodexResumeFallback({ runtimeName, wasResumeAttempt, output, error, errorCode, canRetry }) {
228
+ function shouldRetryCodexResumeFallback({ runtimeName, wasResumeAttempt, output, error, errorCode, canRetry, failureKind = '' }) {
158
229
  return runtimeName === 'codex'
159
230
  && !!wasResumeAttempt
160
231
  && !!error
161
232
  && (!output || !!errorCode)
233
+ && failureKind !== 'user-stop'
162
234
  && !!canRetry;
163
235
  }
164
236
 
@@ -184,6 +256,218 @@ function createClaudeEngine(deps) {
184
256
  return out;
185
257
  }
186
258
 
259
+ function getCodexPermissionProfile(readOnly, daemonCfg = {}, session = {}) {
260
+ return resolveCodexPermissionProfile({ readOnly, daemonCfg, session });
261
+ }
262
+
263
+ function getSessionChatId(chatId, boundProjectKey) {
264
+ const rawChatId = String(chatId || '');
265
+ if (rawChatId.startsWith('_agent_') || rawChatId.startsWith('_scope_')) return rawChatId;
266
+ if (boundProjectKey) return `_bound_${boundProjectKey}`;
267
+ return rawChatId || chatId;
268
+ }
269
+
270
+ function normalizeCodexSandboxMode(value, fallback = null) {
271
+ const text = String(value || '').trim().toLowerCase();
272
+ if (!text) return fallback;
273
+ if (text === 'read-only' || text === 'readonly') return 'read-only';
274
+ if (text === 'workspace-write' || text === 'workspace') return 'workspace-write';
275
+ if (
276
+ text === 'danger-full-access'
277
+ || text === 'dangerous'
278
+ || text === 'full-access'
279
+ || text === 'full'
280
+ || text === 'bypass'
281
+ || text === 'writable'
282
+ ) return 'danger-full-access';
283
+ return fallback;
284
+ }
285
+
286
+ function normalizeCodexApprovalPolicy(value, fallback = null) {
287
+ const text = String(value || '').trim().toLowerCase();
288
+ if (!text) return fallback;
289
+ if (text === 'never' || text === 'no' || text === 'none') return 'never';
290
+ if (text === 'on-failure' || text === 'on_failure' || text === 'failure') return 'on-failure';
291
+ if (text === 'on-request' || text === 'on_request' || text === 'request') return 'on-request';
292
+ if (text === 'untrusted') return 'untrusted';
293
+ return fallback;
294
+ }
295
+
296
+ function normalizeComparableCodexPermissionProfile(profile) {
297
+ if (!profile) return null;
298
+ const sandboxMode = normalizeCodexSandboxMode(
299
+ profile.sandboxMode || profile.permissionMode,
300
+ null
301
+ );
302
+ const approvalPolicy = normalizeCodexApprovalPolicy(
303
+ profile.approvalPolicy,
304
+ null
305
+ );
306
+ if (!sandboxMode && !approvalPolicy) return null;
307
+ return {
308
+ sandboxMode,
309
+ approvalPolicy,
310
+ permissionMode: sandboxMode,
311
+ };
312
+ }
313
+
314
+ function normalizeSenderId(senderId) {
315
+ const text = String(senderId || '').trim();
316
+ return text || '';
317
+ }
318
+
319
+ function sameCodexPermissionProfile(left, right) {
320
+ const normalizedLeft = normalizeComparableCodexPermissionProfile(left);
321
+ const normalizedRight = normalizeComparableCodexPermissionProfile(right);
322
+ if (!normalizedLeft || !normalizedRight) return false;
323
+ const sameSandbox = normalizedLeft.sandboxMode === normalizedRight.sandboxMode;
324
+ const leftApproval = String(normalizedLeft.approvalPolicy || '').trim();
325
+ const rightApproval = String(normalizedRight.approvalPolicy || '').trim();
326
+ if (!leftApproval || !rightApproval) return sameSandbox;
327
+ return sameSandbox && leftApproval === rightApproval;
328
+ }
329
+
330
+ function codexSandboxPrivilegeRank(value) {
331
+ const normalized = normalizeCodexSandboxMode(value, null);
332
+ if (normalized === 'read-only') return 0;
333
+ if (normalized === 'workspace-write') return 1;
334
+ if (normalized === 'danger-full-access') return 2;
335
+ return -1;
336
+ }
337
+
338
+ function codexApprovalPrivilegeRank(value) {
339
+ const normalized = normalizeCodexApprovalPolicy(value, null);
340
+ if (normalized === 'untrusted') return 0;
341
+ if (normalized === 'on-request') return 1;
342
+ if (normalized === 'on-failure') return 2;
343
+ if (normalized === 'never') return 3;
344
+ return -1;
345
+ }
346
+
347
+ function codexNeedsFallbackForRequestedPermissions(actualProfile, requestedProfile) {
348
+ const normalizedActual = normalizeComparableCodexPermissionProfile(actualProfile);
349
+ const normalizedRequested = normalizeComparableCodexPermissionProfile(requestedProfile);
350
+ if (!normalizedActual || !normalizedRequested) return false;
351
+ return (
352
+ codexSandboxPrivilegeRank(normalizedActual.sandboxMode) < codexSandboxPrivilegeRank(normalizedRequested.sandboxMode)
353
+ || codexApprovalPrivilegeRank(normalizedActual.approvalPolicy) < codexApprovalPrivilegeRank(normalizedRequested.approvalPolicy)
354
+ );
355
+ }
356
+
357
+ function buildCodexFallbackBridgePrompt({ fullPrompt, previousSessionId, previousProfile, requestedProfile, recentContext }) {
358
+ const bridge = [];
359
+ bridge.push('[Note: continuing the same MetaMe persona conversation on a fresh Codex execution thread because the previous thread could not satisfy the newly requested permission profile.]');
360
+ if (previousSessionId) {
361
+ bridge.push(`Previous Codex thread: ${String(previousSessionId).slice(0, 8)}`);
362
+ }
363
+ if (previousProfile || requestedProfile) {
364
+ const previousSummary = previousProfile
365
+ ? `${previousProfile.sandboxMode || previousProfile.permissionMode || 'unknown'}/${previousProfile.approvalPolicy || 'unknown'}`
366
+ : 'unknown/unknown';
367
+ const requestedSummary = requestedProfile
368
+ ? `${requestedProfile.sandboxMode || requestedProfile.permissionMode || 'unknown'}/${requestedProfile.approvalPolicy || 'unknown'}`
369
+ : 'unknown/unknown';
370
+ bridge.push(`Permission migration: ${previousSummary} -> ${requestedSummary}`);
371
+ }
372
+ if (recentContext && (recentContext.lastUser || recentContext.lastAssistant)) {
373
+ bridge.push('Recent conversation context:');
374
+ if (recentContext.lastUser) bridge.push(`Last user message: ${String(recentContext.lastUser).trim()}`);
375
+ if (recentContext.lastAssistant) bridge.push(`Last assistant reply: ${String(recentContext.lastAssistant).trim()}`);
376
+ }
377
+ bridge.push('Continue as the same conversation. Do not mention any internal thread migration unless the user explicitly asks.');
378
+ return `${bridge.join('\n')}\n\n[Current user message follows:]\n\n${fullPrompt}`;
379
+ }
380
+
381
+ function getActualCodexPermissionProfile(session) {
382
+ if (!session || !session.id) return null;
383
+ if (typeof getCodexSessionSandboxProfile === 'function') {
384
+ return getCodexSessionSandboxProfile(session.id);
385
+ }
386
+ if (typeof getCodexSessionPermissionMode === 'function') {
387
+ const permissionMode = getCodexSessionPermissionMode(session.id);
388
+ return permissionMode ? { sandboxMode: permissionMode, approvalPolicy: null, permissionMode } : null;
389
+ }
390
+ return null;
391
+ }
392
+
393
+ function inspectClaudeResumeSession(session) {
394
+ const result = {
395
+ shouldResume: true,
396
+ modelPin: null,
397
+ reason: '',
398
+ };
399
+ if (!session || !session.started || !session.id) return result;
400
+ try {
401
+ const sessionFile = findSessionFile && findSessionFile(session.id);
402
+ if (!sessionFile) return result;
403
+ const lines = fs.readFileSync(sessionFile, 'utf8').split('\n').filter(Boolean);
404
+ for (const line of lines.slice(0, 30)) {
405
+ const entry = JSON.parse(line);
406
+ const sessionModel = entry && entry.message && entry.message.model;
407
+ if (!sessionModel || sessionModel === '<synthetic>') continue;
408
+ if (!sessionModel.startsWith('claude-')) {
409
+ return {
410
+ shouldResume: false,
411
+ modelPin: null,
412
+ reason: 'non-claude-session',
413
+ };
414
+ }
415
+ return {
416
+ shouldResume: true,
417
+ modelPin: sessionModel,
418
+ reason: '',
419
+ };
420
+ }
421
+ } catch {
422
+ return result;
423
+ }
424
+ return result;
425
+ }
426
+
427
+ function isClaudeThinkingSignatureError(errMsg) {
428
+ const msg = String(errMsg || '');
429
+ return msg.includes('Invalid signature') || msg.includes('thinking block');
430
+ }
431
+
432
+ function formatClaudeResumeFallbackUserMessage(retryError) {
433
+ if (retryError) {
434
+ return '⚠️ 旧 session 无法继续,已自动切换到新 session,但本次请求仍失败。';
435
+ }
436
+ return '';
437
+ }
438
+
439
+ function classifyCodexResumeFailure(error, errorCode) {
440
+ const message = String(error || '').trim();
441
+ const code = String(errorCode || '').trim();
442
+ const lowered = message.toLowerCase();
443
+ if (code === 'INTERRUPTED_USER') {
444
+ return {
445
+ kind: 'user-stop',
446
+ userMessage: '⚠️ 当前执行已按你的停止动作中断,本轮不会自动续跑。',
447
+ retryPromptPrefix: '',
448
+ };
449
+ }
450
+ const interrupted = (
451
+ lowered.includes('stopped by user')
452
+ || lowered.includes('interrupted')
453
+ || lowered.includes('signal')
454
+ || code === 'INTERRUPTED'
455
+ || code === 'INTERRUPTED_RESTART'
456
+ );
457
+ if (interrupted) {
458
+ return {
459
+ kind: 'interrupted',
460
+ userMessage: '⚠️ 后台刚刚重启或本轮执行被中断。系统正在自动恢复到同一条会话,请稍等。',
461
+ retryPromptPrefix: '[Note: the previous Codex execution was interrupted by a daemon restart or user stop signal. Continue the same conversation if possible. User message follows:]',
462
+ };
463
+ }
464
+ return {
465
+ kind: 'expired',
466
+ userMessage: '⚠️ Codex session 已过期,上下文可能丢失。正在以全新 session 重试,请在回复后补充必要背景。',
467
+ retryPromptPrefix: '[Note: previous Codex session expired and could not be resumed. Treating this as a new session. User message follows:]',
468
+ };
469
+ }
470
+
187
471
 
188
472
  /**
189
473
  * Parse [[FILE:...]] markers from Claude output.
@@ -476,6 +760,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
476
760
  timeoutMs = 600000,
477
761
  chatId = null,
478
762
  metameProject = '',
763
+ metameSenderId = '',
479
764
  runtime = null,
480
765
  onSession = null,
481
766
  ) {
@@ -495,7 +780,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
495
780
  cwd,
496
781
  stdio: ['pipe', 'pipe', 'pipe'],
497
782
  detached: process.platform !== 'win32',
498
- env: rt.buildEnv({ metameProject }),
783
+ env: rt.buildEnv({ metameProject, metameSenderId }),
499
784
  });
500
785
  log('INFO', `[TIMING:${chatId}] spawned ${rt.name} pid=${child.pid}`);
501
786
 
@@ -503,6 +788,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
503
788
  activeProcesses.set(chatId, {
504
789
  child,
505
790
  aborted: false,
791
+ abortReason: null,
506
792
  startedAt: _spawnAt,
507
793
  engine: rt.name,
508
794
  killSignal: rt.killSignal || 'SIGTERM',
@@ -530,11 +816,12 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
530
816
  const now = Date.now();
531
817
  if (!force && now - _lastStreamFlush < STREAM_THROTTLE) return;
532
818
  _lastStreamFlush = now;
533
- onStatus('__STREAM_TEXT__' + _streamText).catch(() => {});
819
+ onStatus('__STREAM_TEXT__' + _streamText).catch(() => { });
534
820
  }
535
821
  const writtenFiles = [];
536
822
  const toolUsageLog = [];
537
823
 
824
+ void timeoutMs;
538
825
  const engineTimeouts = rt.timeouts || {};
539
826
  const IDLE_TIMEOUT_MS = engineTimeouts.idleMs || (5 * 60 * 1000);
540
827
  const TOOL_EXEC_TIMEOUT_MS = engineTimeouts.toolMs || (25 * 60 * 1000);
@@ -583,7 +870,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
583
870
  if (onStatus) {
584
871
  const milestoneMsg = parts.join(' | ');
585
872
  const msg = _streamText ? `__TOOL_OVERLAY__${_streamText}\n\n> ${milestoneMsg}` : milestoneMsg;
586
- onStatus(msg).catch(() => {});
873
+ onStatus(msg).catch(() => { });
587
874
  }
588
875
  }
589
876
  }, 30000);
@@ -721,7 +1008,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
721
1008
  if (onStatus) {
722
1009
  // Overlay tool status on top of streamed text (if any); else show plain status
723
1010
  const msg = _streamText ? `__TOOL_OVERLAY__${_streamText}\n\n> ${status}` : status;
724
- onStatus(msg).catch(() => {});
1011
+ onStatus(msg).catch(() => { });
725
1012
  }
726
1013
  }
727
1014
  }
@@ -755,10 +1042,21 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
755
1042
 
756
1043
  const proc = chatId ? activeProcesses.get(chatId) : null;
757
1044
  const wasAborted = proc && proc.aborted;
1045
+ const abortReason = proc && proc.abortReason ? String(proc.abortReason) : '';
758
1046
  if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
759
1047
 
760
1048
  if (wasAborted) {
761
- finalize({ output: finalResult || null, error: 'Stopped by user', files: writtenFiles, toolUsageLog, usage: finalUsage, sessionId: observedSessionId || '' });
1049
+ finalize({
1050
+ output: finalResult || null,
1051
+ error: 'Stopped by user',
1052
+ errorCode: (abortReason === 'daemon-restart' || abortReason === 'shutdown')
1053
+ ? 'INTERRUPTED_RESTART'
1054
+ : 'INTERRUPTED_USER',
1055
+ files: writtenFiles,
1056
+ toolUsageLog,
1057
+ usage: finalUsage,
1058
+ sessionId: observedSessionId || '',
1059
+ });
762
1060
  return;
763
1061
  }
764
1062
  if (killed) {
@@ -812,11 +1110,22 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
812
1110
 
813
1111
  // Track outbound message_id → session for reply-based session restoration.
814
1112
  // Keeps last 200 entries to avoid unbounded growth.
815
- function trackMsgSession(messageId, session, agentKey) {
816
- if (!messageId || !session || !session.id) return;
1113
+ function trackMsgSession(messageId, session, agentKey, options = {}) {
1114
+ if (!messageId || !session) return;
1115
+ const forceRouteOnly = !!(options && options.routeOnly);
1116
+ if (!forceRouteOnly && !session.id) return;
817
1117
  const st = loadState();
818
1118
  if (!st.msg_sessions) st.msg_sessions = {};
819
- st.msg_sessions[messageId] = { id: session.id, cwd: session.cwd, engine: session.engine || getDefaultEngine(), agentKey: agentKey || null };
1119
+ st.msg_sessions[messageId] = {
1120
+ ...(session.id && !forceRouteOnly ? { id: session.id } : {}),
1121
+ ...(session.cwd ? { cwd: session.cwd } : {}),
1122
+ engine: session.engine || getDefaultEngine(),
1123
+ logicalChatId: session.logicalChatId || null,
1124
+ agentKey: agentKey || null,
1125
+ ...(session.sandboxMode ? { sandboxMode: session.sandboxMode } : {}),
1126
+ ...(session.approvalPolicy ? { approvalPolicy: session.approvalPolicy } : {}),
1127
+ ...(session.permissionMode ? { permissionMode: session.permissionMode } : {}),
1128
+ };
820
1129
  const keys = Object.keys(st.msg_sessions);
821
1130
  if (keys.length > 200) {
822
1131
  for (const k of keys.slice(0, keys.length - 200)) delete st.msg_sessions[k];
@@ -845,7 +1154,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
845
1154
  return loadConfig();
846
1155
  }
847
1156
 
848
- async function askClaude(bot, chatId, prompt, config, readOnly = false) {
1157
+ async function askClaude(bot, chatId, prompt, config, readOnly = false, senderId = null) {
849
1158
  const _t0 = Date.now();
850
1159
  log('INFO', `askClaude for ${chatId}: ${prompt.slice(0, 50)}`);
851
1160
  // Track interaction time for idle/sleep detection
@@ -864,7 +1173,11 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
864
1173
  let _lastStatusCardContent = null; // tracks last clean text written to card (for final-reply dedup)
865
1174
  // Early detect bound project for branded ack card (team members / dispatch agents)
866
1175
  const _ackChatIdStr = String(chatId);
867
- const _ackAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map || {} : {}), ...(config.feishu ? config.feishu.chat_agent_map || {} : {}) };
1176
+ const _ackAgentMap = {
1177
+ ...(config.telegram ? config.telegram.chat_agent_map || {} : {}),
1178
+ ...(config.feishu ? config.feishu.chat_agent_map || {} : {}),
1179
+ ...(config.imessage ? config.imessage.chat_agent_map || {} : {}),
1180
+ };
868
1181
  const _ackBoundKey = _ackAgentMap[_ackChatIdStr] || projectKeyFromVirtualChatId(_ackChatIdStr);
869
1182
  const _ackBoundProj = _ackBoundKey && config.projects ? config.projects[_ackBoundKey] : null;
870
1183
  // _ackCardHeader: non-null for agents with icon/name (team members, dispatch); passed to editMessage to preserve header on streaming edits
@@ -874,12 +1187,14 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
874
1187
  // Fire-and-forget: don't await Telegram RTT before spawning the engine process.
875
1188
  // statusMsgId will be populated well before the first model output (~5s for codex).
876
1189
  // For branded agents: send a card with header so streaming edits preserve the agent identity.
877
- const _ackFn = (_ackCardHeader && bot.sendCard)
878
- ? () => bot.sendCard(chatId, { title: _ackCardHeader.title, body: '🤔', color: _ackCardHeader.color })
879
- : () => (bot.sendMarkdown ? bot.sendMarkdown(chatId, '🤔') : bot.sendMessage(chatId, '🤔'));
880
- _ackFn()
881
- .then(msg => { if (msg && msg.message_id) statusMsgId = msg.message_id; })
882
- .catch(e => log('ERROR', `Failed to send ack to ${chatId}: ${e.message}`));
1190
+ if (!bot.suppressAck) {
1191
+ const _ackFn = (_ackCardHeader && bot.sendCard)
1192
+ ? () => bot.sendCard(chatId, { title: _ackCardHeader.title, body: '🤔', color: _ackCardHeader.color })
1193
+ : () => (bot.sendMarkdown ? bot.sendMarkdown(chatId, '🤔') : bot.sendMessage(chatId, '🤔'));
1194
+ _ackFn()
1195
+ .then(msg => { if (msg && msg.message_id) statusMsgId = msg.message_id; })
1196
+ .catch(e => log('ERROR', `Failed to send ack to ${chatId}: ${e.message}`));
1197
+ }
883
1198
  bot.sendTyping(chatId).catch(() => { });
884
1199
  const typingTimer = setInterval(() => {
885
1200
  bot.sendTyping(chatId).catch(() => { });
@@ -890,489 +1205,580 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
890
1205
  // kill the handler, leaving the typing indicator spinning forever.
891
1206
  try { // ── safety-net-start ──
892
1207
 
893
- // Agent nickname routing: "贾维斯" / "小美,帮我..." → switch project session
894
- // Strict chats (chat_agent_map bound groups) must NOT switch agents via nickname
895
- const _strictAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
896
- const _isStrictChatSession = !!(_strictAgentMap[String(chatId)] || projectKeyFromVirtualChatId(String(chatId)));
897
- const agentMatch = _isStrictChatSession ? null : routeAgent(prompt, config);
898
- if (agentMatch) {
899
- const { key, proj, rest } = agentMatch;
900
- const projCwd = normalizeCwd(proj.cwd);
901
- attachOrCreateSession(chatId, projCwd, proj.name || key, proj.engine ? normalizeEngineName(proj.engine) : getDefaultEngine());
902
- log('INFO', `Agent switch via nickname: ${key} (${projCwd})`);
903
- if (!rest) {
904
- // Pure nickname call — confirm switch and stop
905
- clearInterval(typingTimer);
906
- await bot.sendMessage(chatId, `${proj.icon || '🤖'} ${proj.name || key} 在线`);
907
- return { ok: true };
1208
+ // Agent nickname routing: "贾维斯" / "小美,帮我..." → switch project session
1209
+ // Strict chats (chat_agent_map bound groups) must NOT switch agents via nickname
1210
+ const _strictAgentMap = {
1211
+ ...(config.telegram ? config.telegram.chat_agent_map : {}),
1212
+ ...(config.feishu ? config.feishu.chat_agent_map : {}),
1213
+ ...(config.imessage ? config.imessage.chat_agent_map : {}),
1214
+ };
1215
+ const _isStrictChatSession = !!(_strictAgentMap[String(chatId)] || projectKeyFromVirtualChatId(String(chatId)));
1216
+ const agentMatch = _isStrictChatSession ? null : routeAgent(prompt, config);
1217
+ if (agentMatch) {
1218
+ const { key, proj, rest } = agentMatch;
1219
+ const projCwd = normalizeCwd(proj.cwd);
1220
+ attachOrCreateSession(chatId, projCwd, proj.name || key, proj.engine ? normalizeEngineName(proj.engine) : getDefaultEngine());
1221
+ log('INFO', `Agent switch via nickname: ${key} (${projCwd})`);
1222
+ if (!rest) {
1223
+ // Pure nickname call — confirm switch and stop
1224
+ clearInterval(typingTimer);
1225
+ await bot.sendMessage(chatId, `${proj.icon || '🤖'} ${proj.name || key} 在线`);
1226
+ return { ok: true };
1227
+ }
1228
+ // Nickname + content — strip nickname, continue with rest as prompt
1229
+ prompt = rest;
908
1230
  }
909
- // Nickname + content — strip nickname, continue with rest as prompt
910
- prompt = rest;
911
- }
912
1231
 
913
- // Skill routing: detect skill first, then decide session
914
- // BUT: skip skill routing if agent addressed by nickname OR chat already has an active session
915
- // (active conversation should never be hijacked by keyword-based skill matching)
916
- const chatIdStr = String(chatId);
917
- const chatAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
918
- const boundProjectKey = chatAgentMap[chatIdStr] || projectKeyFromVirtualChatId(chatIdStr);
919
- const boundProject = boundProjectKey && config.projects ? config.projects[boundProjectKey] : null;
920
- // Each virtual chatId (including clones) keeps its own isolated session.
921
- // Parallel tasks must not share JSONL files — concurrent writes cause corruption.
922
- const sessionChatId = boundProjectKey ? `_agent_${boundProjectKey}` : chatId;
923
- const sessionRaw = getSession(sessionChatId);
924
- const boundCwd = (boundProject && boundProject.cwd) ? normalizeCwd(boundProject.cwd) : null;
925
- const boundEngineName = (boundProject && boundProject.engine) ? normalizeEngineName(boundProject.engine) : getDefaultEngine();
926
-
927
- // Engine is determined from config only — bound agent config wins, then global default.
928
- const engineName = normalizeEngineName(
929
- (boundProject && boundProject.engine) || getDefaultEngine()
930
- );
931
- const runtime = getEngineRuntime(engineName);
932
-
933
- // hasActiveSession: does the current engine have an ongoing conversation?
934
- const hasActiveSession = sessionRaw && (
935
- sessionRaw.engines ? !!(sessionRaw.engines[engineName]?.started) : !!sessionRaw.started
936
- );
937
- const skill = (agentMatch || hasActiveSession) ? null : routeSkill(prompt);
938
-
939
- if (!sessionRaw) {
940
- // No saved state for this chatId: start a fresh session.
941
- // Note: daemon_state.json persists across restarts, so this only happens on truly first use
942
- // or after an explicit /new command.
943
- createSession(sessionChatId, boundCwd || undefined, boundProject && boundProject.name ? boundProject.name : '', boundEngineName);
944
- }
1232
+ // Skill routing: detect skill first, then decide session
1233
+ // BUT: skip skill routing if agent addressed by nickname OR chat already has an active session
1234
+ // (active conversation should never be hijacked by keyword-based skill matching)
1235
+ const chatIdStr = String(chatId);
1236
+ const chatAgentMap = {
1237
+ ...(config.telegram ? config.telegram.chat_agent_map : {}),
1238
+ ...(config.feishu ? config.feishu.chat_agent_map : {}),
1239
+ ...(config.imessage ? config.imessage.chat_agent_map : {}),
1240
+ };
1241
+ const boundProjectKey = chatAgentMap[chatIdStr] || projectKeyFromVirtualChatId(chatIdStr);
1242
+ const boundProject = boundProjectKey && config.projects ? config.projects[boundProjectKey] : null;
1243
+ const daemonCfg = (config && config.daemon) || {};
1244
+ // Keep real group chats on their own session key.
1245
+ // Only true virtual agents (_agent_*) should use the virtual namespace.
1246
+ const sessionChatId = getSessionChatId(chatId, boundProjectKey);
1247
+ const sessionRaw = getSession(sessionChatId);
1248
+ const boundCwd = (boundProject && boundProject.cwd) ? normalizeCwd(boundProject.cwd) : null;
1249
+ const boundEngineName = (boundProject && boundProject.engine) ? normalizeEngineName(boundProject.engine) : getDefaultEngine();
1250
+
1251
+ // Engine is determined from config only — bound agent config wins, then global default.
1252
+ const engineName = normalizeEngineName(
1253
+ (boundProject && boundProject.engine) || getDefaultEngine()
1254
+ );
1255
+ const runtime = getEngineRuntime(engineName);
1256
+ const requestedCodexPermissionProfile = engineName === 'codex'
1257
+ ? getCodexPermissionProfile(readOnly, daemonCfg)
1258
+ : null;
1259
+
1260
+ // hasActiveSession: does the current engine have an ongoing conversation?
1261
+ const hasActiveSession = sessionRaw && (
1262
+ sessionRaw.engines ? !!(sessionRaw.engines[engineName]?.started) : !!sessionRaw.started
1263
+ );
1264
+ const detectedSkill = routeSkill(prompt);
1265
+ const skill = shouldAutoRouteSkill({
1266
+ agentMatch,
1267
+ hasActiveSession,
1268
+ boundProjectKey,
1269
+ skillName: detectedSkill,
1270
+ })
1271
+ ? detectedSkill
1272
+ : null;
1273
+
1274
+ if (!sessionRaw) {
1275
+ // No saved state for this chatId: start a fresh session.
1276
+ // Note: daemon_state.json persists across restarts, so this only happens on truly first use
1277
+ // or after an explicit /new command.
1278
+ createSession(
1279
+ sessionChatId,
1280
+ boundCwd || undefined,
1281
+ boundProject && boundProject.name ? boundProject.name : '',
1282
+ boundEngineName,
1283
+ boundEngineName === 'codex' ? requestedCodexPermissionProfile : undefined
1284
+ );
1285
+ }
945
1286
 
946
- // Resolve flat view for current engine (id + started are engine-specific; cwd is shared)
947
- let session = getSessionForEngine(sessionChatId, engineName) || { cwd: boundCwd || HOME, engine: engineName, id: null, started: false };
948
- session.engine = engineName; // keep local copy for Codex resume detection below
949
-
950
- // Pre-spawn session validation: unified for all engines.
951
- // Claude checks JSONL file existence; Codex checks SQLite. Same interface, different backend.
952
- // Skip warning for virtual agents (team members) - they may use worktrees with fresh sessions
953
- const isVirtualAgent = String(sessionChatId).startsWith('_agent_');
954
- if (session.started && session.id && session.id !== '__continue__' && session.cwd) {
955
- const valid = isEngineSessionValid(engineName, session.id, session.cwd);
956
- if (!valid) {
957
- log('WARN', `${engineName} session ${session.id.slice(0, 8)} invalid for ${sessionChatId}; starting fresh ${engineName} session`);
958
- if (!isVirtualAgent) {
959
- await bot.sendMessage(chatId, '⚠️ 上次 session 已失效,已自动开启新 session。').catch(() => {});
1287
+ // Resolve flat view for current engine (id + started are engine-specific; cwd is shared)
1288
+ let session = resolveSessionForEngine(sessionChatId, engineName) || { cwd: boundCwd || HOME, engine: engineName, id: null, started: false };
1289
+ session.engine = engineName; // keep local copy for Codex resume detection below
1290
+ session.logicalChatId = sessionChatId;
1291
+
1292
+ // Pre-spawn session validation: unified for all engines.
1293
+ // Claude checks JSONL file existence; Codex checks SQLite. Same interface, different backend.
1294
+ // Skip warning for virtual agents (team members) - they may use worktrees with fresh sessions
1295
+ const isVirtualAgent = String(sessionChatId).startsWith('_agent_');
1296
+ if (session.started && session.id && session.id !== '__continue__' && session.cwd) {
1297
+ const valid = validateEngineSession(engineName, session.id, session.cwd);
1298
+ if (!valid) {
1299
+ log('WARN', `${engineName} session ${session.id.slice(0, 8)} invalid for ${sessionChatId}; starting fresh ${engineName} session`);
1300
+ if (!isVirtualAgent) {
1301
+ await bot.sendMessage(chatId, '⚠️ 上次 session 已失效,已自动开启新 session。').catch(() => { });
1302
+ }
1303
+ session = createSession(
1304
+ sessionChatId,
1305
+ session.cwd,
1306
+ boundProject && boundProject.name ? boundProject.name : '',
1307
+ engineName,
1308
+ engineName === 'codex' ? requestedCodexPermissionProfile : undefined
1309
+ );
960
1310
  }
961
- session = createSession(sessionChatId, session.cwd, boundProject && boundProject.name ? boundProject.name : '', engineName);
962
1311
  }
963
- }
964
1312
 
965
- const daemonCfg = (config && config.daemon) || {};
966
- const mentorCfg = (daemonCfg.mentor && typeof daemonCfg.mentor === 'object') ? daemonCfg.mentor : {};
967
- const mentorEnabled = !!(mentorEngine && mentorCfg.enabled);
968
- const excludeAgents = new Set(
969
- (Array.isArray(mentorCfg.exclude_agents) ? mentorCfg.exclude_agents : [])
970
- .map(x => String(x || '').trim())
971
- .filter(Boolean)
972
- );
973
- const chatAgentKey = boundProjectKey || 'personal';
974
- const mentorExcluded = excludeAgents.has(chatAgentKey);
975
- let mentorSuppressed = false;
1313
+ if (runtime.name === 'codex' && session.started && session.id) {
1314
+ const actualPermissionProfile = getActualCodexPermissionProfile(session);
1315
+ if (actualPermissionProfile) {
1316
+ const storedPermissionProfile = normalizeComparableCodexPermissionProfile(session);
1317
+ if (!sameCodexPermissionProfile(storedPermissionProfile, actualPermissionProfile)) {
1318
+ session = { ...session, ...actualPermissionProfile };
1319
+ await patchSessionSerialized(sessionChatId, (cur) => {
1320
+ const engines = { ...(cur.engines || {}) };
1321
+ engines.codex = {
1322
+ ...(engines.codex || {}),
1323
+ ...(actualPermissionProfile || {}),
1324
+ };
1325
+ return { ...cur, engines };
1326
+ });
1327
+ }
1328
+ if (!sameCodexPermissionProfile(actualPermissionProfile, requestedCodexPermissionProfile)) {
1329
+ const actualSummary = `${actualPermissionProfile.sandboxMode || actualPermissionProfile.permissionMode || 'unknown'}/${actualPermissionProfile.approvalPolicy || 'unknown'}`;
1330
+ const requestedSummary = `${requestedCodexPermissionProfile.sandboxMode}/${requestedCodexPermissionProfile.approvalPolicy}`;
1331
+ log('INFO', `Codex session ${session.id.slice(0, 8)} permission differs for ${sessionChatId}: ${actualSummary} vs requested ${requestedSummary}; preserving existing session continuity`);
1332
+ }
1333
+ }
1334
+ }
1335
+ const mentorCfg = (daemonCfg.mentor && typeof daemonCfg.mentor === 'object') ? daemonCfg.mentor : {};
1336
+ const mentorEnabled = !!(mentorEngine && mentorCfg.enabled);
1337
+ const excludeAgents = new Set(
1338
+ (Array.isArray(mentorCfg.exclude_agents) ? mentorCfg.exclude_agents : [])
1339
+ .map(x => String(x || '').trim())
1340
+ .filter(Boolean)
1341
+ );
1342
+ const chatAgentKey = boundProjectKey || 'personal';
1343
+ const mentorExcluded = excludeAgents.has(chatAgentKey);
1344
+ let mentorSuppressed = false;
976
1345
 
977
- // Mentor pre-flight breaker: first hit sends a short reassurance; cooldown does not block normal answers.
978
- if (mentorEnabled && !mentorExcluded) {
979
- try {
980
- const breaker = mentorEngine.checkEmotionBreaker(prompt, mentorCfg);
981
- if (breaker && breaker.tripped) {
982
- mentorSuppressed = true;
983
- if (breaker.reason !== 'cooldown_active' && breaker.response) {
984
- await bot.sendMessage(chatId, breaker.response).catch(() => { });
1346
+ // Mentor pre-flight breaker: first hit sends a short reassurance; cooldown does not block normal answers.
1347
+ if (mentorEnabled && !mentorExcluded) {
1348
+ try {
1349
+ const breaker = mentorEngine.checkEmotionBreaker(prompt, mentorCfg);
1350
+ if (breaker && breaker.tripped) {
1351
+ mentorSuppressed = true;
1352
+ if (breaker.reason !== 'cooldown_active' && breaker.response) {
1353
+ await bot.sendMessage(chatId, breaker.response).catch(() => { });
1354
+ }
985
1355
  }
1356
+ } catch (e) {
1357
+ log('WARN', `Mentor breaker failed: ${e.message}`);
986
1358
  }
987
- } catch (e) {
988
- log('WARN', `Mentor breaker failed: ${e.message}`);
989
1359
  }
990
- }
991
1360
 
992
- // Build engine command — prefer per-engine model, fall back to legacy daemon.model
993
- const model = resolveEngineModel(runtime.name, daemonCfg, boundProject && boundProject.model);
994
- const args = runtime.buildArgs({
995
- model,
996
- readOnly,
997
- daemonCfg,
998
- session,
999
- cwd: session.cwd,
1000
- });
1361
+ // Build engine command — prefer per-engine model, fall back to legacy daemon.model
1362
+ let model = resolveEngineModel(runtime.name, daemonCfg, boundProject && boundProject.model);
1363
+
1364
+ // When resuming a Claude session, inspect the original model first.
1365
+ // Thinking block signatures are model-specific; non-Claude JSONL sessions
1366
+ // must not be resumed as Claude.
1367
+ if (runtime.name === 'claude' && session.started && session.id) {
1368
+ const resumeInspection = inspectClaudeResumeSession(session);
1369
+ if (resumeInspection.shouldResume === false) {
1370
+ log('INFO', `[ModelPin] session ${session.id.slice(0, 8)} flagged as ${resumeInspection.reason}; starting fresh Claude session`);
1371
+ session = createSession(sessionChatId, session.cwd, boundProject && boundProject.name ? boundProject.name : '', runtime.name);
1372
+ } else if (resumeInspection.modelPin) {
1373
+ if (resumeInspection.modelPin !== model) {
1374
+ log('INFO', `[ModelPin] resuming ${session.id.slice(0, 8)} with original model ${resumeInspection.modelPin} (configured: ${model})`);
1375
+ }
1376
+ model = resumeInspection.modelPin;
1377
+ }
1378
+ }
1001
1379
 
1002
- // Codex: write/refresh AGENTS.md = CLAUDE.md + SOUL.md on every new session.
1003
- // Written as a real file (not a symlink) for Windows compatibility.
1004
- // Refreshed each session so edits to CLAUDE.md or SOUL.md are always picked up.
1005
- // Codex auto-loads AGENTS.md from cwd and all parent dirs up to ~.
1006
- if (engineName === 'codex' && session.cwd && !session.started) {
1007
- try {
1008
- const parts = [];
1009
- const claudeMd = path.join(session.cwd, 'CLAUDE.md');
1010
- const soulMd = path.join(session.cwd, 'SOUL.md');
1011
- if (fs.existsSync(claudeMd)) parts.push(fs.readFileSync(claudeMd, 'utf8').trim());
1012
- if (fs.existsSync(soulMd)) {
1013
- const soulContent = fs.readFileSync(soulMd, 'utf8').trim();
1014
- if (soulContent) parts.push(soulContent);
1380
+ let agentHint = '';
1381
+ if (!session.started && (boundProject || (session && session.cwd))) {
1382
+ try {
1383
+ // Engine-aware: Codex gets memory only (soul is already in AGENTS.md);
1384
+ // Claude gets soul + memory (SOUL.md is not auto-loaded by Claude).
1385
+ agentHint = buildAgentContextForEngine(
1386
+ boundProject || { cwd: session.cwd },
1387
+ engineName,
1388
+ HOME,
1389
+ ).hint || '';
1390
+ } catch (e) {
1391
+ log('WARN', `Agent context injection failed: ${e.message}`);
1015
1392
  }
1016
- if (parts.length > 0) {
1017
- fs.writeFileSync(path.join(session.cwd, 'AGENTS.md'), parts.join('\n\n'), 'utf8');
1018
- log('INFO', `Refreshed AGENTS.md (${parts.length} section(s)) in ${session.cwd}`);
1393
+ }
1394
+
1395
+ // Memory & Knowledge Injection (RAG)
1396
+ let memoryHint = '';
1397
+
1398
+ // Compact context injection: injected once on first message after /compact, then cleared
1399
+ if (!session.started && session.compactContext) {
1400
+ const _compactCtx = String(session.compactContext).trim();
1401
+ if (_compactCtx) {
1402
+ memoryHint += `\n\n[Context from previous session (compacted):\n${_compactCtx}]`;
1403
+ try {
1404
+ const _stC = loadState();
1405
+ const _engSlot = _stC.sessions && _stC.sessions[sessionChatId] && _stC.sessions[sessionChatId].engines
1406
+ ? _stC.sessions[sessionChatId].engines[engineName]
1407
+ : null;
1408
+ if (_engSlot) { delete _engSlot.compactContext; saveState(_stC); }
1409
+ } catch { /* non-critical */ }
1019
1410
  }
1020
- } catch (e) {
1021
- log('WARN', `AGENTS.md refresh failed: ${e.message}`);
1022
1411
  }
1023
- }
1024
1412
 
1025
- let agentHint = '';
1026
- if (!session.started && (boundProject || (session && session.cwd))) {
1413
+ // projectKey must be declared outside the try block so the daemonHint template below can reference it.
1414
+ const _cid0 = String(chatId);
1415
+ const _agentMap0 = {
1416
+ ...(config.telegram ? config.telegram.chat_agent_map : {}),
1417
+ ...(config.feishu ? config.feishu.chat_agent_map : {}),
1418
+ ...(config.imessage ? config.imessage.chat_agent_map : {}),
1419
+ };
1420
+ const projectKey = _agentMap0[_cid0] || projectKeyFromVirtualChatId(_cid0);
1027
1421
  try {
1028
- // Engine-aware: Codex gets memory only (soul is already in AGENTS.md);
1029
- // Claude gets soul + memory (SOUL.md is not auto-loaded by Claude).
1030
- agentHint = buildAgentContextForEngine(
1031
- boundProject || { cwd: session.cwd },
1032
- engineName,
1033
- HOME,
1034
- ).hint || '';
1422
+ const memory = require('./memory');
1423
+
1424
+ // L1: NOW.md per-agent whiteboard injection(按 projectKey 隔离,防并发冲突)
1425
+ if (!session.started) {
1426
+ try {
1427
+ const nowDir = path.join(HOME, '.metame', 'memory', 'now');
1428
+ const nowKey = projectKey || 'default';
1429
+ const nowPath = path.join(nowDir, `${nowKey}.md`);
1430
+ if (fs.existsSync(nowPath)) {
1431
+ const nowContent = fs.readFileSync(nowPath, 'utf8').trim();
1432
+ if (nowContent) {
1433
+ memoryHint += `\n\n[Current task context:\n${nowContent}]`;
1434
+ }
1435
+ }
1436
+ } catch { /* non-critical */ }
1437
+ }
1438
+
1439
+ // 1. Inject recent session memories ONLY on first message of a session
1440
+ if (!session.started) {
1441
+ const recent = memory.recentSessions({ limit: 1, project: projectKey || undefined });
1442
+ if (recent.length > 0) {
1443
+ const items = recent.map(r => `- [${r.created_at}] ${r.summary}${r.keywords ? ' (keywords: ' + r.keywords + ')' : ''}`).join('\n');
1444
+ memoryHint += `\n\n[Past session memory:\n${items}]`;
1445
+ }
1446
+ }
1447
+
1448
+ // 2. Dynamic Fact Injection (RAG) — first message only
1449
+ // Facts stay in Claude's context for the rest of the session; no need to repeat.
1450
+ // Uses QMD hybrid search if available, falls back to FTS5.
1451
+ if (!session.started) {
1452
+ const searchFn = memory.searchFactsAsync || memory.searchFacts;
1453
+ const factQuery = buildFactSearchQuery(prompt, projectKey);
1454
+ const facts = await Promise.resolve(searchFn(factQuery, { limit: 3, project: projectKey || undefined }));
1455
+ if (facts.length > 0) {
1456
+ // Separate capsule facts from regular facts
1457
+ const capsuleFacts = facts.filter(f => f.relation === 'knowledge_capsule');
1458
+ const regularFacts = facts.filter(f => f.relation !== 'knowledge_capsule');
1459
+
1460
+ // Inject regular facts as before
1461
+ if (regularFacts.length > 0) {
1462
+ const factItems = regularFacts.map(f => `- [${f.relation}] ${f.value}`).join('\n');
1463
+ memoryHint += `\n\n[Relevant facts:\n${factItems}]`;
1464
+ }
1465
+
1466
+ // Capsule facts: derive file path from entity and inject as direct "must read" hint
1467
+ // Entity pattern: capsule.metame_daemon_dispatch → capsules/metame-daemon-dispatch-playbook.md
1468
+ if (capsuleFacts.length > 0) {
1469
+ const capsulePaths = capsuleFacts.map(f => {
1470
+ const slug = f.entity.replace(/^capsule\./, '').replace(/_/g, '-');
1471
+ return path.join(HOME, '.metame', 'memory', 'capsules', `${slug}-playbook.md`);
1472
+ }).filter(p => fs.existsSync(p));
1473
+ if (capsulePaths.length > 0) {
1474
+ // Inject file paths only (no shell commands) — works cross-platform and with all engines.
1475
+ // Claude Code reads via Read tool; Codex/Gemini parse the path directly.
1476
+ memoryHint += `\n\n[Relevant playbook detected — read before answering:\n${capsulePaths.map(p => ` ${p}`).join('\n')}]`;
1477
+ }
1478
+ }
1479
+
1480
+ log('INFO', `[MEMORY] Injected ${regularFacts.length} facts, ${capsuleFacts.length} capsule(s) (query_len=${factQuery.length})`);
1481
+ }
1482
+ }
1483
+
1484
+ memory.close();
1035
1485
  } catch (e) {
1036
- log('WARN', `Agent context injection failed: ${e.message}`);
1486
+ if (e.code !== 'MODULE_NOT_FOUND') log('WARN', `Memory injection failed: ${e.message}`);
1037
1487
  }
1038
- }
1039
1488
 
1040
- // Memory & Knowledge Injection (RAG)
1041
- let memoryHint = '';
1042
- // projectKey must be declared outside the try block so the daemonHint template below can reference it.
1043
- const _cid0 = String(chatId);
1044
- const _agentMap0 = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
1045
- const projectKey = _agentMap0[_cid0] || projectKeyFromVirtualChatId(_cid0);
1046
- try {
1047
- const memory = require('./memory');
1048
-
1049
- // L1: NOW.md per-agent whiteboard injection(按 projectKey 隔离,防并发冲突)
1489
+ // ZPD: build competence hint from brain profile
1490
+ let zdpHint = '';
1491
+ let brainDoc = null;
1050
1492
  if (!session.started) {
1051
1493
  try {
1052
- const nowDir = path.join(HOME, '.metame', 'memory', 'now');
1053
- const nowKey = projectKey || 'default';
1054
- const nowPath = path.join(nowDir, `${nowKey}.md`);
1055
- if (fs.existsSync(nowPath)) {
1056
- const nowContent = fs.readFileSync(nowPath, 'utf8').trim();
1057
- if (nowContent) {
1058
- memoryHint += `\n\n[Current task context:\n${nowContent}]`;
1494
+ const brainPath = path.join(HOME, '.claude_profile.yaml');
1495
+ if (fs.existsSync(brainPath)) {
1496
+ const brain = yaml.load(fs.readFileSync(brainPath, 'utf8'));
1497
+ brainDoc = brain;
1498
+ const cmap = brain && brain.user_competence_map;
1499
+ if (cmap && typeof cmap === 'object' && Object.keys(cmap).length > 0) {
1500
+ const lines = Object.entries(cmap)
1501
+ .map(([domain, level]) => ` ${domain}: ${level}`)
1502
+ .join('\n');
1503
+ zdpHint = `\n- User competence map (adjust explanation depth accordingly):\n${lines}\n Rule: expert→skip basics; intermediate→brief rationale; beginner→one-line analogy.`;
1059
1504
  }
1060
1505
  }
1061
1506
  } catch { /* non-critical */ }
1062
1507
  }
1063
-
1064
- // 1. Inject recent session memories ONLY on first message of a session
1065
- if (!session.started) {
1066
- const recent = memory.recentSessions({ limit: 1, project: projectKey || undefined });
1067
- if (recent.length > 0) {
1068
- const items = recent.map(r => `- [${r.created_at}] ${r.summary}${r.keywords ? ' (keywords: ' + r.keywords + ')' : ''}`).join('\n');
1069
- memoryHint += `\n\n[Past session memory:\n${items}]`;
1070
- }
1508
+ if (!brainDoc) {
1509
+ try {
1510
+ const brainPath = path.join(HOME, '.claude_profile.yaml');
1511
+ if (fs.existsSync(brainPath)) brainDoc = yaml.load(fs.readFileSync(brainPath, 'utf8')) || {};
1512
+ } catch { /* ignore */ }
1071
1513
  }
1072
1514
 
1073
- // 2. Dynamic Fact Injection (RAG) first message only
1074
- // Facts stay in Claude's context for the rest of the session; no need to repeat.
1075
- // Uses QMD hybrid search if available, falls back to FTS5.
1515
+ // Inject daemon hints only on first message of a session
1516
+ // Task-specific rules (3-4) are injected only when isTaskIntent() returns true (~250 token saving for casual chat)
1517
+ let daemonHint = '';
1076
1518
  if (!session.started) {
1077
- const searchFn = memory.searchFactsAsync || memory.searchFacts;
1078
- const factQuery = buildFactSearchQuery(prompt, projectKey);
1079
- const facts = await Promise.resolve(searchFn(factQuery, { limit: 3, project: projectKey || undefined }));
1080
- if (facts.length > 0) {
1081
- const factItems = facts.map(f => `- [${f.relation}] ${f.value}`).join('\n');
1082
- memoryHint += `\n\n[Relevant facts:\n${factItems}]`;
1083
- log('INFO', `[MEMORY] Injected ${facts.length} facts (query_len=${factQuery.length})`);
1084
- }
1085
- }
1086
-
1087
- memory.close();
1088
- } catch (e) {
1089
- if (e.code !== 'MODULE_NOT_FOUND') log('WARN', `Memory injection failed: ${e.message}`);
1090
- }
1091
-
1092
- // ZPD: build competence hint from brain profile
1093
- let zdpHint = '';
1094
- let brainDoc = null;
1095
- if (!session.started) {
1096
- try {
1097
- const brainPath = path.join(HOME, '.claude_profile.yaml');
1098
- if (fs.existsSync(brainPath)) {
1099
- const brain = yaml.load(fs.readFileSync(brainPath, 'utf8'));
1100
- brainDoc = brain;
1101
- const cmap = brain && brain.user_competence_map;
1102
- if (cmap && typeof cmap === 'object' && Object.keys(cmap).length > 0) {
1103
- const lines = Object.entries(cmap)
1104
- .map(([domain, level]) => ` ${domain}: ${level}`)
1105
- .join('\n');
1106
- zdpHint = `\n- User competence map (adjust explanation depth accordingly):\n${lines}\n Rule: expert→skip basics; intermediate→brief rationale; beginner→one-line analogy.`;
1107
- }
1108
- }
1109
- } catch { /* non-critical */ }
1110
- }
1111
- if (!brainDoc) {
1112
- try {
1113
- const brainPath = path.join(HOME, '.claude_profile.yaml');
1114
- if (fs.existsSync(brainPath)) brainDoc = yaml.load(fs.readFileSync(brainPath, 'utf8')) || {};
1115
- } catch { /* ignore */ }
1116
- }
1117
-
1118
- // Inject daemon hints only on first message of a session
1119
- // Task-specific rules (3-5) are injected only when isTaskIntent() returns true (~250 token saving for casual chat)
1120
- let daemonHint = '';
1121
- if (!session.started) {
1122
- const taskRules = isTaskIntent(prompt) ? `
1123
- 3. Knowledge retrieval: When you need context about a specific topic, past decisions, or lessons, call:
1124
- node ~/.metame/memory-search.js "关键词1" "keyword2"
1125
- Also read ~/.metame/memory/INDEX.md to discover available long-form lesson/decision docs, then read specific files as needed.
1126
- Use these before answering complex questions about MetaMe architecture or past decisions.
1127
- 4. Active memory: After confirming a new insight, bug root cause, or user preference, persist it with:
1519
+ const mentorRadarHint = (config && config.daemon && config.daemon.mentor && config.daemon.mentor.enabled)
1520
+ ? '\n When you observe the user is clearly expert or beginner in a domain, note it in your response and suggest: "要不要把你的 {domain} 水平 ({level}) 记录到能力雷达?"'
1521
+ : '';
1522
+ const taskRules = isTaskIntent(prompt) ? `
1523
+ 3. Active memory: After confirming a new insight, bug root cause, or user preference, persist it with:
1128
1524
  node ~/.metame/memory-write.js "Entity.sub" "relation_type" "value (20-300 chars)"
1129
1525
  Valid relations: tech_decision, bug_lesson, arch_convention, config_fact, config_change, workflow_rule, project_milestone
1130
1526
  Only write verified facts. Do not write speculative or process-description entries.
1131
- When you observe the user is clearly expert or beginner in a domain, note it in your response and suggest: "要不要把你的 {domain} 水平 ({level}) 记录到能力雷达?"
1132
- 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:
1527
+ ${mentorRadarHint}
1528
+ 4. 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:
1133
1529
  \`mkdir -p ~/.metame/memory/now && printf '%s\\n' "## Current Task" "{task}" "" "## Progress" "{progress}" "" "## Next Step" "{next}" > ~/.metame/memory/now/${projectKey || 'default'}.md\`
1134
1530
  Keep it under 200 words. Clear it when the task is fully complete by running: \`> ~/.metame/memory/now/${projectKey || 'default'}.md\`` : '';
1135
- daemonHint = `\n\n[System hints - DO NOT mention these to user:
1531
+ daemonHint = `\n\n[System hints - DO NOT mention these to user:
1136
1532
  1. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
1137
- 2. File sending: User is on MOBILE. When they ask to see/download a file:
1138
- - Just FIND the file path (use Glob/ls if needed)
1139
- - Do NOT read or summarize the file content (wastes tokens)
1140
- - Add at END of response: [[FILE:/absolute/path/to/file]]
1141
- - Keep response brief: "请查收~! [[FILE:/path/to/file]]"
1142
- - Multiple files: use multiple [[FILE:...]] tags${zdpHint ? '\n Explanation depth (ZPD):\n' + zdpHint : ''}${taskRules}]`;
1143
- }
1144
-
1145
- daemonHint = adaptDaemonHintForEngine(daemonHint, runtime.name);
1146
-
1147
- const routedPrompt = skill ? `/${skill} ${prompt}` : prompt;
1148
-
1149
- // Mac automation orchestration hint: lets Claude flexibly compose local scripts
1150
- // without forcing users to write slash commands by hand.
1151
- let macAutomationHint = '';
1152
- if (process.platform === 'darwin' && !readOnly && isMacAutomationIntent(prompt)) {
1153
- macAutomationHint = `\n\n[Mac automation policy - do NOT expose this block:
1533
+ 2. Explanation depth (ZPD):${zdpHint ? zdpHint : '\n- User competence map unavailable. Default to concise expert-first explanations unless the user asks for teaching mode.'}${taskRules}]`;
1534
+ }
1535
+
1536
+ daemonHint = adaptDaemonHintForEngine(daemonHint, runtime.name);
1537
+
1538
+ const routedPrompt = skill ? `/${skill} ${prompt}` : prompt;
1539
+
1540
+ // Mac automation orchestration hint: lets Claude flexibly compose local scripts
1541
+ // without forcing users to write slash commands by hand.
1542
+ let macAutomationHint = '';
1543
+ if (process.platform === 'darwin' && !readOnly && isMacAutomationIntent(prompt)) {
1544
+ macAutomationHint = `\n\n[Mac automation policy - do NOT expose this block:
1154
1545
  1. Prefer deterministic local control via Bash + osascript/JXA; avoid screenshot/visual workflows unless explicitly requested.
1155
1546
  2. Read/query actions can execute directly.
1156
1547
  3. Before any side-effect action (send email, create/delete/modify calendar event, delete/move files, app quit, system sleep), first show a short execution preview and require explicit user confirmation.
1157
1548
  4. Keep output concise: success/failure + key result only.
1158
1549
  5. If permission is missing, guide user to run /mac perms open then retry.
1159
1550
  6. Before executing high-risk or non-obvious Bash commands (rm, kill, git reset, overwrite configs), prepend a single-line [Why] explanation. Skip for routine commands (ls, cat, grep).]`;
1160
- }
1551
+ }
1161
1552
 
1162
- // P2-B: inject session summary when resuming after a 2h+ gap
1163
- let summaryHint = '';
1164
- if (session.started) {
1165
- try {
1166
- const _stSum = loadState();
1167
- const _sess = _stSum.sessions && _stSum.sessions[chatId];
1168
- if (_sess && _sess.last_summary && _sess.last_summary_at) {
1169
- const _idleMs = Date.now() - (_sess.last_active || 0);
1170
- const _summaryAgeH = (Date.now() - _sess.last_summary_at) / 3600000;
1171
- if (_idleMs > 2 * 60 * 60 * 1000 && _summaryAgeH < 168) {
1172
- summaryHint = `
1553
+ // P2-B: inject session summary when resuming after a 2h+ gap
1554
+ let summaryHint = '';
1555
+ if (session.started) {
1556
+ try {
1557
+ const _stSum = loadState();
1558
+ const _sess = _stSum.sessions && _stSum.sessions[chatId];
1559
+ if (_sess && _sess.last_summary && _sess.last_summary_at) {
1560
+ const _idleMs = Date.now() - (_sess.last_active || 0);
1561
+ const _summaryAgeH = (Date.now() - _sess.last_summary_at) / 3600000;
1562
+ if (_idleMs > 2 * 60 * 60 * 1000 && _summaryAgeH < 168) {
1563
+ summaryHint = `
1173
1564
 
1174
1565
  [上次对话摘要,供参考]: ${_sess.last_summary}`;
1175
- log('INFO', `[DAEMON] Injected session summary for ${chatId} (idle ${Math.round(_idleMs / 3600000)}h)`);
1566
+ log('INFO', `[DAEMON] Injected session summary for ${chatId} (idle ${Math.round(_idleMs / 3600000)}h)`);
1567
+ }
1176
1568
  }
1177
- }
1178
- } catch { /* non-critical */ }
1179
- }
1569
+ } catch { /* non-critical */ }
1570
+ }
1180
1571
 
1181
- // Mentor context hook: inject after memoryHint, before langGuard.
1182
- let mentorHint = '';
1183
- if (mentorEnabled && !mentorExcluded && !mentorSuppressed) {
1184
- try {
1185
- const signals = collectRecentSessionSignals(session.id, 6);
1186
- let skeleton = null;
1187
- if (sessionAnalytics && typeof sessionAnalytics.extractSkeleton === 'function') {
1188
- const file = findSessionFile(session.id);
1189
- if (file && fs.existsSync(file)) {
1190
- const st = fs.statSync(file);
1191
- if (st.size <= 2 * 1024 * 1024) {
1192
- skeleton = sessionAnalytics.extractSkeleton(file);
1572
+ // Mentor context hook: inject after memoryHint, before langGuard.
1573
+ let mentorHint = '';
1574
+ if (mentorEnabled && !mentorExcluded && !mentorSuppressed) {
1575
+ try {
1576
+ const signals = collectRecentSessionSignals(session.id, 6);
1577
+ let skeleton = null;
1578
+ if (sessionAnalytics && typeof sessionAnalytics.extractSkeleton === 'function') {
1579
+ const file = findSessionFile(session.id);
1580
+ if (file && fs.existsSync(file)) {
1581
+ const st = fs.statSync(file);
1582
+ if (st.size <= 2 * 1024 * 1024) {
1583
+ skeleton = sessionAnalytics.extractSkeleton(file);
1584
+ }
1193
1585
  }
1194
1586
  }
1195
- }
1196
- const zone = skeleton && mentorEngine.computeZone
1197
- ? mentorEngine.computeZone(skeleton).zone
1198
- : 'stretch';
1199
- const sessionState = {
1200
- zone,
1201
- recentMessages: signals.recentMessages,
1202
- cwd: session.cwd,
1203
- skeleton,
1204
- sessionStartTime: signals.sessionStartTime || new Date().toISOString(),
1205
- topic: String(prompt || '').slice(0, 120),
1206
- currentTopic: String(prompt || '').slice(0, 120),
1207
- lastUserMessage: String(prompt || '').slice(0, 200),
1208
- };
1209
- const built = mentorEngine.buildMentorPrompt(sessionState, brainDoc || {}, mentorCfg);
1210
- if (built && String(built).trim()) mentorHint = `\n\n${String(built).trim()}`;
1211
-
1212
- // Collect reflection debt: if user returns to same project+topic, inject recall prompt.
1213
- // Suppressed by quiet_until (user explicitly asked for silence), but NOT by expert skip
1214
- // (even experts may not have reviewed AI-generated code).
1215
- const quietUntil = brainDoc && brainDoc.growth ? brainDoc.growth.quiet_until : null;
1216
- const quietMs = quietUntil ? new Date(quietUntil).getTime() : 0;
1217
- const isQuiet = quietMs && quietMs > Date.now();
1218
- if (!isQuiet && mentorEngine.collectDebt) {
1219
- const info = deriveProjectInfo(session && session.cwd ? session.cwd : '');
1220
- const projectId = info && info.project_id ? info.project_id : '';
1221
- if (projectId) {
1222
- const debt = mentorEngine.collectDebt(projectId, String(prompt || '').slice(0, 120));
1223
- if (debt && debt.prompt) {
1224
- mentorHint += `\n\n[Reflection debt] ${debt.prompt}`;
1587
+ const zone = skeleton && mentorEngine.computeZone
1588
+ ? mentorEngine.computeZone(skeleton).zone
1589
+ : 'stretch';
1590
+ const sessionState = {
1591
+ zone,
1592
+ recentMessages: signals.recentMessages,
1593
+ cwd: session.cwd,
1594
+ skeleton,
1595
+ sessionStartTime: signals.sessionStartTime || new Date().toISOString(),
1596
+ topic: String(prompt || '').slice(0, 120),
1597
+ currentTopic: String(prompt || '').slice(0, 120),
1598
+ lastUserMessage: String(prompt || '').slice(0, 200),
1599
+ };
1600
+ const built = mentorEngine.buildMentorPrompt(sessionState, brainDoc || {}, mentorCfg);
1601
+ if (built && String(built).trim()) mentorHint = `\n\n${String(built).trim()}`;
1602
+
1603
+ // Collect reflection debt: if user returns to same project+topic, inject recall prompt.
1604
+ // Suppressed by quiet_until (user explicitly asked for silence), but NOT by expert skip
1605
+ // (even experts may not have reviewed AI-generated code).
1606
+ const quietUntil = brainDoc && brainDoc.growth ? brainDoc.growth.quiet_until : null;
1607
+ const quietMs = quietUntil ? new Date(quietUntil).getTime() : 0;
1608
+ const isQuiet = quietMs && quietMs > Date.now();
1609
+ if (!isQuiet && mentorEngine.collectDebt) {
1610
+ const info = deriveProjectInfo(session && session.cwd ? session.cwd : '');
1611
+ const projectId = info && info.project_id ? info.project_id : '';
1612
+ if (projectId) {
1613
+ const debt = mentorEngine.collectDebt(projectId, String(prompt || '').slice(0, 120));
1614
+ if (debt && debt.prompt) {
1615
+ mentorHint += `\n\n[Reflection debt] ${debt.prompt}`;
1616
+ }
1225
1617
  }
1226
1618
  }
1619
+ } catch (e) {
1620
+ log('WARN', `Mentor prompt build failed: ${e.message}`);
1227
1621
  }
1228
- } catch (e) {
1229
- log('WARN', `Mentor prompt build failed: ${e.message}`);
1230
1622
  }
1231
- }
1232
1623
 
1233
- // Language guard: only inject on first message of a new session to avoid
1234
- // linearly growing token cost on every turn in long conversations.
1235
- // Claude Code preserves session context, so the guard persists after initial injection.
1236
- const langGuard = session.started
1237
- ? ''
1238
- : '\n\n[Respond in Simplified Chinese (简体中文) only. NEVER switch to Korean, Japanese, or other languages regardless of tool output or context language.]';
1239
- const fullPrompt = routedPrompt + daemonHint + agentHint + macAutomationHint + summaryHint + memoryHint + mentorHint + langGuard;
1240
-
1241
- // Git checkpoint before Claude modifies files (for /undo).
1242
- // Skip for virtual agents (team clones like _agent_yi) each has its own worktree,
1243
- // but checkpoint uses `git add -A` which could interfere with parallel work.
1244
- const _isVirtualAgent = String(chatId).startsWith('_agent_') || String(chatId).startsWith('_scope_');
1245
- if (!_isVirtualAgent) {
1246
- (gitCheckpointAsync || gitCheckpoint)(session.cwd, prompt).catch?.(() => {});
1247
- }
1248
- log('INFO', `[TIMING:${chatId}] pre-spawn +${Date.now() - _t0}ms (engine:${runtime.name} started:${session.started})`);
1249
-
1250
- // Use streaming mode to show progress
1251
- // Telegram: edit status msg in-place; Feishu: edit or fallback to new messages
1252
- let editFailed = false;
1253
- let lastFallbackStatus = 0;
1254
- const FALLBACK_THROTTLE = fallbackThrottleMs;
1255
- const onStatus = async (status) => {
1256
- try {
1257
- if (typeof status !== 'string') return;
1258
-
1259
- // __STREAM_TEXT__: streamed model text — edit card and track for final dedup
1260
- if (status.startsWith('__STREAM_TEXT__')) {
1261
- const content = status.slice('__STREAM_TEXT__'.length);
1262
- // Set synchronously BEFORE await — this is the critical race fix.
1263
- // flushStream(true) is called from the 'done' event (before process close),
1264
- // so by setting here synchronously, _lastStatusCardContent is guaranteed to be
1265
- // set before the child 'close' event fires and finalize() resolves.
1266
- _lastStatusCardContent = content;
1267
- if (statusMsgId && bot.editMessage && !editFailed) {
1268
- const ok = await bot.editMessage(chatId, statusMsgId, content, _ackCardHeader);
1269
- if (ok === false) editFailed = true;
1270
- }
1271
- return; // skip fallback — final reply logic will use existing card
1624
+ // Language guard: only inject on first message of a new session to avoid
1625
+ // linearly growing token cost on every turn in long conversations.
1626
+ // Claude Code preserves session context, so the guard persists after initial injection.
1627
+ const langGuard = session.started
1628
+ ? ''
1629
+ : '\n\n[Respond in Simplified Chinese (简体中文) only. NEVER switch to Korean, Japanese, or other languages regardless of tool output or context language.]';
1630
+ let intentHint = '';
1631
+ if (runtime.name === 'codex') {
1632
+ try {
1633
+ const block = buildIntentHintBlock(prompt, config, boundProjectKey || projectKey || '');
1634
+ if (block) intentHint = `\n\n${block}`;
1635
+ } catch (e) {
1636
+ log('WARN', `Intent registry injection failed: ${e.message}`);
1272
1637
  }
1638
+ }
1639
+ const fullPrompt = routedPrompt + daemonHint + intentHint + agentHint + macAutomationHint + summaryHint + memoryHint + mentorHint + langGuard;
1640
+ if (runtime.name === 'codex' && session.started && session.id && requestedCodexPermissionProfile) {
1641
+ const actualPermissionProfile = getActualCodexPermissionProfile(session);
1642
+ if (codexNeedsFallbackForRequestedPermissions(actualPermissionProfile, requestedCodexPermissionProfile)) {
1643
+ const actualSummary = actualPermissionProfile
1644
+ ? `${actualPermissionProfile.sandboxMode || actualPermissionProfile.permissionMode || 'unknown'}/${actualPermissionProfile.approvalPolicy || 'unknown'}`
1645
+ : 'unknown/unknown';
1646
+ const requestedSummary = `${requestedCodexPermissionProfile.sandboxMode}/${requestedCodexPermissionProfile.approvalPolicy}`;
1647
+ log('INFO', `Codex session ${session.id.slice(0, 8)} is below requested permissions for ${sessionChatId}: ${actualSummary} vs ${requestedSummary}; trying native resume first`);
1648
+ }
1649
+ }
1273
1650
 
1274
- // __TOOL_OVERLAY__: text + tool status line — edit card but don't update _lastStatusCardContent
1275
- if (status.startsWith('__TOOL_OVERLAY__')) {
1276
- const content = status.slice('__TOOL_OVERLAY__'.length);
1277
- if (statusMsgId && bot.editMessage && !editFailed) {
1278
- await bot.editMessage(chatId, statusMsgId, content, _ackCardHeader);
1279
- // intentionally NOT updating _lastStatusCardContent — overlay is transient
1651
+ const args = runtime.buildArgs({
1652
+ model,
1653
+ readOnly,
1654
+ daemonCfg,
1655
+ session,
1656
+ cwd: session.cwd,
1657
+ permissionProfile: runtime.name === 'codex' ? requestedCodexPermissionProfile : null,
1658
+ });
1659
+
1660
+ // Codex: write/refresh AGENTS.md = CLAUDE.md + SOUL.md on every fresh execution thread.
1661
+ // This must happen after any permission-triggered fallback decision so the spawned process uses
1662
+ // the final session object and fresh exec args rather than stale resume args.
1663
+ if (engineName === 'codex' && session.cwd && !session.started) {
1664
+ try {
1665
+ const parts = [];
1666
+ const claudeMd = path.join(session.cwd, 'CLAUDE.md');
1667
+ const soulMd = path.join(session.cwd, 'SOUL.md');
1668
+ if (fs.existsSync(claudeMd)) parts.push(fs.readFileSync(claudeMd, 'utf8').trim());
1669
+ if (fs.existsSync(soulMd)) {
1670
+ const soulContent = fs.readFileSync(soulMd, 'utf8').trim();
1671
+ if (soulContent) parts.push(soulContent);
1280
1672
  }
1281
- return;
1673
+ if (parts.length > 0) {
1674
+ fs.writeFileSync(path.join(session.cwd, 'AGENTS.md'), parts.join('\n\n'), 'utf8');
1675
+ log('INFO', `Refreshed AGENTS.md (${parts.length} section(s)) in ${session.cwd}`);
1676
+ }
1677
+ } catch (e) {
1678
+ log('WARN', `AGENTS.md refresh failed: ${e.message}`);
1282
1679
  }
1680
+ }
1681
+
1682
+ // Git checkpoint before Claude modifies files (for /undo).
1683
+ // Skip for virtual agents (team clones like _agent_yi) — each has its own worktree,
1684
+ // but checkpoint uses `git add -A` which could interfere with parallel work.
1685
+ const _isVirtualAgent = String(chatId).startsWith('_agent_') || String(chatId).startsWith('_scope_');
1686
+ if (!_isVirtualAgent) {
1687
+ try {
1688
+ const checkpointResult = (gitCheckpointAsync || gitCheckpoint)(session.cwd, prompt);
1689
+ if (checkpointResult && typeof checkpointResult.catch === 'function') {
1690
+ checkpointResult.catch(() => { });
1691
+ }
1692
+ } catch { /* non-critical */ }
1693
+ }
1694
+ log('INFO', `[TIMING:${chatId}] pre-spawn +${Date.now() - _t0}ms (engine:${runtime.name} started:${session.started})`);
1695
+
1696
+ // Use streaming mode to show progress
1697
+ // Telegram: edit status msg in-place; Feishu: edit or fallback to new messages
1698
+ let editFailed = false;
1699
+ let lastFallbackStatus = 0;
1700
+ const FALLBACK_THROTTLE = fallbackThrottleMs;
1701
+ const onStatus = async (status) => {
1702
+ try {
1703
+ if (typeof status !== 'string') return;
1704
+
1705
+ // __STREAM_TEXT__: streamed model text — edit card and track for final dedup
1706
+ if (status.startsWith('__STREAM_TEXT__')) {
1707
+ const content = status.slice('__STREAM_TEXT__'.length);
1708
+ // Set synchronously BEFORE await — this is the critical race fix.
1709
+ // flushStream(true) is called from the 'done' event (before process close),
1710
+ // so by setting here synchronously, _lastStatusCardContent is guaranteed to be
1711
+ // set before the child 'close' event fires and finalize() resolves.
1712
+ _lastStatusCardContent = content;
1713
+ if (statusMsgId && bot.editMessage && !editFailed) {
1714
+ const ok = await bot.editMessage(chatId, statusMsgId, content, _ackCardHeader);
1715
+ if (ok === false) editFailed = true;
1716
+ }
1717
+ return; // skip fallback — final reply logic will use existing card
1718
+ }
1283
1719
 
1284
- // Plain status (tool names before any text, milestone timers, etc.)
1285
- if (statusMsgId && bot.editMessage && !editFailed) {
1286
- const ok = await bot.editMessage(chatId, statusMsgId, status, _ackCardHeader);
1287
- if (ok !== false) {
1288
- _lastStatusCardContent = status;
1720
+ // __TOOL_OVERLAY__: text + tool status line edit card but don't update _lastStatusCardContent
1721
+ if (status.startsWith('__TOOL_OVERLAY__')) {
1722
+ const content = status.slice('__TOOL_OVERLAY__'.length);
1723
+ if (statusMsgId && bot.editMessage && !editFailed) {
1724
+ await bot.editMessage(chatId, statusMsgId, content, _ackCardHeader);
1725
+ // intentionally NOT updating _lastStatusCardContent — overlay is transient
1726
+ }
1289
1727
  return;
1290
1728
  }
1291
- editFailed = true;
1292
- }
1293
- // Fallback: send as new message with throttle to avoid spam
1294
- const now = Date.now();
1295
- if (now - lastFallbackStatus < FALLBACK_THROTTLE) return;
1296
- lastFallbackStatus = now;
1297
- await bot.sendMessage(chatId, status);
1298
- } catch { /* ignore status update failures */ }
1299
- };
1300
1729
 
1301
- const wasCodexResumeAttempt = runtime.name === 'codex'
1302
- && !!(session && session.started && session.id && session.id !== '__continue__');
1303
- const onSession = async (nextSessionId) => {
1304
- const safeNextId = String(nextSessionId || '').trim();
1305
- if (!safeNextId) return;
1306
- const prevSessionId = session && session.id ? String(session.id) : '';
1307
- const wasStarted = !!(session && session.started);
1308
- session = {
1309
- ...session,
1310
- id: safeNextId,
1311
- engine: runtime.name,
1312
- started: true,
1730
+ // Plain status (tool names before any text, milestone timers, etc.)
1731
+ if (statusMsgId && bot.editMessage && !editFailed) {
1732
+ const ok = await bot.editMessage(chatId, statusMsgId, status, _ackCardHeader);
1733
+ if (ok !== false) {
1734
+ _lastStatusCardContent = status;
1735
+ return;
1736
+ }
1737
+ editFailed = true;
1738
+ }
1739
+ // Fallback: send as new message with throttle to avoid spam
1740
+ const now = Date.now();
1741
+ if (now - lastFallbackStatus < FALLBACK_THROTTLE) return;
1742
+ lastFallbackStatus = now;
1743
+ await bot.sendMessage(chatId, status);
1744
+ } catch { /* ignore status update failures */ }
1313
1745
  };
1314
- await patchSessionSerialized(sessionChatId, (cur) => {
1315
- const engines = { ...(cur.engines || {}) };
1316
- engines[runtime.name] = { ...(engines[runtime.name] || {}), id: safeNextId, started: true };
1317
- return { ...cur, cwd: session.cwd || cur.cwd || HOME, engines };
1318
- });
1319
- if (runtime.name === 'codex' && wasStarted && prevSessionId && prevSessionId !== safeNextId && prevSessionId !== '__continue__') {
1320
- log('WARN', `Codex thread migrated for ${chatId}: ${prevSessionId.slice(0, 8)} -> ${safeNextId.slice(0, 8)}`);
1321
- }
1322
- };
1323
1746
 
1324
- let output, error, errorCode, files, toolUsageLog, timedOut, usage, sessionId;
1325
- try {
1326
- ({
1327
- output,
1328
- error,
1329
- errorCode,
1330
- timedOut,
1331
- files,
1332
- toolUsageLog,
1333
- usage,
1334
- sessionId,
1335
- } = await spawnClaudeStreaming(
1336
- args,
1337
- fullPrompt,
1338
- session.cwd,
1339
- onStatus,
1340
- 600000,
1341
- chatId,
1342
- boundProjectKey || '',
1343
- runtime,
1344
- onSession,
1345
- ));
1346
-
1347
- if (sessionId) await onSession(sessionId);
1348
-
1349
- if (shouldRetryCodexResumeFallback({
1350
- runtimeName: runtime.name,
1351
- wasResumeAttempt: wasCodexResumeAttempt,
1352
- output,
1353
- error,
1354
- errorCode,
1355
- canRetry: canRetryCodexResume(chatId),
1356
- })) {
1357
- markCodexResumeRetried(chatId);
1358
- log('WARN', `Codex resume failed for ${chatId}, retrying once with fresh exec: ${String(error).slice(0, 120)}`);
1359
- // Notify user explicitly — silent context loss is worse than a visible warning.
1360
- await bot.sendMessage(chatId, '⚠️ Codex session 已过期,上下文丢失。正在以全新 session 重试,请在回复后补充必要背景。').catch(() => {});
1361
- session = createSession(
1362
- sessionChatId,
1363
- session.cwd,
1364
- boundProject && boundProject.name ? boundProject.name : '',
1365
- 'codex'
1366
- );
1367
- const retryArgs = runtime.buildArgs({
1368
- model,
1369
- readOnly,
1370
- daemonCfg,
1371
- session,
1372
- cwd: session.cwd,
1747
+ const wasCodexResumeAttempt = runtime.name === 'codex'
1748
+ && !!(session && session.started && session.id && session.id !== '__continue__');
1749
+ const onSession = async (nextSessionId) => {
1750
+ const safeNextId = String(nextSessionId || '').trim();
1751
+ if (!safeNextId) return;
1752
+ const prevSessionId = session && session.id ? String(session.id) : '';
1753
+ const wasStarted = !!(session && session.started);
1754
+ session = {
1755
+ ...session,
1756
+ id: safeNextId,
1757
+ engine: runtime.name,
1758
+ logicalChatId: sessionChatId,
1759
+ started: true,
1760
+ };
1761
+ await patchSessionSerialized(sessionChatId, (cur) => {
1762
+ const engines = { ...(cur.engines || {}) };
1763
+ const actualPermissionProfile = runtime.name === 'codex'
1764
+ ? (getActualCodexPermissionProfile({ id: safeNextId }) || requestedCodexPermissionProfile)
1765
+ : null;
1766
+ engines[runtime.name] = {
1767
+ ...(engines[runtime.name] || {}),
1768
+ id: safeNextId,
1769
+ started: true,
1770
+ ...(runtime.name === 'codex' ? { runtimeSessionObserved: true } : {}),
1771
+ ...(runtime.name === 'codex' ? actualPermissionProfile : {}),
1772
+ };
1773
+ return { ...cur, cwd: session.cwd || cur.cwd || HOME, engines };
1373
1774
  });
1374
- // Prepend a context-loss marker so Codex knows this is a fresh session mid-conversation.
1375
- const retryPrompt = `[Note: previous Codex session expired and could not be resumed. Treating this as a new session. User message follows:]\n\n${fullPrompt}`;
1775
+ if (runtime.name === 'codex' && wasStarted && prevSessionId && prevSessionId !== safeNextId && prevSessionId !== '__continue__') {
1776
+ log('WARN', `Codex thread migrated for ${chatId}: ${prevSessionId.slice(0, 8)} -> ${safeNextId.slice(0, 8)}`);
1777
+ }
1778
+ };
1779
+
1780
+ let output, error, errorCode, files, toolUsageLog, timedOut, sessionId;
1781
+ try {
1376
1782
  ({
1377
1783
  output,
1378
1784
  error,
@@ -1380,291 +1786,464 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1380
1786
  timedOut,
1381
1787
  files,
1382
1788
  toolUsageLog,
1383
- usage,
1384
1789
  sessionId,
1385
1790
  } = await spawnClaudeStreaming(
1386
- retryArgs,
1387
- retryPrompt,
1791
+ args,
1792
+ fullPrompt,
1388
1793
  session.cwd,
1389
1794
  onStatus,
1390
1795
  600000,
1391
1796
  chatId,
1392
1797
  boundProjectKey || '',
1798
+ normalizeSenderId(senderId),
1393
1799
  runtime,
1394
1800
  onSession,
1395
1801
  ));
1802
+
1396
1803
  if (sessionId) await onSession(sessionId);
1804
+
1805
+ if (runtime.name === 'codex' && requestedCodexPermissionProfile) {
1806
+ let observedRuntimeProfile = getActualCodexPermissionProfile(sessionId ? { id: sessionId } : session);
1807
+ let stabilizationRetryCount = 0;
1808
+ while (codexNeedsFallbackForRequestedPermissions(observedRuntimeProfile, requestedCodexPermissionProfile)
1809
+ && stabilizationRetryCount < CODEX_PERMISSION_STABILIZE_MAX_RETRIES) {
1810
+ stabilizationRetryCount += 1;
1811
+ const previousSessionId = String(sessionId || session.id || '').trim();
1812
+ const observedSummary = observedRuntimeProfile
1813
+ ? `${observedRuntimeProfile.sandboxMode || observedRuntimeProfile.permissionMode || 'unknown'}/${observedRuntimeProfile.approvalPolicy || 'unknown'}`
1814
+ : 'unknown/unknown';
1815
+ const requestedSummary = `${requestedCodexPermissionProfile.sandboxMode}/${requestedCodexPermissionProfile.approvalPolicy}`;
1816
+ log(
1817
+ 'WARN',
1818
+ `Codex thread ${String(sessionId || session.id || '').slice(0, 8)} ended below requested permissions for ${sessionChatId}: ${observedSummary} vs ${requestedSummary}; retrying with a new execution thread (${stabilizationRetryCount}/${CODEX_PERMISSION_STABILIZE_MAX_RETRIES})`
1819
+ );
1820
+ session = createSession(
1821
+ sessionChatId,
1822
+ session.cwd,
1823
+ boundProject && boundProject.name ? boundProject.name : '',
1824
+ 'codex',
1825
+ requestedCodexPermissionProfile
1826
+ );
1827
+ const retryRecentContext = previousSessionId && typeof getSessionRecentContext === 'function'
1828
+ ? getSessionRecentContext(previousSessionId)
1829
+ : null;
1830
+ const freshRetryPrompt = buildCodexFallbackBridgePrompt({
1831
+ fullPrompt,
1832
+ previousSessionId,
1833
+ previousProfile: normalizeComparableCodexPermissionProfile(observedRuntimeProfile),
1834
+ requestedProfile: requestedCodexPermissionProfile,
1835
+ recentContext: retryRecentContext,
1836
+ });
1837
+ const freshRetryArgs = runtime.buildArgs({
1838
+ model,
1839
+ readOnly,
1840
+ daemonCfg,
1841
+ session,
1842
+ cwd: session.cwd,
1843
+ permissionProfile: requestedCodexPermissionProfile,
1844
+ });
1845
+ ({
1846
+ output,
1847
+ error,
1848
+ errorCode,
1849
+ timedOut,
1850
+ files,
1851
+ toolUsageLog,
1852
+ sessionId,
1853
+ } = await spawnClaudeStreaming(
1854
+ freshRetryArgs,
1855
+ freshRetryPrompt,
1856
+ session.cwd,
1857
+ onStatus,
1858
+ 600000,
1859
+ chatId,
1860
+ boundProjectKey || '',
1861
+ normalizeSenderId(senderId),
1862
+ runtime,
1863
+ onSession,
1864
+ ));
1865
+ if (sessionId) await onSession(sessionId);
1866
+ observedRuntimeProfile = getActualCodexPermissionProfile(sessionId ? { id: sessionId } : session);
1867
+ }
1868
+ if (codexNeedsFallbackForRequestedPermissions(observedRuntimeProfile, requestedCodexPermissionProfile)) {
1869
+ const observedSummary = observedRuntimeProfile
1870
+ ? `${observedRuntimeProfile.sandboxMode || observedRuntimeProfile.permissionMode || 'unknown'}/${observedRuntimeProfile.approvalPolicy || 'unknown'}`
1871
+ : 'unknown/unknown';
1872
+ const requestedSummary = `${requestedCodexPermissionProfile.sandboxMode}/${requestedCodexPermissionProfile.approvalPolicy}`;
1873
+ log(
1874
+ 'WARN',
1875
+ `Codex thread ${String(sessionId || session.id || '').slice(0, 8)} still below requested permissions for ${sessionChatId} after ${CODEX_PERMISSION_STABILIZE_MAX_RETRIES} stabilization retries: ${observedSummary} vs ${requestedSummary}`
1876
+ );
1877
+ }
1878
+ }
1879
+
1880
+ const resumeFailure = classifyCodexResumeFailure(error, errorCode);
1881
+ if (shouldRetryCodexResumeFallback({
1882
+ runtimeName: runtime.name,
1883
+ wasResumeAttempt: wasCodexResumeAttempt,
1884
+ output,
1885
+ error,
1886
+ errorCode,
1887
+ failureKind: resumeFailure.kind,
1888
+ canRetry: canRetryCodexResume(chatId, resumeFailure.kind),
1889
+ })) {
1890
+ markCodexResumeRetried(chatId, resumeFailure.kind);
1891
+ log(
1892
+ 'WARN',
1893
+ `Codex resume failed for ${chatId}, retrying once with ${resumeFailure.kind === 'interrupted' ? 'native resume recovery' : 'fresh exec'}: ${String(error).slice(0, 120)}`
1894
+ );
1895
+ await bot.sendMessage(chatId, resumeFailure.userMessage).catch(() => { });
1896
+ if (resumeFailure.kind !== 'interrupted') {
1897
+ session = createSession(
1898
+ sessionChatId,
1899
+ session.cwd,
1900
+ boundProject && boundProject.name ? boundProject.name : '',
1901
+ 'codex',
1902
+ requestedCodexPermissionProfile
1903
+ );
1904
+ }
1905
+ const retryArgs = runtime.buildArgs({
1906
+ model,
1907
+ readOnly,
1908
+ daemonCfg,
1909
+ session,
1910
+ cwd: session.cwd,
1911
+ permissionProfile: requestedCodexPermissionProfile,
1912
+ });
1913
+ const retryPrompt = `${resumeFailure.retryPromptPrefix}\n\n${fullPrompt}`;
1914
+ ({
1915
+ output,
1916
+ error,
1917
+ errorCode,
1918
+ timedOut,
1919
+ files,
1920
+ toolUsageLog,
1921
+ sessionId,
1922
+ } = await spawnClaudeStreaming(
1923
+ retryArgs,
1924
+ retryPrompt,
1925
+ session.cwd,
1926
+ onStatus,
1927
+ 600000,
1928
+ chatId,
1929
+ boundProjectKey || '',
1930
+ normalizeSenderId(senderId),
1931
+ runtime,
1932
+ onSession,
1933
+ ));
1934
+ if (sessionId) await onSession(sessionId);
1935
+ }
1936
+ } catch (spawnErr) {
1937
+ clearInterval(typingTimer);
1938
+ if (statusMsgId && bot.deleteMessage) bot.deleteMessage(chatId, statusMsgId).catch(() => { });
1939
+ log('ERROR', `spawnClaudeStreaming crashed for ${chatId}: ${spawnErr.message}`);
1940
+ await bot.sendMessage(chatId, `❌ 内部错误: ${spawnErr.message}`).catch(() => { });
1941
+ return { ok: false, error: spawnErr.message };
1397
1942
  }
1398
- } catch (spawnErr) {
1399
1943
  clearInterval(typingTimer);
1400
- if (statusMsgId && bot.deleteMessage) bot.deleteMessage(chatId, statusMsgId).catch(() => { });
1401
- log('ERROR', `spawnClaudeStreaming crashed for ${chatId}: ${spawnErr.message}`);
1402
- await bot.sendMessage(chatId, `❌ 内部错误: ${spawnErr.message}`).catch(() => { });
1403
- return { ok: false, error: spawnErr.message };
1404
- }
1405
- clearInterval(typingTimer);
1406
1944
 
1407
- // Skill evolution: capture signal + hot path heuristic check
1408
- if (skillEvolution) {
1409
- try {
1410
- const signal = skillEvolution.extractSkillSignal(fullPrompt, output, error, files, session.cwd, toolUsageLog);
1411
- if (signal) {
1412
- skillEvolution.appendSkillSignal(signal);
1413
- skillEvolution.checkHotEvolution(signal);
1414
- }
1415
- } catch (e) { log('WARN', `Skill evolution signal capture failed: ${e.message}`); }
1416
- }
1945
+ // [PROTECTED] L0 lossless diary see logRawSessionDiary() at file top
1946
+ logRawSessionDiary(fs, path, HOME, { chatId, prompt, output, error, projectKey: boundProjectKey });
1947
+
1948
+ // Skill evolution: capture signal + hot path heuristic check
1949
+ if (skillEvolution) {
1950
+ try {
1951
+ const signal = skillEvolution.extractSkillSignal(fullPrompt, output, error, files, session.cwd, toolUsageLog);
1952
+ if (signal) {
1953
+ skillEvolution.appendSkillSignal(signal);
1954
+ skillEvolution.checkHotEvolution(signal);
1955
+ }
1956
+ } catch (e) { log('WARN', `Skill evolution signal capture failed: ${e.message}`); }
1957
+ }
1417
1958
 
1418
- // statusMsgId is always available for final reply handling (edit or delete).
1419
- const _statusMsgIdForReply = statusMsgId || null;
1959
+ // statusMsgId is always available for final reply handling (edit or delete).
1960
+ const _statusMsgIdForReply = statusMsgId || null;
1420
1961
 
1421
- // Mentor post-flight debt registration (intense mode only).
1422
- if (mentorEnabled && !mentorExcluded && !mentorSuppressed && mentorEngine && typeof mentorEngine.registerDebt === 'function' && output) {
1423
- try {
1424
- const mode = resolveMentorMode(mentorCfg);
1425
- if (mode === 'intense') {
1426
- const codeLines = countCodeLines(output);
1427
- if (codeLines > 30) {
1428
- const info = deriveProjectInfo(session && session.cwd ? session.cwd : '');
1429
- const projectId = info && info.project_id ? info.project_id : 'proj_default';
1430
- mentorEngine.registerDebt(projectId, String(prompt || '').slice(0, 120), codeLines);
1431
- log('INFO', `[MENTOR] Registered reflection debt (${projectId}, lines=${codeLines})`);
1962
+ // Mentor post-flight debt registration (intense mode only).
1963
+ if (mentorEnabled && !mentorExcluded && !mentorSuppressed && mentorEngine && typeof mentorEngine.registerDebt === 'function' && output) {
1964
+ try {
1965
+ const mode = resolveMentorMode(mentorCfg);
1966
+ if (mode === 'intense') {
1967
+ const codeLines = countCodeLines(output);
1968
+ if (codeLines > 30) {
1969
+ const info = deriveProjectInfo(session && session.cwd ? session.cwd : '');
1970
+ const projectId = info && info.project_id ? info.project_id : 'proj_default';
1971
+ mentorEngine.registerDebt(projectId, String(prompt || '').slice(0, 120), codeLines);
1972
+ log('INFO', `[MENTOR] Registered reflection debt (${projectId}, lines=${codeLines})`);
1973
+ }
1432
1974
  }
1975
+ } catch (e) {
1976
+ log('WARN', `Mentor post-flight failed: ${e.message}`);
1433
1977
  }
1434
- } catch (e) {
1435
- log('WARN', `Mentor post-flight failed: ${e.message}`);
1436
1978
  }
1437
- }
1438
1979
 
1439
- // When Claude completes with no text output (pure tool work), send a done notice
1440
- if (output === '' && !error) {
1441
- // Special case: if dispatch_to was called, send a "forwarded" confirmation
1442
- const dispatchedTargets = (toolUsageLog || [])
1443
- .filter(t => t.tool === 'Bash' && typeof t.context === 'string' && t.context.includes('dispatch_to'))
1444
- .map(t => { const m = t.context.match(/dispatch_to\s+(\S+)/); return m ? m[1] : null; })
1445
- .filter(Boolean);
1446
- if (dispatchedTargets.length > 0) {
1447
- const allProjects = (config && config.projects) || {};
1448
- const names = dispatchedTargets.map(k => (allProjects[k] && allProjects[k].name) || k).join('、');
1449
- const doneMsg = await bot.sendMessage(chatId, `✉️ 已转达给 ${names},处理中…`);
1980
+ // When Claude completes with no text output (pure tool work), send a done notice
1981
+ if (output === '' && !error) {
1982
+ // Special case: if dispatch_to was called, send a "forwarded" confirmation
1983
+ const dispatchedTargets = (toolUsageLog || [])
1984
+ .filter(t => t.tool === 'Bash' && typeof t.context === 'string' && t.context.includes('dispatch_to'))
1985
+ .map(t => { const m = t.context.match(/dispatch_to\s+(\S+)/); return m ? m[1] : null; })
1986
+ .filter(Boolean);
1987
+ if (dispatchedTargets.length > 0) {
1988
+ const allProjects = (config && config.projects) || {};
1989
+ const names = dispatchedTargets.map(k => (allProjects[k] && allProjects[k].name) || k).join('、');
1990
+ const doneMsg = await bot.sendMessage(chatId, `✉️ 已转达给 ${names},处理中…`);
1991
+ if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session, String(chatId).startsWith('_agent_') ? String(chatId).slice(7) : null);
1992
+ const wasNew = !session.started;
1993
+ if (wasNew) markSessionStarted(sessionChatId, engineName);
1994
+ return { ok: true };
1995
+ }
1996
+ const filesDesc = files && files.length > 0 ? `\n修改了 ${files.length} 个文件` : '';
1997
+ const doneMsg = await bot.sendMessage(chatId, `✅ 完成${filesDesc}`);
1450
1998
  if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session, String(chatId).startsWith('_agent_') ? String(chatId).slice(7) : null);
1451
1999
  const wasNew = !session.started;
1452
2000
  if (wasNew) markSessionStarted(sessionChatId, engineName);
1453
2001
  return { ok: true };
1454
2002
  }
1455
- const filesDesc = files && files.length > 0 ? `\n修改了 ${files.length} 个文件` : '';
1456
- const doneMsg = await bot.sendMessage(chatId, `✅ 完成${filesDesc}`);
1457
- if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session, String(chatId).startsWith('_agent_') ? String(chatId).slice(7) : null);
1458
- const wasNew = !session.started;
1459
- if (wasNew) markSessionStarted(sessionChatId, engineName);
1460
- return { ok: true };
1461
- }
1462
2003
 
1463
- if (output) {
1464
- if (runtime.name === 'codex') _codexResumeRetryTs.delete(String(chatId));
1465
- // Detect provider/model errors disguised as output (e.g., "model not found", API errors)
1466
- if (runtime.name === 'claude') {
1467
- const activeProvCheck = providerMod ? providerMod.getActiveName() : 'anthropic';
1468
- const builtinModelsCheck = ['sonnet', 'opus', 'haiku'];
1469
- const looksLikeError = output.length < 300 && /\b(not found|invalid model|unauthorized|401|403|404|error|failed)\b/i.test(output);
1470
- if (looksLikeError && (activeProvCheck !== 'anthropic' || !builtinModelsCheck.includes(model))) {
1471
- try {
1472
- config = fallbackToDefaultProvider(`output looks like error for ${activeProvCheck}/${model}`);
1473
- await bot.sendMessage(chatId, `⚠️ ${activeProvCheck}/${model} 疑似失败,已回退到 anthropic/opus\n输出: ${output.slice(0, 150)}`);
1474
- } catch (fbErr) {
1475
- log('ERROR', `Fallback failed: ${fbErr.message}`);
1476
- await bot.sendMarkdown(chatId, output);
2004
+ if (output) {
2005
+ if (runtime.name === 'codex') {
2006
+ _codexResumeRetryTs.delete(getCodexResumeRetryKey(chatId, 'interrupted'));
2007
+ _codexResumeRetryTs.delete(getCodexResumeRetryKey(chatId, 'expired'));
2008
+ _codexResumeRetryTs.delete(getCodexResumeRetryKey(chatId, 'default'));
2009
+ }
2010
+ // Detect provider/model errors disguised as output (e.g., "model not found", API errors)
2011
+ if (runtime.name === 'claude') {
2012
+ const activeProvCheck = providerMod ? providerMod.getActiveName() : 'anthropic';
2013
+ const builtinModelsCheck = ['sonnet', 'opus', 'haiku'];
2014
+ const looksLikeError = output.length < 300 && /\b(not found|invalid model|unauthorized|401|403|404|error|failed)\b/i.test(output);
2015
+ if (looksLikeError && (activeProvCheck !== 'anthropic' || !builtinModelsCheck.includes(model))) {
2016
+ try {
2017
+ config = fallbackToDefaultProvider(`output looks like error for ${activeProvCheck}/${model}`);
2018
+ await bot.sendMessage(chatId, `⚠️ ${activeProvCheck}/${model} 疑似失败,已回退到 anthropic/opus\n输出: ${output.slice(0, 150)}`);
2019
+ } catch (fbErr) {
2020
+ log('ERROR', `Fallback failed: ${fbErr.message}`);
2021
+ await bot.sendMarkdown(chatId, output);
2022
+ }
2023
+ return { ok: false, error: output };
1477
2024
  }
1478
- return { ok: false, error: output };
1479
2025
  }
1480
- }
1481
2026
 
1482
- // Mark session as started after first successful call
1483
- const wasNew = !session.started;
1484
- if (wasNew) markSessionStarted(sessionChatId, engineName);
2027
+ // Mark session as started after first successful call
2028
+ const wasNew = !session.started;
2029
+ if (wasNew) {
2030
+ markSessionStarted(sessionChatId, engineName);
2031
+ if (runtime.name === 'codex' && session.runtimeSessionObserved === false) {
2032
+ log('WARN', `Codex completed without emitting thread id for ${chatId}; keeping session non-resumable until a real thread id is observed`);
2033
+ }
2034
+ }
1485
2035
 
1486
- const estimated = Math.ceil((prompt.length + output.length) / 4);
1487
- const chatCategory = classifyChatUsage(chatId, {
1488
- projectKey: boundProjectKey || '',
1489
- cwd: session && session.cwd,
1490
- homeDir: HOME,
1491
- });
1492
- recordTokens(loadState(), estimated, { category: chatCategory });
2036
+ const estimated = Math.ceil((prompt.length + output.length) / 4);
2037
+ const chatCategory = classifyChatUsage(chatId, {
2038
+ projectKey: boundProjectKey || '',
2039
+ cwd: session && session.cwd,
2040
+ homeDir: HOME,
2041
+ });
2042
+ recordTokens(loadState(), estimated, { category: chatCategory });
1493
2043
 
1494
- // Parse [[FILE:...]] markers from output (Claude's explicit file sends)
1495
- let { markedFiles, cleanOutput } = parseFileMarkers(output);
2044
+ // Parse [[FILE:...]] markers from output (Claude's explicit file sends)
2045
+ let { markedFiles, cleanOutput } = parseFileMarkers(output);
1496
2046
 
1497
- // Timeout with partial results: prepend warning
1498
- if (timedOut) {
1499
- cleanOutput = `⚠️ **任务超时,以下是已完成的部分结果:**\n\n${cleanOutput}`;
1500
- }
2047
+ // Timeout with partial results: prepend warning
2048
+ if (timedOut) {
2049
+ cleanOutput = `⚠️ **任务超时,以下是已完成的部分结果:**\n\n${cleanOutput}`;
2050
+ }
1501
2051
 
1502
- // Match current session to a project for colored card display.
1503
- // Prefer the bound project (known by virtual chatId or chat_agent_map) — avoids ambiguity
1504
- // when multiple projects share the same cwd (e.g. team members with parent project cwd).
1505
- let activeProject = boundProject || null;
1506
- if (!activeProject && session && session.cwd && config && config.projects) {
1507
- const sessionCwd = path.resolve(normalizeCwd(session.cwd));
1508
- for (const [, proj] of Object.entries(config.projects)) {
1509
- if (!proj.cwd) continue;
1510
- const projCwd = path.resolve(normalizeCwd(proj.cwd));
1511
- if (sessionCwd === projCwd) { activeProject = proj; break; }
2052
+ // Match current session to a project for colored card display.
2053
+ // Prefer the bound project (known by virtual chatId or chat_agent_map) — avoids ambiguity
2054
+ // when multiple projects share the same cwd (e.g. team members with parent project cwd).
2055
+ let activeProject = boundProject || null;
2056
+ if (!activeProject && session && session.cwd && config && config.projects) {
2057
+ const sessionCwd = path.resolve(normalizeCwd(session.cwd));
2058
+ for (const [, proj] of Object.entries(config.projects)) {
2059
+ if (!proj.cwd) continue;
2060
+ const projCwd = path.resolve(normalizeCwd(proj.cwd));
2061
+ if (sessionCwd === projCwd) { activeProject = proj; break; }
2062
+ }
1512
2063
  }
1513
- }
1514
2064
 
1515
- let replyMsg;
1516
- try {
1517
- log('DEBUG', `[REPLY:${chatId}] statusMsgId=${statusMsgId} editFailed=${editFailed} activeProject=${activeProject && activeProject.name} lastCard=${_lastStatusCardContent ? _lastStatusCardContent.slice(0,40) : 'null'}`);
1518
-
1519
- // Strategy: always try to update the status card first (avoids sending a new card
1520
- // while the old 🤔 card lingers, which would produce two messages).
1521
- // If edit fails: try to delete the status card (awaited, not fire-and-forget).
1522
- // If delete also fails: fall through to sending a new card.
1523
- if (_statusMsgIdForReply && bot.editMessage) {
1524
- // Skip redundant edit: streaming already wrote the final content to the card.
1525
- // _lastStatusCardContent tracks the last __STREAM_TEXT__ write, so if it matches
1526
- // cleanOutput the card is already showing the right content no update needed.
1527
- if (_lastStatusCardContent !== null && _lastStatusCardContent === cleanOutput) {
1528
- log('DEBUG', `[REPLY:${chatId}] skipping editMessage — card already shows final content`);
1529
- replyMsg = { message_id: _statusMsgIdForReply };
1530
- } else {
1531
- const editOk = await bot.editMessage(chatId, _statusMsgIdForReply, cleanOutput, _ackCardHeader);
1532
- log('DEBUG', `[REPLY:${chatId}] editMessage result=${editOk}`);
1533
- if (editOk !== false) {
2065
+ let replyMsg;
2066
+ try {
2067
+ log('DEBUG', `[REPLY:${chatId}] statusMsgId=${statusMsgId} editFailed=${editFailed} activeProject=${activeProject && activeProject.name} lastCard=${_lastStatusCardContent ? _lastStatusCardContent.slice(0, 40) : 'null'}`);
2068
+
2069
+ // siri_ask: write full response to temp file for any dispatch-triggered reply
2070
+ if (chatId && chatId.startsWith('_agent_') && cleanOutput) {
2071
+ try { require('fs').writeFileSync('/tmp/siri_response.txt', cleanOutput); } catch {}
2072
+ }
2073
+
2074
+ // Strategy: always try to update the status card first (avoids sending a new card
2075
+ // while the old 🤔 card lingers, which would produce two messages).
2076
+ // If edit fails: try to delete the status card (awaited, not fire-and-forget).
2077
+ // If delete also fails: fall through to sending a new card.
2078
+ if (_statusMsgIdForReply && bot.editMessage) {
2079
+ // Skip redundant edit: streaming already wrote the final content to the card.
2080
+ // _lastStatusCardContent tracks the last __STREAM_TEXT__ write, so if it matches
2081
+ // cleanOutput the card is already showing the right content — no update needed.
2082
+ if (_lastStatusCardContent !== null && _lastStatusCardContent === cleanOutput) {
2083
+ log('DEBUG', `[REPLY:${chatId}] skipping editMessage — card already shows final content`);
1534
2084
  replyMsg = { message_id: _statusMsgIdForReply };
1535
- } else if (bot.deleteMessage) {
1536
- const deleted = await bot.deleteMessage(chatId, _statusMsgIdForReply).then(() => true).catch(() => false);
1537
- log('DEBUG', `[REPLY:${chatId}] deleteMessage result=${deleted}`);
1538
- if (!deleted) {
1539
- // Both edit and delete failed — try one more edit attempt to avoid leaving 🤔
1540
- log('WARN', `[REPLY:${chatId}] deleteMessage failed status card may linger alongside new reply`);
2085
+ } else {
2086
+ const editOk = await bot.editMessage(chatId, _statusMsgIdForReply, cleanOutput, _ackCardHeader);
2087
+ log('DEBUG', `[REPLY:${chatId}] editMessage result=${editOk}`);
2088
+ if (editOk !== false) {
2089
+ replyMsg = { message_id: _statusMsgIdForReply };
2090
+ } else if (bot.deleteMessage) {
2091
+ const deleted = await bot.deleteMessage(chatId, _statusMsgIdForReply).then(() => true).catch(() => false);
2092
+ log('DEBUG', `[REPLY:${chatId}] deleteMessage result=${deleted}`);
2093
+ if (!deleted) {
2094
+ // Both edit and delete failed — try one more edit attempt to avoid leaving 🤔
2095
+ log('WARN', `[REPLY:${chatId}] deleteMessage failed — status card may linger alongside new reply`);
2096
+ }
1541
2097
  }
1542
2098
  }
2099
+ } else if (_statusMsgIdForReply && bot.deleteMessage) {
2100
+ // No editMessage — delete the status card
2101
+ await bot.deleteMessage(chatId, _statusMsgIdForReply).catch(() => { });
1543
2102
  }
1544
- } else if (_statusMsgIdForReply && bot.deleteMessage) {
1545
- // No editMessage — delete the status card
1546
- await bot.deleteMessage(chatId, _statusMsgIdForReply).catch(() => { });
1547
- }
1548
2103
 
1549
- if (!replyMsg) {
1550
- if (activeProject && bot.sendCard) {
1551
- log('DEBUG', `[REPLY:${chatId}] sending sendCard`);
1552
- replyMsg = await bot.sendCard(chatId, {
1553
- title: `${activeProject.icon || '🤖'} ${activeProject.name || ''}`,
1554
- body: cleanOutput,
1555
- color: activeProject.color || 'blue',
1556
- });
1557
- log('DEBUG', `[REPLY:${chatId}] sendCard done msgId=${replyMsg && replyMsg.message_id}`);
1558
- } else {
1559
- log('DEBUG', `[REPLY:${chatId}] sending sendMarkdown`);
1560
- replyMsg = await bot.sendMarkdown(chatId, cleanOutput);
1561
- log('DEBUG', `[REPLY:${chatId}] sendMarkdown done msgId=${replyMsg && replyMsg.message_id}`);
2104
+ if (!replyMsg) {
2105
+ if (activeProject && bot.sendCard) {
2106
+ log('DEBUG', `[REPLY:${chatId}] sending sendCard`);
2107
+ replyMsg = await bot.sendCard(chatId, {
2108
+ title: `${activeProject.icon || '🤖'} ${activeProject.name || ''}`,
2109
+ body: cleanOutput,
2110
+ color: activeProject.color || 'blue',
2111
+ });
2112
+ log('DEBUG', `[REPLY:${chatId}] sendCard done msgId=${replyMsg && replyMsg.message_id}`);
2113
+ } else {
2114
+ log('DEBUG', `[REPLY:${chatId}] sending sendMarkdown`);
2115
+ replyMsg = await bot.sendMarkdown(chatId, cleanOutput);
2116
+ log('DEBUG', `[REPLY:${chatId}] sendMarkdown done msgId=${replyMsg && replyMsg.message_id}`);
2117
+ }
2118
+ }
2119
+ } catch (sendErr) {
2120
+ log('WARN', `sendCard/sendMarkdown failed (${sendErr.message}), falling back to sendMessage`);
2121
+ try { replyMsg = await bot.sendMessage(chatId, cleanOutput); } catch (e2) {
2122
+ log('ERROR', `sendMessage fallback also failed: ${e2.message}`);
1562
2123
  }
1563
2124
  }
1564
- } catch (sendErr) {
1565
- log('WARN', `sendCard/sendMarkdown failed (${sendErr.message}), falling back to sendMessage`);
1566
- try { replyMsg = await bot.sendMessage(chatId, cleanOutput); } catch (e2) {
1567
- log('ERROR', `sendMessage fallback also failed: ${e2.message}`);
2125
+ const trackedAgentKey = String(chatId).startsWith('_agent_') ? String(chatId).slice(7) : null;
2126
+ if (replyMsg && replyMsg.message_id && session) {
2127
+ if (runtime.name === 'codex' && session.runtimeSessionObserved === false) {
2128
+ trackMsgSession(replyMsg.message_id, session, trackedAgentKey, { routeOnly: true });
2129
+ } else {
2130
+ trackMsgSession(replyMsg.message_id, session, trackedAgentKey);
2131
+ }
1568
2132
  }
1569
- }
1570
- if (replyMsg && replyMsg.message_id && session) trackMsgSession(replyMsg.message_id, session, String(chatId).startsWith('_agent_') ? String(chatId).slice(7) : null);
1571
-
1572
- await sendFileButtons(bot, chatId, mergeFileCollections(markedFiles, files));
1573
2133
 
1574
- // Timeout: also send the reason after the partial result
1575
- if (timedOut && error) {
1576
- try { await bot.sendMessage(chatId, error); } catch { /* */ }
1577
- }
2134
+ const fileMsgs = await sendFileButtons(bot, chatId, mergeFileCollections(markedFiles, files));
2135
+ if (session && Array.isArray(fileMsgs)) {
2136
+ for (const msg of fileMsgs) {
2137
+ if (!msg || !msg.message_id) continue;
2138
+ if (runtime.name === 'codex' && session.runtimeSessionObserved === false) {
2139
+ trackMsgSession(msg.message_id, session, trackedAgentKey, { routeOnly: true });
2140
+ } else {
2141
+ trackMsgSession(msg.message_id, session, trackedAgentKey);
2142
+ }
2143
+ }
2144
+ }
1578
2145
 
1579
- // Auto-name: if this was the first message and session has no name, generate one
1580
- if (runtime.name === 'claude' && wasNew && !getSessionName(session.id)) {
1581
- autoNameSession(chatId, session.id, prompt, session.cwd).catch(() => { });
1582
- }
2146
+ // Timeout: also send the reason after the partial result
2147
+ if (timedOut && error) {
2148
+ try { await bot.sendMessage(chatId, error); } catch { /* */ }
2149
+ }
1583
2150
 
1584
- // Auto-refresh memory-snapshot.md for this agent on first session message (fire-and-forget)
1585
- if (wasNew && boundProject && boundProject.agent_id) {
1586
- setImmediate(async () => {
1587
- try {
1588
- const memory = require('./memory');
1589
- const pKey = boundProjectKey || '';
1590
- const sessions = memory.recentSessions({ limit: 5, project: pKey });
1591
- const factsRaw = memory.searchFacts('', { limit: 10, project: pKey });
1592
- const facts = Array.isArray(factsRaw) ? factsRaw : [];
1593
- memory.close();
1594
- const snapshotContent = buildMemorySnapshotContent(sessions, facts);
1595
- const agentId = boundProject.agent_id;
1596
- if (refreshMemorySnapshot(agentId, snapshotContent, HOME)) {
1597
- log('DEBUG', `[AGENT] Memory snapshot refreshed for ${agentId}`);
1598
- }
1599
- } catch { /* non-critical — memory module may not be available */ }
1600
- });
1601
- }
1602
- return { ok: !timedOut };
1603
- } else {
1604
- const errMsg = error || 'Unknown error';
1605
- const userErrMsg = (errorCode === 'AUTH_REQUIRED' || errorCode === 'RATE_LIMIT')
1606
- ? errMsg
1607
- : `Error: ${errMsg.slice(0, 200)}`;
1608
- log('ERROR', `ask${runtime.name === 'codex' ? 'Codex' : 'Claude'} failed for ${chatId}: ${errMsg.slice(0, 300)} (${errorCode || 'NO_CODE'})`);
1609
-
1610
- // If session not found (expired/deleted), create new and retry once (Claude path)
1611
- if (runtime.name === 'claude' && (errMsg.includes('not found') || errMsg.includes('No session') || errMsg.includes('already in use'))) {
1612
- log('WARN', `Session ${session.id} unusable (${errMsg.includes('already in use') ? 'locked' : 'not found'}), creating new`);
1613
- session = createSession(sessionChatId, session.cwd, '', runtime.name);
1614
-
1615
- const retryArgs = runtime.buildArgs({
1616
- model,
1617
- readOnly,
1618
- daemonCfg,
1619
- session,
1620
- cwd: session.cwd,
1621
- });
2151
+ // Auto-name: if this was the first message and session has no name, generate one
2152
+ if (runtime.name === 'claude' && wasNew && !getSessionName(session.id)) {
2153
+ autoNameSession(chatId, session.id, prompt, session.cwd).catch(() => { });
2154
+ }
1622
2155
 
1623
- const retry = await spawnClaudeStreaming(
1624
- retryArgs,
1625
- fullPrompt,
1626
- session.cwd,
1627
- onStatus,
1628
- 600000,
1629
- chatId,
1630
- boundProjectKey || '',
1631
- runtime,
1632
- onSession,
1633
- );
1634
- if (retry.sessionId) await onSession(retry.sessionId);
1635
- if (retry.output) {
1636
- markSessionStarted(sessionChatId, runtime.name);
1637
- const { markedFiles: retryMarked, cleanOutput: retryClean } = parseFileMarkers(retry.output);
1638
- await bot.sendMarkdown(chatId, retryClean);
1639
- await sendFileButtons(bot, chatId, mergeFileCollections(retryMarked, retry.files));
1640
- return { ok: true };
1641
- } else {
1642
- log('ERROR', `askClaude retry failed: ${(retry.error || '').slice(0, 200)}`);
1643
- try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
1644
- return { ok: false, error: retry.error || errMsg };
2156
+ // Auto-refresh memory-snapshot.md for this agent on first session message (fire-and-forget)
2157
+ if (wasNew && boundProject && boundProject.agent_id) {
2158
+ setImmediate(async () => {
2159
+ try {
2160
+ const memory = require('./memory');
2161
+ const pKey = boundProjectKey || '';
2162
+ const sessions = memory.recentSessions({ limit: 5, project: pKey });
2163
+ const factsRaw = memory.searchFacts('', { limit: 10, project: pKey });
2164
+ const facts = Array.isArray(factsRaw) ? factsRaw : [];
2165
+ memory.close();
2166
+ const snapshotContent = buildMemorySnapshotContent(sessions, facts);
2167
+ const agentId = boundProject.agent_id;
2168
+ if (refreshMemorySnapshot(agentId, snapshotContent, HOME)) {
2169
+ log('DEBUG', `[AGENT] Memory snapshot refreshed for ${agentId}`);
2170
+ }
2171
+ } catch { /* non-critical — memory module may not be available */ }
2172
+ });
1645
2173
  }
2174
+ return { ok: !timedOut };
1646
2175
  } else {
1647
- // Auto-fallback: if custom provider/model fails, revert to anthropic + opus (Claude path only)
1648
- if (runtime.name === 'claude') {
1649
- const activeProv = providerMod ? providerMod.getActiveName() : 'anthropic';
1650
- const builtinModels = ENGINE_MODEL_CONFIG.claude.options;
1651
- if ((activeProv !== 'anthropic' || !builtinModels.includes(model)) && !errMsg.includes('Stopped by user')) {
1652
- try {
1653
- config = fallbackToDefaultProvider(`${activeProv}/${model} error: ${errMsg.slice(0, 100)}`);
1654
- await bot.sendMessage(chatId, `⚠️ ${activeProv}/${model} 失败,已回退到 anthropic/opus\n原因: ${errMsg.slice(0, 100)}`);
1655
- } catch (fallbackErr) {
1656
- log('ERROR', `Fallback failed: ${fallbackErr.message}`);
2176
+ const errMsg = error || 'Unknown error';
2177
+ const userErrMsg = (errorCode === 'AUTH_REQUIRED' || errorCode === 'RATE_LIMIT')
2178
+ ? errMsg
2179
+ : `Error: ${errMsg.slice(0, 200)}`;
2180
+ log('ERROR', `ask${runtime.name === 'codex' ? 'Codex' : 'Claude'} failed for ${chatId}: ${errMsg.slice(0, 300)} (${errorCode || 'NO_CODE'})`);
2181
+
2182
+ // If session not found / locked / thinking signature invalid — create new and retry once (Claude path)
2183
+ const _isThinkingSignatureError = isClaudeThinkingSignatureError(errMsg);
2184
+ const _isSessionResumeFail = errMsg.includes('not found') || errMsg.includes('No session') || errMsg.includes('already in use') || _isThinkingSignatureError;
2185
+ if (runtime.name === 'claude' && _isSessionResumeFail) {
2186
+ const _reason = errMsg.includes('already in use') ? 'locked' : _isThinkingSignatureError ? 'thinking-signature-invalid' : 'not found';
2187
+ log('WARN', `Session ${session.id} unusable (${_reason}), creating new`);
2188
+ session = createSession(sessionChatId, session.cwd, '', runtime.name);
2189
+
2190
+ const retryArgs = runtime.buildArgs({
2191
+ model,
2192
+ readOnly,
2193
+ daemonCfg,
2194
+ session,
2195
+ cwd: session.cwd,
2196
+ });
2197
+
2198
+ const retry = await spawnClaudeStreaming(
2199
+ retryArgs,
2200
+ fullPrompt,
2201
+ session.cwd,
2202
+ onStatus,
2203
+ 600000,
2204
+ chatId,
2205
+ boundProjectKey || '',
2206
+ normalizeSenderId(senderId),
2207
+ runtime,
2208
+ onSession,
2209
+ );
2210
+ if (retry.sessionId) await onSession(retry.sessionId);
2211
+ if (retry.output) {
2212
+ markSessionStarted(sessionChatId, runtime.name);
2213
+ const { markedFiles: retryMarked, cleanOutput: retryClean } = parseFileMarkers(retry.output);
2214
+ await bot.sendMarkdown(chatId, retryClean);
2215
+ await sendFileButtons(bot, chatId, mergeFileCollections(retryMarked, retry.files));
2216
+ return { ok: true };
2217
+ } else {
2218
+ log('ERROR', `askClaude retry failed: ${(retry.error || '').slice(0, 200)}`);
2219
+ const retryUserMsg = _isThinkingSignatureError
2220
+ ? formatClaudeResumeFallbackUserMessage(retry.error || errMsg)
2221
+ : userErrMsg;
2222
+ try { await bot.sendMessage(chatId, retryUserMsg); } catch { /* */ }
2223
+ return { ok: false, error: retry.error || errMsg };
2224
+ }
2225
+ } else {
2226
+ // Auto-fallback: if custom provider/model fails, revert to anthropic + opus (Claude path only)
2227
+ if (runtime.name === 'claude') {
2228
+ const activeProv = providerMod ? providerMod.getActiveName() : 'anthropic';
2229
+ const builtinModels = ENGINE_MODEL_CONFIG.claude.options;
2230
+ if ((activeProv !== 'anthropic' || !builtinModels.includes(model)) && !errMsg.includes('Stopped by user')) {
2231
+ try {
2232
+ config = fallbackToDefaultProvider(`${activeProv}/${model} error: ${errMsg.slice(0, 100)}`);
2233
+ await bot.sendMessage(chatId, `⚠️ ${activeProv}/${model} 失败,已回退到 anthropic/opus\n原因: ${errMsg.slice(0, 100)}`);
2234
+ } catch (fallbackErr) {
2235
+ log('ERROR', `Fallback failed: ${fallbackErr.message}`);
2236
+ try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
2237
+ }
2238
+ } else {
1657
2239
  try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
1658
2240
  }
1659
2241
  } else {
1660
2242
  try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
1661
2243
  }
1662
- } else {
1663
- try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
2244
+ return { ok: false, error: errMsg, errorCode };
1664
2245
  }
1665
- return { ok: false, error: errMsg, errorCode };
1666
2246
  }
1667
- }
1668
2247
 
1669
2248
  } catch (fatalErr) { // ── safety-net-catch ──
1670
2249
  clearInterval(typingTimer);
@@ -1687,9 +2266,23 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1687
2266
  shouldRetryCodexResumeFallback,
1688
2267
  formatEngineSpawnError,
1689
2268
  adaptDaemonHintForEngine,
2269
+ getSessionChatId,
2270
+ getCodexPermissionProfile,
2271
+ getActualCodexPermissionProfile,
2272
+ sameCodexPermissionProfile,
2273
+ inspectClaudeResumeSession,
2274
+ isClaudeThinkingSignatureError,
2275
+ formatClaudeResumeFallbackUserMessage,
2276
+ classifyCodexResumeFailure,
1690
2277
  canRetryCodexResume,
1691
2278
  markCodexResumeRetried,
2279
+ getCodexResumeRetryKey,
1692
2280
  CODEX_RESUME_RETRY_WINDOW_MS,
2281
+ shouldAutoRouteSkill,
2282
+ codexSandboxPrivilegeRank,
2283
+ codexApprovalPrivilegeRank,
2284
+ codexNeedsFallbackForRequestedPermissions,
2285
+ buildCodexFallbackBridgePrompt,
1693
2286
  },
1694
2287
  };
1695
2288
  }