metame-cli 1.5.4 → 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 (40) hide show
  1. package/README.md +6 -1
  2. package/index.js +277 -55
  3. package/package.json +2 -2
  4. package/scripts/agent-layer.js +4 -2
  5. package/scripts/bin/dispatch_to +17 -5
  6. package/scripts/daemon-admin-commands.js +264 -62
  7. package/scripts/daemon-agent-commands.js +188 -66
  8. package/scripts/daemon-bridges.js +447 -48
  9. package/scripts/daemon-claude-engine.js +650 -103
  10. package/scripts/daemon-command-router.js +134 -27
  11. package/scripts/daemon-command-session-route.js +118 -0
  12. package/scripts/daemon-default.yaml +2 -0
  13. package/scripts/daemon-engine-runtime.js +96 -20
  14. package/scripts/daemon-exec-commands.js +106 -50
  15. package/scripts/daemon-file-browser.js +63 -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 +34 -2
  19. package/scripts/daemon-session-commands.js +102 -45
  20. package/scripts/daemon-session-store.js +497 -66
  21. package/scripts/daemon-siri-bridge.js +234 -0
  22. package/scripts/daemon-siri-imessage.js +209 -0
  23. package/scripts/daemon-task-scheduler.js +10 -2
  24. package/scripts/daemon.js +610 -181
  25. package/scripts/docs/hook-config.md +7 -4
  26. package/scripts/docs/maintenance-manual.md +8 -1
  27. package/scripts/feishu-adapter.js +7 -15
  28. package/scripts/hooks/doc-router.js +29 -0
  29. package/scripts/hooks/intent-doc-router.js +54 -0
  30. package/scripts/hooks/intent-engine.js +9 -40
  31. package/scripts/intent-registry.js +59 -0
  32. package/scripts/memory-extract.js +59 -0
  33. package/scripts/mentor-engine.js +6 -0
  34. package/scripts/schema.js +1 -0
  35. package/scripts/self-reflect.js +110 -12
  36. package/scripts/session-analytics.js +160 -0
  37. package/scripts/signal-capture.js +1 -1
  38. package/scripts/team-dispatch.js +150 -11
  39. package/scripts/hooks/intent-agent-manage.js +0 -50
  40. package/scripts/hooks/intent-hook-config.js +0 -28
@@ -2,7 +2,14 @@
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
 
8
15
  /**
@@ -37,7 +44,6 @@ function createClaudeEngine(deps) {
37
44
  getActiveProviderEnv,
38
45
  activeProcesses,
39
46
  saveActivePids,
40
- messageQueue,
41
47
  log,
42
48
  yaml,
43
49
  providerMod,
@@ -52,7 +58,6 @@ function createClaudeEngine(deps) {
52
58
  isContentFile,
53
59
  sendFileButtons,
54
60
  findSessionFile,
55
- listRecentSessions,
56
61
  getSession,
57
62
  getSessionForEngine,
58
63
  createSession,
@@ -60,6 +65,9 @@ function createClaudeEngine(deps) {
60
65
  writeSessionName,
61
66
  markSessionStarted,
62
67
  isEngineSessionValid,
68
+ getCodexSessionSandboxProfile,
69
+ getCodexSessionPermissionMode,
70
+ getSessionRecentContext,
63
71
  gitCheckpoint,
64
72
  gitCheckpointAsync,
65
73
  recordTokens,
@@ -73,11 +81,42 @@ function createClaudeEngine(deps) {
73
81
  function getDefaultEngine() {
74
82
  return (typeof _getDefaultEngine === 'function') ? _getDefaultEngine() : 'claude';
75
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
+ }
76
106
  let mentorEngine = null;
77
107
  try { mentorEngine = require('./mentor-engine'); } catch { /* optional */ }
78
108
  let sessionAnalytics = null;
79
109
  try { sessionAnalytics = require('./session-analytics'); } catch { /* optional */ }
80
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
+
81
120
  const getEngineRuntime = typeof injectedGetEngineRuntime === 'function'
82
121
  ? injectedGetEngineRuntime
83
122
  : createEngineRuntimeFactory({ fs, path, HOME, CLAUDE_BIN, getActiveProviderEnv });
