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
@@ -20,7 +20,6 @@ function createCommandRouter(deps) {
20
20
  getNoSleepProcess,
21
21
  activeProcesses,
22
22
  messageQueue,
23
- sleep,
24
23
  log,
25
24
  agentTools,
26
25
  pendingAgentFlows,
@@ -29,6 +28,71 @@ function createCommandRouter(deps) {
29
28
  getDefaultEngine,
30
29
  } = deps;
31
30
 
31
+ function clearQueuedTimer(chatId) {
32
+ const q = messageQueue && messageQueue.get(chatId);
33
+ if (q && q.timer) {
34
+ clearTimeout(q.timer);
35
+ q.timer = null;
36
+ }
37
+ }
38
+
39
+ function interruptActiveProcess(chatId) {
40
+ const proc = activeProcesses.get(chatId);
41
+ if (proc && proc.child) {
42
+ proc.aborted = true;
43
+ const signal = proc.killSignal || 'SIGTERM';
44
+ try { process.kill(-proc.child.pid, signal); } catch { try { proc.child.kill(signal); } catch { /* */ } }
45
+ return true;
46
+ }
47
+ return false;
48
+ }
49
+
50
+ function shouldPauseAndMergeFollowUps(chatId) {
51
+ const proc = activeProcesses.get(chatId);
52
+ return !!(proc && proc.engine === 'codex');
53
+ }
54
+
55
+ function getFollowUpDebounceMs(config) {
56
+ const raw = Number(config && config.daemon && config.daemon.follow_up_debounce_ms);
57
+ if (Number.isFinite(raw) && raw >= 300) return raw;
58
+ return 2500;
59
+ }
60
+
61
+ function buildMergedFollowUpPrompt(messages) {
62
+ return [
63
+ '继续上面的工作,并结合我刚刚连续补充的消息统一处理:',
64
+ '',
65
+ messages.join('\n'),
66
+ ].join('\n');
67
+ }
68
+
69
+ function scheduleQueuedResume(bot, chatId, config, readOnly, senderId) {
70
+ const q = messageQueue.get(chatId);
71
+ if (!q || q.mode !== 'resume-after-pause') return;
72
+ clearQueuedTimer(chatId);
73
+ const delay = getFollowUpDebounceMs(config);
74
+ q.timer = setTimeout(async () => {
75
+ const pending = messageQueue.get(chatId);
76
+ if (!pending || pending.mode !== 'resume-after-pause') return;
77
+ if (activeProcesses.has(chatId)) {
78
+ scheduleQueuedResume(bot, chatId, config, readOnly, senderId);
79
+ return;
80
+ }
81
+ const msgs = pending.messages.splice(0);
82
+ messageQueue.delete(chatId);
83
+ if (msgs.length === 0) return;
84
+ log('INFO', `Follow-up: resuming with ${msgs.length} merged queued message(s) for ${chatId}`);
85
+ resetCooldown(chatId);
86
+ try {
87
+ await askClaude(bot, chatId, buildMergedFollowUpPrompt(msgs), config, readOnly, senderId);
88
+ } catch (err) {
89
+ log('WARN', `Follow-up resume failed for ${chatId}: ${err.message}`);
90
+ try { await bot.sendMessage(chatId, `⚠️ 继续处理补充消息失败:${err.message}`); } catch { /* */ }
91
+ }
92
+ }, delay);
93
+ if (typeof q.timer.unref === 'function') q.timer.unref();
94
+ }
95
+
32
96
  function resolveFlowTtlMs() {
33
97
  const raw = typeof agentFlowTtlMs === 'function' ? agentFlowTtlMs() : agentFlowTtlMs;
34
98
  const num = Number(raw);
@@ -214,10 +278,19 @@ function createCommandRouter(deps) {
214
278
  return null;
215
279
  }
216
280
 
281
+ function buildSessionChatId(chatId, projectKey = null) {
282
+ const rawChatId = String(chatId || '');
283
+ const inferredKey = projectKey || projectKeyFromVirtualChatId(rawChatId);
284
+ if (rawChatId.startsWith('_agent_') || rawChatId.startsWith('_scope_')) return rawChatId;
285
+ return inferredKey ? `_bound_${inferredKey}` : rawChatId;
286
+ }
287
+
217
288
  function getBoundProjectForChat(chatId, cfg) {
218
289
  const map = {
219
290
  ...(cfg.telegram ? cfg.telegram.chat_agent_map : {}),
220
291
  ...(cfg.feishu ? cfg.feishu.chat_agent_map : {}),
292
+ ...(cfg.imessage ? cfg.imessage.chat_agent_map : {}),
293
+ ...(cfg.siri_bridge ? cfg.siri_bridge.chat_agent_map : {}),
221
294
  };
222
295
  const key = map[String(chatId)];
223
296
  const proj = key && cfg.projects ? cfg.projects[key] : null;
@@ -585,18 +658,28 @@ function createCommandRouter(deps) {
585
658
  // --- chat_agent_map: auto-switch agent based on dedicated chatId ---
586
659
  // Configure in daemon.yaml: feishu.chat_agent_map or telegram.chat_agent_map
587
660
  // e.g. chat_agent_map: { "oc_xxx": "personal", "oc_yyy": "metame" }
588
- const chatAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
661
+ const chatAgentMap = {
662
+ ...(config.telegram ? config.telegram.chat_agent_map : {}),
663
+ ...(config.feishu ? config.feishu.chat_agent_map : {}),
664
+ ...(config.imessage ? config.imessage.chat_agent_map : {}),
665
+ ...(config.siri_bridge ? config.siri_bridge.chat_agent_map : {}),
666
+ };
589
667
  const _chatIdStr = String(chatId);
590
668
  const mappedKey = chatAgentMap[_chatIdStr] ||
591
669
  projectKeyFromVirtualChatId(_chatIdStr);
592
670
  if (mappedKey && config.projects && config.projects[mappedKey]) {
593
671
  const proj = config.projects[mappedKey];
594
672
  const projCwd = normalizeCwd(proj.cwd);
595
- const cur = loadState().sessions?.[chatId];
596
- const curEngine = String((cur && cur.engine) || getDefaultEngine()).toLowerCase();
673
+ const sessionChatId = buildSessionChatId(chatId, mappedKey);
674
+ const cur = loadState().sessions?.[sessionChatId];
597
675
  const projEngine = String((proj && proj.engine) || getDefaultEngine()).toLowerCase();
598
- if (!cur || cur.cwd !== projCwd || curEngine !== projEngine) {
599
- attachOrCreateSession(chatId, projCwd, proj.name || mappedKey, proj.engine || getDefaultEngine());
676
+ // Multi-engine format stores engines in cur.engines object; legacy format uses cur.engine string.
677
+ // Check whether the session already has a slot for the project's configured engine.
678
+ const curHasEngine = cur && (
679
+ cur.engines ? !!cur.engines[projEngine] : String(cur.engine || '').toLowerCase() === projEngine
680
+ );
681
+ if (!cur || cur.cwd !== projCwd || !curHasEngine) {
682
+ attachOrCreateSession(sessionChatId, projCwd, proj.name || mappedKey, proj.engine || getDefaultEngine());
600
683
  }
601
684
  }
602
685
 
@@ -609,7 +692,7 @@ function createCommandRouter(deps) {
609
692
  return;
610
693
  }
611
694
 
612
- const adminResult = await handleAdminCommand({ bot, chatId, text, config, state });
695
+ const adminResult = await handleAdminCommand({ bot, chatId, text, config, state, senderId });
613
696
  if (adminResult.handled) {
614
697
  config = adminResult.config || config;
615
698
  return;
@@ -673,16 +756,10 @@ function createCommandRouter(deps) {
673
756
  if (activeProcesses.has(chatId) && INTERRUPT_RE.test(text.trim())) {
674
757
  // Kill current process but preserve session for resume
675
758
  if (messageQueue.has(chatId)) {
676
- const q = messageQueue.get(chatId);
677
- if (q.timer) clearTimeout(q.timer);
759
+ clearQueuedTimer(chatId);
678
760
  messageQueue.delete(chatId);
679
761
  }
680
- const proc = activeProcesses.get(chatId);
681
- if (proc && proc.child) {
682
- proc.aborted = true;
683
- const signal = proc.killSignal || 'SIGTERM';
684
- try { process.kill(-proc.child.pid, signal); } catch { try { proc.child.kill(signal); } catch { /* */ } }
685
- }
762
+ interruptActiveProcess(chatId);
686
763
  await bot.sendMessage(chatId, '⏸ 好的,听你说');
687
764
  return;
688
765
  }
@@ -695,31 +772,60 @@ function createCommandRouter(deps) {
695
772
  if (handled) {
696
773
  // /last attached the session — now send "继续" to actually resume the conversation
697
774
  resetCooldown(chatId);
698
- await askClaude(bot, chatId, '继续上面的工作', config, readOnly);
775
+ await askClaude(bot, chatId, '继续上面的工作', config, readOnly, senderId);
699
776
  return;
700
777
  }
701
778
  // No session found — fall through to normal askClaude
702
779
  }
703
780
 
704
- // If a task is running: queue message, DON'T kill will be sent as follow-up after completion
781
+ // While collecting follow-up messages after a pause, keep merging them until the debounce window closes.
782
+ if (messageQueue.has(chatId)) {
783
+ const q = messageQueue.get(chatId);
784
+ if (q && q.mode === 'resume-after-pause') {
785
+ q.messages.push(text);
786
+ scheduleQueuedResume(bot, chatId, config, readOnly, senderId);
787
+ return;
788
+ }
789
+ }
790
+
791
+ // If a task is running: pause it, collect the user's burst of messages, then resume with a merged follow-up.
705
792
  if (activeProcesses.has(chatId)) {
793
+ if (!shouldPauseAndMergeFollowUps(chatId)) {
794
+ const isFirst = !messageQueue.has(chatId);
795
+ if (isFirst) {
796
+ messageQueue.set(chatId, { messages: [] });
797
+ }
798
+ const q = messageQueue.get(chatId);
799
+ if (q.messages.length >= 10) {
800
+ await bot.sendMessage(chatId, '⚠️ 排队已满(10条),请等当前任务完成');
801
+ return;
802
+ }
803
+ q.messages.push(text);
804
+ if (isFirst) {
805
+ await bot.sendMessage(chatId, '📝 收到,完成后继续处理');
806
+ }
807
+ return;
808
+ }
706
809
  const isFirst = !messageQueue.has(chatId);
707
810
  if (isFirst) {
708
- messageQueue.set(chatId, { messages: [] });
811
+ messageQueue.set(chatId, { messages: [], mode: 'resume-after-pause', timer: null });
709
812
  }
710
813
  const q = messageQueue.get(chatId);
711
- if (q.messages.length >= 10) {
712
- await bot.sendMessage(chatId, '⚠️ 排队已满(10条),请等当前任务完成');
713
- return;
714
- }
715
814
  q.messages.push(text);
716
815
  if (isFirst) {
717
- await bot.sendMessage(chatId, '📝 收到,完成后继续处理');
816
+ interruptActiveProcess(chatId);
817
+ await bot.sendMessage(chatId, '⏸ 已暂停当前任务,你可以继续连发,我会自动合并后续内容再继续');
718
818
  }
819
+ scheduleQueuedResume(bot, chatId, config, readOnly, senderId);
719
820
  return;
720
821
  }
721
822
  // Strict mode: chats with a fixed agent in chat_agent_map must not cross-dispatch
722
- const _strictChatAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
823
+ const _strictChatAgentMap = {
824
+ ...(config.telegram ? config.telegram.chat_agent_map : {}),
825
+ ...(config.feishu ? config.feishu.chat_agent_map : {}),
826
+ ...(config.imessage ? config.imessage.chat_agent_map : {}),
827
+ ...(config.siri_bridge ? config.siri_bridge.chat_agent_map : {}),
828
+ };
723
829
  const _isStrictChat = !!(_strictChatAgentMap[String(chatId)] || projectKeyFromVirtualChatId(String(chatId)));
724
830
 
725
831
  // Nickname-only switch: bypass cooldown + budget (no Claude call)
@@ -729,7 +835,7 @@ function createCommandRouter(deps) {
729
835
  if (quickAgent && !quickAgent.rest) {
730
836
  const { key, proj } = quickAgent;
731
837
  const projCwd = normalizeCwd(proj.cwd);
732
- attachOrCreateSession(chatId, projCwd, proj.name || key, proj.engine || getDefaultEngine());
838
+ attachOrCreateSession(buildSessionChatId(chatId, key), projCwd, proj.name || key, proj.engine || getDefaultEngine());
733
839
  log('INFO', `Agent switch via nickname: ${key} (${projCwd})`);
734
840
  await bot.sendMessage(chatId, `${proj.icon || '🤖'} ${proj.name || key} 在线`);
735
841
  return;
@@ -755,7 +861,7 @@ function createCommandRouter(deps) {
755
861
  await bot.sendMessage(chatId, 'Daily token budget exceeded.');
756
862
  return;
757
863
  }
758
- const claudeResult = await askClaude(bot, chatId, text, config, readOnly);
864
+ const claudeResult = await askClaude(bot, chatId, text, config, readOnly, senderId);
759
865
  const claudeFailed = !!(claudeResult && claudeResult.ok === false);
760
866
  const claudeAborted = !!(claudeResult && claudeResult.error === 'Stopped by user');
761
867
  if (claudeFailed && !claudeAborted && !macLocalFirst && macFallbackEnabled && allowLocalMacControl) {
@@ -773,13 +879,14 @@ function createCommandRouter(deps) {
773
879
  // Use while-loop instead of recursion to avoid unbounded stack growth
774
880
  while (messageQueue.has(chatId)) {
775
881
  const q = messageQueue.get(chatId);
882
+ if (q && q.mode === 'resume-after-pause') break;
776
883
  const msgs = q.messages.splice(0);
777
884
  messageQueue.delete(chatId);
778
885
  if (msgs.length === 0) break;
779
886
  const combined = msgs.join('\n');
780
887
  log('INFO', `Follow-up: processing ${msgs.length} queued message(s) for ${chatId}`);
781
888
  resetCooldown(chatId);
782
- const followUp = await askClaude(bot, chatId, combined, config, readOnly);
889
+ const followUp = await askClaude(bot, chatId, combined, config, readOnly, senderId);
783
890
  if (followUp && followUp.error === 'Stopped by user') break;
784
891
  }
785
892
 
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+
3
+ function createCommandSessionResolver(deps) {
4
+ const {
5
+ path,
6
+ loadConfig,
7
+ loadState,
8
+ getSession,
9
+ getSessionForEngine,
10
+ getDefaultEngine = () => 'claude',
11
+ } = deps;
12
+
13
+ function normalizeEngineName(name) {
14
+ return String(name || '').trim().toLowerCase() === 'codex' ? 'codex' : getDefaultEngine();
15
+ }
16
+
17
+ function inferStoredEngine(rawSession) {
18
+ if (!rawSession || typeof rawSession !== 'object') return getDefaultEngine();
19
+ if (rawSession.engine) return normalizeEngineName(rawSession.engine);
20
+ const slots = rawSession.engines && typeof rawSession.engines === 'object' ? rawSession.engines : null;
21
+ if (!slots) return getDefaultEngine();
22
+ const started = Object.entries(slots).find(([, slot]) => slot && slot.started);
23
+ if (started) return normalizeEngineName(started[0]);
24
+ const available = Object.keys(slots);
25
+ return available.length === 1 ? normalizeEngineName(available[0]) : getDefaultEngine();
26
+ }
27
+
28
+ function buildBoundSessionChatId(projectKey) {
29
+ const key = String(projectKey || '').trim();
30
+ return key ? `_bound_${key}` : '';
31
+ }
32
+
33
+ function normalizeRouteCwd(cwd) {
34
+ if (!cwd) return null;
35
+ try {
36
+ return path.resolve(String(cwd));
37
+ } catch {
38
+ return String(cwd);
39
+ }
40
+ }
41
+
42
+ function getSessionRoute(chatId) {
43
+ const cfg = loadConfig();
44
+ const state = loadState();
45
+ const chatKey = String(chatId);
46
+ const agentMap = { ...(cfg.telegram ? cfg.telegram.chat_agent_map : {}), ...(cfg.feishu ? cfg.feishu.chat_agent_map : {}) };
47
+ const boundKey = agentMap[chatKey] || null;
48
+ const boundProj = boundKey && cfg.projects ? cfg.projects[boundKey] : null;
49
+ const stickyKey = state && state.team_sticky ? state.team_sticky[chatKey] : null;
50
+ const stickyMember = stickyKey && boundProj && Array.isArray(boundProj.team)
51
+ ? boundProj.team.find((m) => m && m.key === stickyKey)
52
+ : null;
53
+
54
+ if (stickyMember) {
55
+ return {
56
+ sessionChatId: `_agent_${stickyMember.key}`,
57
+ cwd: normalizeRouteCwd(stickyMember.cwd || (boundProj && boundProj.cwd) || null),
58
+ engine: normalizeEngineName(stickyMember.engine || (boundProj && boundProj.engine)),
59
+ };
60
+ }
61
+
62
+ if (boundProj) {
63
+ return {
64
+ sessionChatId: buildBoundSessionChatId(boundKey),
65
+ cwd: normalizeRouteCwd(boundProj.cwd || null),
66
+ engine: normalizeEngineName(boundProj.engine),
67
+ };
68
+ }
69
+
70
+ const rawSession = getSession(chatId);
71
+ return {
72
+ sessionChatId: String(chatId),
73
+ cwd: rawSession && rawSession.cwd ? normalizeRouteCwd(rawSession.cwd) : null,
74
+ engine: inferStoredEngine(rawSession),
75
+ };
76
+ }
77
+
78
+ function getActiveSession(chatId) {
79
+ const route = getSessionRoute(chatId);
80
+ const rawSession = getSession(route.sessionChatId) || getSession(chatId);
81
+ const engine = normalizeEngineName((rawSession && rawSession.engine) || route.engine);
82
+ const engineSession = getSessionForEngine(route.sessionChatId, engine)
83
+ || getSessionForEngine(chatId, engine);
84
+ if (engineSession && engineSession.id) {
85
+ return {
86
+ route,
87
+ sessionKey: route.sessionChatId,
88
+ engine,
89
+ session: engineSession,
90
+ };
91
+ }
92
+ if (rawSession && rawSession.id) {
93
+ return {
94
+ route,
95
+ sessionKey: route.sessionChatId,
96
+ engine,
97
+ session: { cwd: rawSession.cwd, engine, id: rawSession.id, started: !!rawSession.started },
98
+ };
99
+ }
100
+ return {
101
+ route,
102
+ sessionKey: route.sessionChatId,
103
+ engine,
104
+ session: null,
105
+ };
106
+ }
107
+
108
+ return {
109
+ normalizeEngineName,
110
+ inferStoredEngine,
111
+ buildBoundSessionChatId,
112
+ normalizeRouteCwd,
113
+ getSessionRoute,
114
+ getActiveSession,
115
+ };
116
+ }
117
+
118
+ module.exports = { createCommandSessionResolver };
@@ -4,12 +4,14 @@
4
4
  telegram:
5
5
  enabled: false
6
6
  bot_token: null
7
+ admin_chat_id: null
7
8
  allowed_chat_ids: []
8
9
 
9
10
  feishu:
10
11
  enabled: false
11
12
  app_id: null
12
13
  app_secret: null
14
+ admin_chat_id: null
13
15
  allowed_chat_ids: []
14
16
  remote_dispatch:
15
17
  enabled: false
@@ -239,7 +239,7 @@ function parseCodexStreamEvent(line) {
239
239
  }
240
240
 
241
241
  function buildClaudeArgs(options = {}) {
242
- const { model = ENGINE_MODEL_CONFIG.claude.main, readOnly = false, daemonCfg = {}, session = {} } = options;
242
+ const { model = ENGINE_MODEL_CONFIG.claude.main, readOnly = false, session = {} } = options;
243
243
  const args = ['-p', '--model', model];
244
244
  if (readOnly) {
245
245
  const readOnlyTools = ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task'];
@@ -260,8 +260,76 @@ function buildClaudeArgs(options = {}) {
260
260
  return args;
261
261
  }
262
262
 
263
+ function normalizeCodexSandboxMode(value, fallback = 'danger-full-access') {
264
+ const text = String(value || '').trim().toLowerCase();
265
+ if (!text) return fallback;
266
+ if (text === 'read-only' || text === 'readonly') return 'read-only';
267
+ if (text === 'workspace-write' || text === 'workspace') return 'workspace-write';
268
+ if (
269
+ text === 'danger-full-access'
270
+ || text === 'dangerous'
271
+ || text === 'full-access'
272
+ || text === 'full'
273
+ || text === 'bypass'
274
+ || text === 'writable'
275
+ ) return 'danger-full-access';
276
+ return fallback;
277
+ }
278
+
279
+ function normalizeCodexApprovalPolicy(value, fallback = 'never') {
280
+ const text = String(value || '').trim().toLowerCase();
281
+ if (!text) return fallback;
282
+ if (text === 'never' || text === 'no' || text === 'none') return 'never';
283
+ if (text === 'on-failure' || text === 'on_failure' || text === 'failure') return 'on-failure';
284
+ if (text === 'on-request' || text === 'on_request' || text === 'request') return 'on-request';
285
+ if (text === 'untrusted') return 'untrusted';
286
+ return fallback;
287
+ }
288
+
289
+ function resolveCodexPermissionProfile(options = {}) {
290
+ const { readOnly = false, daemonCfg = {}, session = {} } = options;
291
+ if (readOnly) {
292
+ return {
293
+ sandboxMode: 'read-only',
294
+ approvalPolicy: 'never',
295
+ permissionMode: 'read-only',
296
+ };
297
+ }
298
+
299
+ const codexCfg = (daemonCfg && daemonCfg.codex && typeof daemonCfg.codex === 'object') ? daemonCfg.codex : {};
300
+ const sandboxMode = normalizeCodexSandboxMode(
301
+ codexCfg.sandbox_mode
302
+ || codexCfg.sandboxMode
303
+ || codexCfg.sandbox
304
+ || codexCfg.permission_mode
305
+ || codexCfg.permissionMode
306
+ || session.sandboxMode
307
+ || session.permissionMode,
308
+ 'danger-full-access'
309
+ );
310
+ const approvalPolicy = normalizeCodexApprovalPolicy(
311
+ codexCfg.approval_policy
312
+ || codexCfg.approvalPolicy
313
+ || session.approvalPolicy,
314
+ sandboxMode === 'danger-full-access' ? 'never' : 'on-failure'
315
+ );
316
+
317
+ return {
318
+ sandboxMode,
319
+ approvalPolicy,
320
+ permissionMode: sandboxMode,
321
+ };
322
+ }
323
+
263
324
  function buildCodexArgs(options = {}) {
264
- const { model = ENGINE_MODEL_CONFIG.codex.main, readOnly = false, daemonCfg = {}, session = {}, cwd } = options;
325
+ const {
326
+ model = ENGINE_MODEL_CONFIG.codex.main,
327
+ readOnly = false,
328
+ daemonCfg = {},
329
+ session = {},
330
+ cwd,
331
+ permissionProfile = null,
332
+ } = options;
265
333
  const isResume = (session && session.started && session.id && session.id !== '__continue__');
266
334
  const args = isResume
267
335
  ? ['exec', 'resume', session.id]
@@ -272,16 +340,13 @@ function buildCodexArgs(options = {}) {
272
340
  // -C (cwd) is only supported on fresh exec, not resume
273
341
  if (cwd && !isResume) args.push('-C', cwd);
274
342
 
275
- // Permission flags are only valid on fresh exec, not resume.
276
- // `codex exec resume` does not accept -s or --dangerously-bypass-approvals-and-sandbox.
277
- if (!isResume) {
278
- if (readOnly) {
279
- args.push('-s', 'read-only');
280
- } else {
281
- // Mobile sessions: user cannot click permission dialogs.
282
- // Security relies on allowed_chat_ids whitelist, not tool restrictions.
283
- args.push('--dangerously-bypass-approvals-and-sandbox');
284
- }
343
+ const effectivePermissionProfile = permissionProfile || resolveCodexPermissionProfile({ readOnly, daemonCfg, session });
344
+ if (effectivePermissionProfile.sandboxMode === 'danger-full-access' && effectivePermissionProfile.approvalPolicy === 'never') {
345
+ // Keep the legacy shortcut for the fully-trusted mobile/default path.
346
+ args.push('--dangerously-bypass-approvals-and-sandbox');
347
+ } else {
348
+ // codex 0.114.0 removed --ask-for-approval; only -s <sandboxMode> is needed
349
+ args.push('-s', effectivePermissionProfile.sandboxMode);
285
350
  }
286
351
 
287
352
  // "-" means prompt is read from stdin.
@@ -289,6 +354,18 @@ function buildCodexArgs(options = {}) {
289
354
  return args;
290
355
  }
291
356
 
357
+ function buildCodexEnv(baseEnv = {}, { metameProject = '', metameSenderId = '' } = {}) {
358
+ const env = { ...baseEnv, METAME_PROJECT: metameProject, METAME_SENDER_ID: String(metameSenderId || '') };
359
+ const strippedKeys = [
360
+ 'CODEX_THREAD_ID',
361
+ 'METAME_ACTIVE_SESSION',
362
+ 'CLAUDE_CODE_SSE_PORT',
363
+ ];
364
+ for (const key of strippedKeys) delete env[key];
365
+ if (env.CODEX_HOME && !fs.existsSync(env.CODEX_HOME)) delete env.CODEX_HOME;
366
+ return env;
367
+ }
368
+
292
369
  function createEngineRuntimeFactory(deps = {}) {
293
370
  const home = deps.HOME || os.homedir();
294
371
  const claudeBin = deps.CLAUDE_BIN || resolveBinary('claude', { ...deps, HOME: home });
@@ -308,12 +385,7 @@ function createEngineRuntimeFactory(deps = {}) {
308
385
  killSignal: 'SIGTERM',
309
386
  timeouts: { idleMs: 10 * 60 * 1000, toolMs: 25 * 60 * 1000, ceilingMs: 60 * 60 * 1000 },
310
387
  buildArgs: buildCodexArgs,
311
- buildEnv: ({ metameProject = '' } = {}) => {
312
- const env = { ...process.env, METAME_PROJECT: metameProject };
313
- // Unset CODEX_HOME if it points to a non-existent path (corrupted env var)
314
- if (env.CODEX_HOME && !fs.existsSync(env.CODEX_HOME)) delete env.CODEX_HOME;
315
- return env;
316
- },
388
+ buildEnv: ({ metameProject = '', metameSenderId = '' } = {}) => buildCodexEnv(process.env, { metameProject, metameSenderId }),
317
389
  parseStreamEvent: parseCodexStreamEvent,
318
390
  classifyError: classifyEngineError,
319
391
  };
@@ -326,9 +398,9 @@ function createEngineRuntimeFactory(deps = {}) {
326
398
  killSignal: 'SIGTERM',
327
399
  timeouts: { idleMs: 5 * 60 * 1000, toolMs: 25 * 60 * 1000, ceilingMs: 60 * 60 * 1000 },
328
400
  buildArgs: buildClaudeArgs,
329
- buildEnv: ({ metameProject = '' } = {}) => ({
401
+ buildEnv: ({ metameProject = '', metameSenderId = '' } = {}) => ({
330
402
  ...(() => {
331
- const env = { ...process.env, ...getActiveProviderEnv(), METAME_PROJECT: metameProject };
403
+ const env = { ...process.env, ...getActiveProviderEnv(), METAME_PROJECT: metameProject, METAME_SENDER_ID: String(metameSenderId || '') };
332
404
  delete env.CLAUDECODE;
333
405
  return env;
334
406
  })(),
@@ -354,6 +426,10 @@ module.exports = {
354
426
  parseCodexStreamEvent,
355
427
  buildClaudeArgs,
356
428
  buildCodexArgs,
429
+ buildCodexEnv,
430
+ normalizeCodexSandboxMode,
431
+ normalizeCodexApprovalPolicy,
432
+ resolveCodexPermissionProfile,
357
433
  BUILTIN_CLAUDE_MODEL_VALUES,
358
434
  },
359
435
  };