@@ -147,7 +186,11 @@ function createClaudeEngine(deps) {
147
186
  if (!state.sessions) state.sessions = {};
148
187
  const cur = state.sessions[chatId] || {};
149
188
  const patched = typeof patchFn === 'function' ? patchFn(cur) : cur;
150
- 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
+ }
151
194
  saveState(state);
152
195
  }).catch((e) => {
153
196
  log('WARN', `patchSessionSerialized failed for ${chatId}: ${e.message}`);
@@ -159,27 +202,35 @@ function createClaudeEngine(deps) {
159
202
  }
160
203
 
161
204
  const CODEX_RESUME_RETRY_WINDOW_MS = 10 * 60 * 1000;
162
- 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
207
+
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
+ }
163
213
 
164
- function canRetryCodexResume(chatId) {
165
- const key = String(chatId || '');
214
+ function canRetryCodexResume(chatId, kind = 'default') {
215
+ const key = getCodexResumeRetryKey(chatId, kind);
166
216
  if (!key) return false;
167
217
  const last = Number(_codexResumeRetryTs.get(key) || 0);
168
218
  if (!last) return true;
169
219
  return (Date.now() - last) > CODEX_RESUME_RETRY_WINDOW_MS;
170
220
  }
171
221
 
172
- function markCodexResumeRetried(chatId) {
173
- const key = String(chatId || '');
222
+ function markCodexResumeRetried(chatId, kind = 'default') {
223
+ const key = getCodexResumeRetryKey(chatId, kind);
174
224
  if (!key) return;
175
225
  _codexResumeRetryTs.set(key, Date.now());
176
226
  }
177
227
 
178
- function shouldRetryCodexResumeFallback({ runtimeName, wasResumeAttempt, output, error, errorCode, canRetry }) {
228
+ function shouldRetryCodexResumeFallback({ runtimeName, wasResumeAttempt, output, error, errorCode, canRetry, failureKind = '' }) {
179
229
  return runtimeName === 'codex'
180
230
  && !!wasResumeAttempt
181
231
  && !!error
182
232
  && (!output || !!errorCode)
233
+ && failureKind !== 'user-stop'
183
234
  && !!canRetry;
184
235
  }
185
236
 
@@ -205,6 +256,218 @@ function createClaudeEngine(deps) {
205
256
  return out;
206
257
  }
207
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
+
208
471
 
209
472
  /**
210
473
  * Parse [[FILE:...]] markers from Claude output.
@@ -497,6 +760,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
497
760
  timeoutMs = 600000,
498
761
  chatId = null,
499
762
  metameProject = '',
763
+ metameSenderId = '',
500
764
  runtime = null,
501
765
  onSession = null,
502
766
  ) {
@@ -516,7 +780,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
516
780
  cwd,
517
781
  stdio: ['pipe', 'pipe', 'pipe'],
518
782
  detached: process.platform !== 'win32',
519
- env: rt.buildEnv({ metameProject }),
783
+ env: rt.buildEnv({ metameProject, metameSenderId }),
520
784
  });
521
785
  log('INFO', `[TIMING:${chatId}] spawned ${rt.name} pid=${child.pid}`);
522
786
 
@@ -524,6 +788,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
524
788
  activeProcesses.set(chatId, {
525
789
  child,
526
790
  aborted: false,
791
+ abortReason: null,
527
792
  startedAt: _spawnAt,
528
793
  engine: rt.name,
529
794
  killSignal: rt.killSignal || 'SIGTERM',
@@ -556,6 +821,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
556
821
  const writtenFiles = [];
557
822
  const toolUsageLog = [];
558
823
 
824
+ void timeoutMs;
559
825
  const engineTimeouts = rt.timeouts || {};
560
826
  const IDLE_TIMEOUT_MS = engineTimeouts.idleMs || (5 * 60 * 1000);
561
827
  const TOOL_EXEC_TIMEOUT_MS = engineTimeouts.toolMs || (25 * 60 * 1000);
@@ -776,10 +1042,21 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
776
1042
 
777
1043
  const proc = chatId ? activeProcesses.get(chatId) : null;
778
1044
  const wasAborted = proc && proc.aborted;
1045
+ const abortReason = proc && proc.abortReason ? String(proc.abortReason) : '';
779
1046
  if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
780
1047
 
781
1048
  if (wasAborted) {
782
- 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
+ });
783
1060
  return;
784
1061
  }
785
1062
  if (killed) {
@@ -833,11 +1110,22 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
833
1110
 
834
1111
  // Track outbound message_id → session for reply-based session restoration.
835
1112
  // Keeps last 200 entries to avoid unbounded growth.
836
- function trackMsgSession(messageId, session, agentKey) {
837
- 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;
838
1117
  const st = loadState();
839
1118
  if (!st.msg_sessions) st.msg_sessions = {};
840
- 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
+ };
841
1129
  const keys = Object.keys(st.msg_sessions);
842
1130
  if (keys.length > 200) {
843
1131
  for (const k of keys.slice(0, keys.length - 200)) delete st.msg_sessions[k];
@@ -866,7 +1154,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
866
1154
  return loadConfig();
867
1155
  }
868
1156
 
869
- async function askClaude(bot, chatId, prompt, config, readOnly = false) {
1157
+ async function askClaude(bot, chatId, prompt, config, readOnly = false, senderId = null) {
870
1158
  const _t0 = Date.now();
871
1159
  log('INFO', `askClaude for ${chatId}: ${prompt.slice(0, 50)}`);
872
1160
  // Track interaction time for idle/sleep detection
@@ -885,7 +1173,11 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
885
1173
  let _lastStatusCardContent = null; // tracks last clean text written to card (for final-reply dedup)
886
1174
  // Early detect bound project for branded ack card (team members / dispatch agents)
887
1175
  const _ackChatIdStr = String(chatId);
888
- 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
+ };
889
1181
  const _ackBoundKey = _ackAgentMap[_ackChatIdStr] || projectKeyFromVirtualChatId(_ackChatIdStr);
890
1182
  const _ackBoundProj = _ackBoundKey && config.projects ? config.projects[_ackBoundKey] : null;
891
1183
  // _ackCardHeader: non-null for agents with icon/name (team members, dispatch); passed to editMessage to preserve header on streaming edits
@@ -895,12 +1187,14 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
895
1187
  // Fire-and-forget: don't await Telegram RTT before spawning the engine process.
896
1188
  // statusMsgId will be populated well before the first model output (~5s for codex).
897
1189
  // For branded agents: send a card with header so streaming edits preserve the agent identity.
898
- const _ackFn = (_ackCardHeader && bot.sendCard)
899
- ? () => bot.sendCard(chatId, { title: _ackCardHeader.title, body: '🤔', color: _ackCardHeader.color })
900
- : () => (bot.sendMarkdown ? bot.sendMarkdown(chatId, '🤔') : bot.sendMessage(chatId, '🤔'));
901
- _ackFn()
902
- .then(msg => { if (msg && msg.message_id) statusMsgId = msg.message_id; })
903
- .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
+ }
904
1198
  bot.sendTyping(chatId).catch(() => { });
905
1199
  const typingTimer = setInterval(() => {
906
1200
  bot.sendTyping(chatId).catch(() => { });
@@ -913,7 +1207,11 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
913
1207
 
914
1208
  // Agent nickname routing: "贾维斯" / "小美,帮我..." → switch project session
915
1209
  // Strict chats (chat_agent_map bound groups) must NOT switch agents via nickname
916
- const _strictAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
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
+ };
917
1215
  const _isStrictChatSession = !!(_strictAgentMap[String(chatId)] || projectKeyFromVirtualChatId(String(chatId)));
918
1216
  const agentMatch = _isStrictChatSession ? null : routeAgent(prompt, config);
919
1217
  if (agentMatch) {
@@ -935,12 +1233,17 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
935
1233
  // BUT: skip skill routing if agent addressed by nickname OR chat already has an active session
936
1234
  // (active conversation should never be hijacked by keyword-based skill matching)
937
1235
  const chatIdStr = String(chatId);
938
- const chatAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
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
+ };
939
1241
  const boundProjectKey = chatAgentMap[chatIdStr] || projectKeyFromVirtualChatId(chatIdStr);
940
1242
  const boundProject = boundProjectKey && config.projects ? config.projects[boundProjectKey] : null;
941
- // Each virtual chatId (including clones) keeps its own isolated session.
942
- // Parallel tasks must not share JSONL files concurrent writes cause corruption.
943
- const sessionChatId = boundProjectKey ? `_agent_${boundProjectKey}` : chatId;
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);
944
1247
  const sessionRaw = getSession(sessionChatId);
945
1248
  const boundCwd = (boundProject && boundProject.cwd) ? normalizeCwd(boundProject.cwd) : null;
946
1249
  const boundEngineName = (boundProject && boundProject.engine) ? normalizeEngineName(boundProject.engine) : getDefaultEngine();
@@ -950,40 +1253,85 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
950
1253
  (boundProject && boundProject.engine) || getDefaultEngine()
951
1254
  );
952
1255
  const runtime = getEngineRuntime(engineName);
1256
+ const requestedCodexPermissionProfile = engineName === 'codex'
1257
+ ? getCodexPermissionProfile(readOnly, daemonCfg)
1258
+ : null;
953
1259
 
954
1260
  // hasActiveSession: does the current engine have an ongoing conversation?
955
1261
  const hasActiveSession = sessionRaw && (
956
1262
  sessionRaw.engines ? !!(sessionRaw.engines[engineName]?.started) : !!sessionRaw.started
957
1263
  );
958
- const skill = (agentMatch || hasActiveSession) ? null : routeSkill(prompt);
1264
+ const detectedSkill = routeSkill(prompt);
1265
+ const skill = shouldAutoRouteSkill({
1266
+ agentMatch,
1267
+ hasActiveSession,
1268
+ boundProjectKey,
1269
+ skillName: detectedSkill,
1270
+ })
1271
+ ? detectedSkill
1272
+ : null;
959
1273
 
960
1274
  if (!sessionRaw) {
961
1275
  // No saved state for this chatId: start a fresh session.
962
1276
  // Note: daemon_state.json persists across restarts, so this only happens on truly first use
963
1277
  // or after an explicit /new command.
964
- createSession(sessionChatId, boundCwd || undefined, boundProject && boundProject.name ? boundProject.name : '', boundEngineName);
1278
+ createSession(
1279
+ sessionChatId,
1280
+ boundCwd || undefined,
1281
+ boundProject && boundProject.name ? boundProject.name : '',
1282
+ boundEngineName,
1283
+ boundEngineName === 'codex' ? requestedCodexPermissionProfile : undefined
1284
+ );
965
1285
  }
966
1286
 
967
1287
  // Resolve flat view for current engine (id + started are engine-specific; cwd is shared)
968
- let session = getSessionForEngine(sessionChatId, engineName) || { cwd: boundCwd || HOME, engine: engineName, id: null, started: false };
1288
+ let session = resolveSessionForEngine(sessionChatId, engineName) || { cwd: boundCwd || HOME, engine: engineName, id: null, started: false };
969
1289
  session.engine = engineName; // keep local copy for Codex resume detection below
1290
+ session.logicalChatId = sessionChatId;
970
1291
 
971
1292
  // Pre-spawn session validation: unified for all engines.
972
1293
  // Claude checks JSONL file existence; Codex checks SQLite. Same interface, different backend.
973
1294
  // Skip warning for virtual agents (team members) - they may use worktrees with fresh sessions
974
1295
  const isVirtualAgent = String(sessionChatId).startsWith('_agent_');
975
1296
  if (session.started && session.id && session.id !== '__continue__' && session.cwd) {
976
- const valid = isEngineSessionValid(engineName, session.id, session.cwd);
1297
+ const valid = validateEngineSession(engineName, session.id, session.cwd);
977
1298
  if (!valid) {
978
1299
  log('WARN', `${engineName} session ${session.id.slice(0, 8)} invalid for ${sessionChatId}; starting fresh ${engineName} session`);
979
1300
  if (!isVirtualAgent) {
980
1301
  await bot.sendMessage(chatId, '⚠️ 上次 session 已失效,已自动开启新 session。').catch(() => { });
981
1302
  }
982
- session = createSession(sessionChatId, session.cwd, boundProject && boundProject.name ? boundProject.name : '', engineName);
1303
+ session = createSession(
1304
+ sessionChatId,
1305
+ session.cwd,
1306
+ boundProject && boundProject.name ? boundProject.name : '',
1307
+ engineName,
1308
+ engineName === 'codex' ? requestedCodexPermissionProfile : undefined
1309
+ );
983
1310
  }
984
1311
  }
985
1312
 
986
- const daemonCfg = (config && config.daemon) || {};
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
+ }
987
1335
  const mentorCfg = (daemonCfg.mentor && typeof daemonCfg.mentor === 'object') ? daemonCfg.mentor : {};
988
1336
  const mentorEnabled = !!(mentorEngine && mentorCfg.enabled);
989
1337
  const excludeAgents = new Set(
@@ -1011,35 +1359,21 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1011
1359
  }
1012
1360
 
1013
1361
  // Build engine command — prefer per-engine model, fall back to legacy daemon.model
1014
- const model = resolveEngineModel(runtime.name, daemonCfg, boundProject && boundProject.model);
1015
- const args = runtime.buildArgs({
1016
- model,
1017
- readOnly,
1018
- daemonCfg,
1019
- session,
1020
- cwd: session.cwd,
1021
- });
1022
-
1023
- // Codex: write/refresh AGENTS.md = CLAUDE.md + SOUL.md on every new session.
1024
- // Written as a real file (not a symlink) for Windows compatibility.
1025
- // Refreshed each session so edits to CLAUDE.md or SOUL.md are always picked up.
1026
- // Codex auto-loads AGENTS.md from cwd and all parent dirs up to ~.
1027
- if (engineName === 'codex' && session.cwd && !session.started) {
1028
- try {
1029
- const parts = [];
1030
- const claudeMd = path.join(session.cwd, 'CLAUDE.md');
1031
- const soulMd = path.join(session.cwd, 'SOUL.md');
1032
- if (fs.existsSync(claudeMd)) parts.push(fs.readFileSync(claudeMd, 'utf8').trim());
1033
- if (fs.existsSync(soulMd)) {
1034
- const soulContent = fs.readFileSync(soulMd, 'utf8').trim();
1035
- if (soulContent) parts.push(soulContent);
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})`);
1036
1375
  }
1037
- if (parts.length > 0) {
1038
- fs.writeFileSync(path.join(session.cwd, 'AGENTS.md'), parts.join('\n\n'), 'utf8');
1039
- log('INFO', `Refreshed AGENTS.md (${parts.length} section(s)) in ${session.cwd}`);
1040
- }
1041
- } catch (e) {
1042
- log('WARN', `AGENTS.md refresh failed: ${e.message}`);
1376
+ model = resumeInspection.modelPin;
1043
1377
  }
1044
1378
  }
1045
1379
 
@@ -1060,9 +1394,29 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1060
1394
 
1061
1395
  // Memory & Knowledge Injection (RAG)
1062
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 */ }
1410
+ }
1411
+ }
1412
+
1063
1413
  // projectKey must be declared outside the try block so the daemonHint template below can reference it.
1064
1414
  const _cid0 = String(chatId);
1065
- const _agentMap0 = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
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
+ };
1066
1420
  const projectKey = _agentMap0[_cid0] || projectKeyFromVirtualChatId(_cid0);
1067
1421
  try {
1068
1422
  const memory = require('./memory');
@@ -1159,30 +1513,24 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1159
1513
  }
1160
1514
 
1161
1515
  // Inject daemon hints only on first message of a session
1162
- // Task-specific rules (3-5) are injected only when isTaskIntent() returns true (~250 token saving for casual chat)
1516
+ // Task-specific rules (3-4) are injected only when isTaskIntent() returns true (~250 token saving for casual chat)
1163
1517
  let daemonHint = '';
1164
1518
  if (!session.started) {
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
+ : '';
1165
1522
  const taskRules = isTaskIntent(prompt) ? `
1166
- 3. Knowledge retrieval: When you need context about a specific topic, past decisions, or lessons, call:
1167
- node ~/.metame/memory-search.js "关键词1" "keyword2"
1168
- If no relevant facts surface, check ~/.metame/memory/INDEX.md for available playbook/decision docs.
1169
- Use these before answering complex questions about MetaMe architecture or past decisions.
1170
- 4. Active memory: After confirming a new insight, bug root cause, or user preference, persist it with:
1523
+ 3. Active memory: After confirming a new insight, bug root cause, or user preference, persist it with:
1171
1524
  node ~/.metame/memory-write.js "Entity.sub" "relation_type" "value (20-300 chars)"
1172
1525
  Valid relations: tech_decision, bug_lesson, arch_convention, config_fact, config_change, workflow_rule, project_milestone
1173
1526
  Only write verified facts. Do not write speculative or process-description entries.
1174
- When you observe the user is clearly expert or beginner in a domain, note it in your response and suggest: "要不要把你的 {domain} 水平 ({level}) 记录到能力雷达?"
1175
- 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:
1176
1529
  \`mkdir -p ~/.metame/memory/now && printf '%s\\n' "## Current Task" "{task}" "" "## Progress" "{progress}" "" "## Next Step" "{next}" > ~/.metame/memory/now/${projectKey || 'default'}.md\`
1177
1530
  Keep it under 200 words. Clear it when the task is fully complete by running: \`> ~/.metame/memory/now/${projectKey || 'default'}.md\`` : '';
1178
1531
  daemonHint = `\n\n[System hints - DO NOT mention these to user:
1179
1532
  1. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
1180
- 2. File sending: User is on MOBILE. When they ask to see/download a file:
1181
- - Just FIND the file path (use Glob/ls if needed)
1182
- - Do NOT read or summarize the file content (wastes tokens)
1183
- - Add at END of response: [[FILE:/absolute/path/to/file]]
1184
- - Keep response brief: "请查收~! [[FILE:/path/to/file]]"
1185
- - Multiple files: use multiple [[FILE:...]] tags${zdpHint ? '\n Explanation depth (ZPD):\n' + zdpHint : ''}${taskRules}]`;
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}]`;
1186
1534
  }
1187
1535
 
1188
1536
  daemonHint = adaptDaemonHintForEngine(daemonHint, runtime.name);
@@ -1279,14 +1627,69 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1279
1627
  const langGuard = session.started
1280
1628
  ? ''
1281
1629
  : '\n\n[Respond in Simplified Chinese (简体中文) only. NEVER switch to Korean, Japanese, or other languages regardless of tool output or context language.]';
1282
- const fullPrompt = routedPrompt + daemonHint + agentHint + macAutomationHint + summaryHint + memoryHint + mentorHint + langGuard;
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}`);
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
+ }
1650
+
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);
1672
+ }
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}`);
1679
+ }
1680
+ }
1283
1681
 
1284
1682
  // Git checkpoint before Claude modifies files (for /undo).
1285
1683
  // Skip for virtual agents (team clones like _agent_yi) — each has its own worktree,
1286
1684
  // but checkpoint uses `git add -A` which could interfere with parallel work.
1287
1685
  const _isVirtualAgent = String(chatId).startsWith('_agent_') || String(chatId).startsWith('_scope_');
1288
1686
  if (!_isVirtualAgent) {
1289
- (gitCheckpointAsync || gitCheckpoint)(session.cwd, prompt).catch?.(() => { });
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 */ }
1290
1693
  }
1291
1694
  log('INFO', `[TIMING:${chatId}] pre-spawn +${Date.now() - _t0}ms (engine:${runtime.name} started:${session.started})`);
1292
1695
 
@@ -1352,11 +1755,21 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1352
1755
  ...session,
1353
1756
  id: safeNextId,
1354
1757
  engine: runtime.name,
1758
+ logicalChatId: sessionChatId,
1355
1759
  started: true,
1356
1760
  };
1357
1761
  await patchSessionSerialized(sessionChatId, (cur) => {
1358
1762
  const engines = { ...(cur.engines || {}) };
1359
- engines[runtime.name] = { ...(engines[runtime.name] || {}), id: safeNextId, started: true };
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
+ };
1360
1773
  return { ...cur, cwd: session.cwd || cur.cwd || HOME, engines };
1361
1774
  });
1362
1775
  if (runtime.name === 'codex' && wasStarted && prevSessionId && prevSessionId !== safeNextId && prevSessionId !== '__continue__') {
@@ -1364,7 +1777,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1364
1777
  }
1365
1778
  };
1366
1779
 
1367
- let output, error, errorCode, files, toolUsageLog, timedOut, usage, sessionId;
1780
+ let output, error, errorCode, files, toolUsageLog, timedOut, sessionId;
1368
1781
  try {
1369
1782
  ({
1370
1783
  output,
@@ -1373,7 +1786,6 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1373
1786
  timedOut,
1374
1787
  files,
1375
1788
  toolUsageLog,
1376
- usage,
1377
1789
  sessionId,
1378
1790
  } = await spawnClaudeStreaming(
1379
1791
  args,
@@ -1383,39 +1795,122 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1383
1795
  600000,
1384
1796
  chatId,
1385
1797
  boundProjectKey || '',
1798
+ normalizeSenderId(senderId),
1386
1799
  runtime,
1387
1800
  onSession,
1388
1801
  ));
1389
1802
 
1390
1803
  if (sessionId) await onSession(sessionId);
1391
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);
1392
1881
  if (shouldRetryCodexResumeFallback({
1393
1882
  runtimeName: runtime.name,
1394
1883
  wasResumeAttempt: wasCodexResumeAttempt,
1395
1884
  output,
1396
1885
  error,
1397
1886
  errorCode,
1398
- canRetry: canRetryCodexResume(chatId),
1887
+ failureKind: resumeFailure.kind,
1888
+ canRetry: canRetryCodexResume(chatId, resumeFailure.kind),
1399
1889
  })) {
1400
- markCodexResumeRetried(chatId);
1401
- log('WARN', `Codex resume failed for ${chatId}, retrying once with fresh exec: ${String(error).slice(0, 120)}`);
1402
- // Notify user explicitly — silent context loss is worse than a visible warning.
1403
- await bot.sendMessage(chatId, '⚠️ Codex session 已过期,上下文丢失。正在以全新 session 重试,请在回复后补充必要背景。').catch(() => { });
1404
- session = createSession(
1405
- sessionChatId,
1406
- session.cwd,
1407
- boundProject && boundProject.name ? boundProject.name : '',
1408
- 'codex'
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)}`
1409
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
+ }
1410
1905
  const retryArgs = runtime.buildArgs({
1411
1906
  model,
1412
1907
  readOnly,
1413
1908
  daemonCfg,
1414
1909
  session,
1415
1910
  cwd: session.cwd,
1911
+ permissionProfile: requestedCodexPermissionProfile,
1416
1912
  });
1417
- // Prepend a context-loss marker so Codex knows this is a fresh session mid-conversation.
1418
- const retryPrompt = `[Note: previous Codex session expired and could not be resumed. Treating this as a new session. User message follows:]\n\n${fullPrompt}`;
1913
+ const retryPrompt = `${resumeFailure.retryPromptPrefix}\n\n${fullPrompt}`;
1419
1914
  ({
1420
1915
  output,
1421
1916
  error,
@@ -1423,7 +1918,6 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1423
1918
  timedOut,
1424
1919
  files,
1425
1920
  toolUsageLog,
1426
- usage,
1427
1921
  sessionId,
1428
1922
  } = await spawnClaudeStreaming(
1429
1923
  retryArgs,
@@ -1433,6 +1927,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1433
1927
  600000,
1434
1928
  chatId,
1435
1929
  boundProjectKey || '',
1930
+ normalizeSenderId(senderId),
1436
1931
  runtime,
1437
1932
  onSession,
1438
1933
  ));
@@ -1507,7 +2002,11 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1507
2002
  }
1508
2003
 
1509
2004
  if (output) {
1510
- if (runtime.name === 'codex') _codexResumeRetryTs.delete(String(chatId));
2005
+ if (runtime.name === 'codex') {
2006
+ _codexResumeRetryTs.delete(getCodexResumeRetryKey(chatId, 'interrupted'));
2007
+ _codexResumeRetryTs.delete(getCodexResumeRetryKey(chatId, 'expired'));
2008
+ _codexResumeRetryTs.delete(getCodexResumeRetryKey(chatId, 'default'));
2009
+ }
1511
2010
  // Detect provider/model errors disguised as output (e.g., "model not found", API errors)
1512
2011
  if (runtime.name === 'claude') {
1513
2012
  const activeProvCheck = providerMod ? providerMod.getActiveName() : 'anthropic';
@@ -1527,7 +2026,12 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1527
2026
 
1528
2027
  // Mark session as started after first successful call
1529
2028
  const wasNew = !session.started;
1530
- if (wasNew) markSessionStarted(sessionChatId, engineName);
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
+ }
1531
2035
 
1532
2036
  const estimated = Math.ceil((prompt.length + output.length) / 4);
1533
2037
  const chatCategory = classifyChatUsage(chatId, {
@@ -1562,6 +2066,11 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1562
2066
  try {
1563
2067
  log('DEBUG', `[REPLY:${chatId}] statusMsgId=${statusMsgId} editFailed=${editFailed} activeProject=${activeProject && activeProject.name} lastCard=${_lastStatusCardContent ? _lastStatusCardContent.slice(0, 40) : 'null'}`);
1564
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
+
1565
2074
  // Strategy: always try to update the status card first (avoids sending a new card
1566
2075
  // while the old 🤔 card lingers, which would produce two messages).
1567
2076
  // If edit fails: try to delete the status card (awaited, not fire-and-forget).
@@ -1613,9 +2122,26 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1613
2122
  log('ERROR', `sendMessage fallback also failed: ${e2.message}`);
1614
2123
  }
1615
2124
  }
1616
- if (replyMsg && replyMsg.message_id && session) trackMsgSession(replyMsg.message_id, session, String(chatId).startsWith('_agent_') ? String(chatId).slice(7) : null);
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
+ }
2132
+ }
1617
2133
 
1618
- await sendFileButtons(bot, chatId, mergeFileCollections(markedFiles, files));
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
+ }
1619
2145
 
1620
2146
  // Timeout: also send the reason after the partial result
1621
2147
  if (timedOut && error) {
@@ -1653,9 +2179,12 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1653
2179
  : `Error: ${errMsg.slice(0, 200)}`;
1654
2180
  log('ERROR', `ask${runtime.name === 'codex' ? 'Codex' : 'Claude'} failed for ${chatId}: ${errMsg.slice(0, 300)} (${errorCode || 'NO_CODE'})`);
1655
2181
 
1656
- // If session not found (expired/deleted), create new and retry once (Claude path)
1657
- if (runtime.name === 'claude' && (errMsg.includes('not found') || errMsg.includes('No session') || errMsg.includes('already in use'))) {
1658
- log('WARN', `Session ${session.id} unusable (${errMsg.includes('already in use') ? 'locked' : 'not found'}), creating new`);
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`);
1659
2188
  session = createSession(sessionChatId, session.cwd, '', runtime.name);
1660
2189
 
1661
2190
  const retryArgs = runtime.buildArgs({
@@ -1674,6 +2203,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1674
2203
  600000,
1675
2204
  chatId,
1676
2205
  boundProjectKey || '',
2206
+ normalizeSenderId(senderId),
1677
2207
  runtime,
1678
2208
  onSession,
1679
2209
  );
@@ -1686,7 +2216,10 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1686
2216
  return { ok: true };
1687
2217
  } else {
1688
2218
  log('ERROR', `askClaude retry failed: ${(retry.error || '').slice(0, 200)}`);
1689
- try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
2219
+ const retryUserMsg = _isThinkingSignatureError
2220
+ ? formatClaudeResumeFallbackUserMessage(retry.error || errMsg)
2221
+ : userErrMsg;
2222
+ try { await bot.sendMessage(chatId, retryUserMsg); } catch { /* */ }
1690
2223
  return { ok: false, error: retry.error || errMsg };
1691
2224
  }
1692
2225
  } else {
@@ -1733,9 +2266,23 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1733
2266
  shouldRetryCodexResumeFallback,
1734
2267
  formatEngineSpawnError,
1735
2268
  adaptDaemonHintForEngine,
2269
+ getSessionChatId,
2270
+ getCodexPermissionProfile,
2271
+ getActualCodexPermissionProfile,
2272
+ sameCodexPermissionProfile,
2273
+ inspectClaudeResumeSession,
2274
+ isClaudeThinkingSignatureError,
2275
+ formatClaudeResumeFallbackUserMessage,
2276
+ classifyCodexResumeFailure,
1736
2277
  canRetryCodexResume,
1737
2278
  markCodexResumeRetried,
2279
+ getCodexResumeRetryKey,
1738
2280
  CODEX_RESUME_RETRY_WINDOW_MS,
2281
+ shouldAutoRouteSkill,
2282
+ codexSandboxPrivilegeRank,
2283
+ codexApprovalPrivilegeRank,
2284
+ codexNeedsFallbackForRequestedPermissions,
2285
+ buildCodexFallbackBridgePrompt,
1739
2286
  },
1740
2287
  };
1741
2288
  }