metame-cli 1.5.4 → 1.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +6 -1
  2. package/index.js +277 -55
  3. package/package.json +3 -2
  4. package/scripts/agent-layer.js +4 -2
  5. package/scripts/bin/dispatch_to +18 -6
  6. package/scripts/bin/push-clean.sh +72 -0
  7. package/scripts/daemon-admin-commands.js +266 -64
  8. package/scripts/daemon-agent-commands.js +188 -66
  9. package/scripts/daemon-bridges.js +475 -50
  10. package/scripts/daemon-checkpoints.js +84 -30
  11. package/scripts/daemon-claude-engine.js +651 -103
  12. package/scripts/daemon-command-router.js +134 -27
  13. package/scripts/daemon-command-session-route.js +118 -0
  14. package/scripts/daemon-default.yaml +2 -0
  15. package/scripts/daemon-dispatch-cards.js +185 -0
  16. package/scripts/daemon-engine-runtime.js +96 -20
  17. package/scripts/daemon-exec-commands.js +106 -50
  18. package/scripts/daemon-file-browser.js +63 -7
  19. package/scripts/daemon-notify.js +18 -4
  20. package/scripts/daemon-ops-commands.js +28 -6
  21. package/scripts/daemon-remote-dispatch.js +34 -2
  22. package/scripts/daemon-session-commands.js +102 -45
  23. package/scripts/daemon-session-store.js +497 -66
  24. package/scripts/daemon-siri-bridge.js +234 -0
  25. package/scripts/daemon-siri-imessage.js +209 -0
  26. package/scripts/daemon-task-scheduler.js +10 -2
  27. package/scripts/{team-dispatch.js → daemon-team-dispatch.js} +150 -11
  28. package/scripts/daemon.js +484 -181
  29. package/scripts/docs/hook-config.md +7 -4
  30. package/scripts/docs/maintenance-manual.md +10 -3
  31. package/scripts/docs/pointer-map.md +2 -2
  32. package/scripts/feishu-adapter.js +7 -15
  33. package/scripts/hooks/doc-router.js +29 -0
  34. package/scripts/hooks/intent-doc-router.js +54 -0
  35. package/scripts/hooks/intent-engine.js +9 -40
  36. package/scripts/intent-registry.js +59 -0
  37. package/scripts/memory-extract.js +59 -0
  38. package/scripts/mentor-engine.js +6 -0
  39. package/scripts/schema.js +1 -0
  40. package/scripts/self-reflect.js +110 -12
  41. package/scripts/session-analytics.js +160 -0
  42. package/scripts/signal-capture.js +1 -1
  43. package/scripts/hooks/intent-agent-manage.js +0 -50
  44. package/scripts/hooks/intent-hook-config.js +0 -28
package/scripts/daemon.js CHANGED
@@ -47,7 +47,7 @@ const LOG_FILE = path.join(METAME_DIR, 'daemon.log');
47
47
  const BRAIN_FILE = path.join(HOME, '.claude_profile.yaml');
48
48
  const DISPATCH_DIR = path.join(METAME_DIR, 'dispatch');
49
49
  const DISPATCH_LOG = path.join(DISPATCH_DIR, 'dispatch-log.jsonl');
50
- const { socketPath, needsSocketCleanup } = require('./platform');
50
+ const { sleepSync, socketPath, needsSocketCleanup } = require('./platform');
51
51
  const SOCK_PATH = socketPath(METAME_DIR);
52
52
 
53
53
  // Resolve claude binary path (daemon may not inherit user's full PATH)
@@ -69,6 +69,8 @@ const CLAUDE_BIN = (() => {
69
69
  // Skill evolution module (hot path + cold path)
70
70
  let skillEvolution = null;
71
71
  try { skillEvolution = require('./skill-evolution'); } catch { /* graceful fallback */ }
72
+ let userAcl = null;
73
+ try { userAcl = require('./daemon-user-acl'); } catch { /* optional */ }
72
74
  const {
73
75
  normalizeRemoteDispatchConfig,
74
76
  encodePacket: encodeRemoteDispatchPacket,
@@ -84,18 +86,15 @@ function isMacLocalOrchestratorIntent(prompt) {
84
86
  const text = String(prompt || '').trim();
85
87
  if (!text) return false;
86
88
 
87
- // Explicit macOS automation keywords.
88
- if (/\b(?:mac|macos|applescript|osascript|jxa|hammerspoon|aerospace|yabai|skhd|raycast|launchctl|keyboard maestro)\b/i.test(text)) {
89
- return true;
90
- }
91
- if (/(自动化|辅助功能|系统设置|隐私|权限|锁屏|锁定屏幕|睡眠|休眠|静音|取消静音|音量)/.test(text)) {
92
- return true;
93
- }
89
+ const hasAutomationVerb = /(?:自动化|脚本|控制|操作|执行|设置|调整|打开|关闭|启动|退出|切到|唤起|锁屏|锁定屏幕|睡眠|休眠|静音|取消静音|调(?:高|低|整)?音量|open|launch|quit|activate|lock\s*screen|sleep|mute|unmute|set\s+volume|run\s+(?:an?\s+)?script)/i.test(text);
90
+ const hasMacTool = /\b(?:mac|macos|applescript|osascript|jxa|hammerspoon|aerospace|yabai|skhd|raycast|launchctl|keyboard maestro|shortcuts)\b/i.test(text);
91
+ const hasMacTarget = /(?:微信|WeChat|飞书|Feishu|Finder|Safari|Terminal|iTerm|系统设置|System Settings|辅助功能|隐私|权限|屏幕录制|自动化|电脑|桌面|访达|System Events|LaunchAgent|快捷指令|锁屏|锁定屏幕|睡眠|休眠|静音|音量|mac)/i.test(text);
94
92
 
95
- // General verbs must be paired with explicit macOS targets to avoid over-routing.
96
- const hasAction = /(?:打开|关闭|启动|退出|切到|唤起|锁屏|锁定屏幕|睡眠|休眠|静音|取消静音|调(?:高|低|整)?音量|open|launch|quit|activate|lock\s*screen|sleep|mute|unmute)/i.test(text);
97
- const hasTarget = /(?:微信|WeChat|飞书|Feishu|Finder|Safari|Terminal|iTerm|系统设置|System Settings|电脑|System Events|mac)/i.test(text);
98
- return hasAction && hasTarget;
93
+ // Require an actual automation ask. Mentioning "macOS" or "权限" alone should not route.
94
+ if (hasMacTool && hasAutomationVerb) return true;
95
+
96
+ // Natural-language control only triggers when both the action and the macOS target are explicit.
97
+ return hasAutomationVerb && hasMacTarget;
99
98
  }
100
99
 
101
100
  const SKILL_ROUTES = [
@@ -148,13 +147,22 @@ const { createSessionCommandHandler } = require('./daemon-session-commands');
148
147
  const { createSessionStore } = require('./daemon-session-store');
149
148
  const { createCheckpointUtils } = require('./daemon-checkpoints');
150
149
  const { createBridgeStarter } = require('./daemon-bridges');
151
- const { buildTeamRosterHint } = require('./team-dispatch');
150
+ const { buildTeamRosterHint, buildEnrichedPrompt, updateDispatchContextFiles } = require('./daemon-team-dispatch');
151
+ const {
152
+ resolveDispatchTarget,
153
+ buildTeamTaskResumeHint,
154
+ appendTeamTaskResumeHint,
155
+ buildDispatchResponseCard,
156
+ buildDispatchTaskCard,
157
+ buildDispatchReceipt,
158
+ sendDispatchTaskCard,
159
+ } = require('./daemon-dispatch-cards');
152
160
  const { createFileBrowser } = require('./daemon-file-browser');
153
161
  const { createPidManager, setupRuntimeWatchers } = require('./daemon-runtime-lifecycle');
154
162
  const { repairAgentLayer } = require('./agent-layer');
155
163
  const { createNotifier } = require('./daemon-notify');
156
164
  const { createClaudeEngine } = require('./daemon-claude-engine');
157
- const { createEngineRuntimeFactory, detectDefaultEngine, resolveEngineModel, ENGINE_MODEL_CONFIG, ENGINE_DISTILL_MAP, ENGINE_DEFAULT_MODEL } = require('./daemon-engine-runtime');
165
+ const { createEngineRuntimeFactory, detectDefaultEngine, resolveEngineModel, ENGINE_MODEL_CONFIG } = require('./daemon-engine-runtime');
158
166
  const { createCommandRouter } = require('./daemon-command-router');
159
167
  const { createTaskScheduler } = require('./daemon-task-scheduler');
160
168
  const { createAgentTools } = require('./daemon-agent-tools');
@@ -520,15 +528,6 @@ function recordTokens(state, tokens, meta = null) {
520
528
  }
521
529
 
522
530
 
523
- function getBudgetWarning(config, state) {
524
- const limit = (config.budget && config.budget.daily_limit) || 50000;
525
- const threshold = (config.budget && config.budget.warning_threshold) || 0.8;
526
- const ratio = state.budget.tokens_used / limit;
527
- if (ratio >= 1) return 'exceeded';
528
- if (ratio >= threshold) return 'warning';
529
- return 'ok';
530
- }
531
-
532
531
  const taskBoard = createTaskBoard({
533
532
  logger: (msg) => log('WARN', msg),
534
533
  });
@@ -540,12 +539,58 @@ const taskBoard = createTaskBoard({
540
539
  // Late-bound reference to handleCommand (defined later in file)
541
540
  let _handleCommand = null;
542
541
  let _dispatchBridgeRef = null; // Store bridge (not bot) so .bot is always the live object after reconnects
542
+ const _pendingRemoteDispatches = new Map();
543
543
  function setDispatchHandler(fn) { _handleCommand = fn; }
544
544
 
545
545
  function getRemoteDispatchConfig(config) {
546
546
  return normalizeRemoteDispatchConfig(config || {});
547
547
  }
548
548
 
549
+ function trackRemoteDispatch(packet) {
550
+ if (!packet || packet.type !== 'task') return;
551
+ const requestId = String(packet.id || '').trim();
552
+ const targetChatId = String(packet.source_chat_id || '').trim();
553
+ if (!requestId || !targetChatId) return;
554
+ const liveBot = _dispatchBridgeRef && _dispatchBridgeRef.bot;
555
+ const timeoutMs = 15000;
556
+ const existing = _pendingRemoteDispatches.get(requestId);
557
+ if (existing && existing.timer) clearTimeout(existing.timer);
558
+ const timer = setTimeout(async () => {
559
+ _pendingRemoteDispatches.delete(requestId);
560
+ const text = [
561
+ '⏱️ 远端 Dispatch 超时',
562
+ '',
563
+ `目标: ${packet.to_peer}:${packet.target_project || 'unknown'}`,
564
+ `请求: ${requestId}`,
565
+ `状态: 15s 内未收到回执`,
566
+ ].join('\n');
567
+ log('WARN', `Remote dispatch timeout id=${requestId} target=${packet.to_peer}:${packet.target_project || 'unknown'}`);
568
+ if (!liveBot) return;
569
+ try {
570
+ if (liveBot.sendMarkdown) await liveBot.sendMarkdown(targetChatId, text);
571
+ else await liveBot.sendMessage(targetChatId, text);
572
+ } catch (e) {
573
+ log('WARN', `Remote dispatch timeout delivery failed: ${e.message}`);
574
+ }
575
+ }, timeoutMs);
576
+ _pendingRemoteDispatches.set(requestId, {
577
+ id: requestId,
578
+ targetChatId,
579
+ targetPeer: String(packet.to_peer || '').trim(),
580
+ targetProject: String(packet.target_project || '').trim(),
581
+ timer,
582
+ });
583
+ }
584
+
585
+ function resolveTrackedRemoteDispatch(requestId) {
586
+ const key = String(requestId || '').trim();
587
+ if (!key) return null;
588
+ const tracked = _pendingRemoteDispatches.get(key) || null;
589
+ if (tracked && tracked.timer) clearTimeout(tracked.timer);
590
+ if (tracked) _pendingRemoteDispatches.delete(key);
591
+ return tracked;
592
+ }
593
+
549
594
  async function sendRemoteDispatch(packet, config) {
550
595
  const rd = getRemoteDispatchConfig(config);
551
596
  const liveBot = _dispatchBridgeRef && _dispatchBridgeRef.bot;
@@ -562,6 +607,10 @@ async function sendRemoteDispatch(packet, config) {
562
607
  from_peer: rd.selfPeer,
563
608
  }, rd.secret);
564
609
  await liveBot.sendMessage(rd.chatId, body);
610
+ log('INFO', `Remote dispatch sent type=${packet.type} id=${id} to=${packet.to_peer}:${packet.target_project || 'unknown'} via=${rd.chatId}`);
611
+ if (packet.type === 'task') {
612
+ trackRemoteDispatch({ ...packet, id }, config);
613
+ }
565
614
  return { success: true, id };
566
615
  } catch (e) {
567
616
  return { success: false, error: e.message };
@@ -587,38 +636,91 @@ function createNullBot(onOutput) {
587
636
  };
588
637
  }
589
638
 
639
+ function stripLeadingPlanSection(text) {
640
+ const src = String(text || '');
641
+ if (!src.trim()) return '';
642
+ const normalized = src.replace(/\r\n/g, '\n');
643
+ const paragraphs = normalized.split(/\n\s*\n/);
644
+ if (paragraphs.length === 0) return normalized.trim();
645
+ const first = String(paragraphs[0] || '').trim();
646
+ if (!/^计划[::]/.test(first)) return normalized.trim();
647
+ const rest = paragraphs.slice(1).join('\n\n').trim();
648
+ if (rest) return rest;
649
+ const lines = normalized.split('\n');
650
+ const remaining = lines.slice(1).join('\n').trim();
651
+ return remaining || first.replace(/^计划[::]\s*/, '').trim();
652
+ }
653
+
590
654
  /**
591
655
  * Forward bot: routes all calls to a real bot with a fixed chatId.
592
656
  * Used for dispatch tasks so Claude's streaming output appears in the target's Feishu channel.
593
657
  */
594
- function createStreamForwardBot(realBot, chatId, onOutput = null) {
658
+ function createStreamForwardBot(realBot, chatId, onOutput = null, opts = {}) {
595
659
  // Track edit-broken state independently so dispatch failures don't poison realBot's flag
596
660
  let _editBroken = false;
661
+ const ready = opts && opts.ready && typeof opts.ready.then === 'function'
662
+ ? opts.ready.catch(() => {})
663
+ : Promise.resolve();
664
+ async function waitUntilReady() {
665
+ await ready;
666
+ }
667
+ function normalizeOutput(payload) {
668
+ const text = typeof payload === 'object'
669
+ ? (payload.body || payload.title || JSON.stringify(payload))
670
+ : String(payload);
671
+ return opts.stripPlan !== false ? stripLeadingPlanSection(text) : text;
672
+ }
673
+ async function deliver(text, rawText = text) {
674
+ const displayText = normalizeOutput(text);
675
+ if (onOutput) onOutput(rawText);
676
+ if (opts.responseCard && realBot.sendCard) {
677
+ return realBot.sendCard(chatId, {
678
+ title: opts.responseCard.title,
679
+ body: displayText,
680
+ color: opts.responseCard.color || 'blue',
681
+ });
682
+ }
683
+ return realBot.sendMessage(chatId, displayText);
684
+ }
597
685
  return {
598
686
  sendMessage: async (_, text) => {
687
+ await waitUntilReady();
599
688
  log('INFO', `[StreamBot→${chatId.slice(-8)}] msg: ${String(text).slice(0, 80)}`);
600
- if (onOutput) onOutput(text);
601
- return realBot.sendMessage(chatId, text);
689
+ return deliver(text, text);
602
690
  },
603
691
  sendMarkdown: async (_, text) => {
692
+ await waitUntilReady();
604
693
  log('INFO', `[StreamBot→${chatId.slice(-8)}] md: ${String(text).slice(0, 80)}`);
694
+ if (opts.responseCard && realBot.sendCard) {
695
+ const displayText = normalizeOutput(text);
696
+ if (onOutput) onOutput(text);
697
+ return realBot.sendCard(chatId, {
698
+ title: opts.responseCard.title,
699
+ body: displayText,
700
+ color: opts.responseCard.color || 'blue',
701
+ });
702
+ }
703
+ const displayText = normalizeOutput(text);
605
704
  if (onOutput) onOutput(text);
606
- return realBot.sendMarkdown(chatId, text);
705
+ return realBot.sendMarkdown(chatId, displayText);
607
706
  },
608
707
  sendCard: async (_, card) => {
708
+ await waitUntilReady();
609
709
  const title = typeof card === 'object' ? (card.title || card.body || '').slice(0, 60) : String(card).slice(0, 60);
610
710
  log('INFO', `[StreamBot→${chatId.slice(-8)}] card: ${title}`);
611
711
  if (onOutput) onOutput(typeof card === 'object' ? (card.body || card.title || JSON.stringify(card)) : card);
612
712
  return realBot.sendCard(chatId, card);
613
713
  },
614
714
  sendRawCard: async (_, header, elements) => {
715
+ await waitUntilReady();
615
716
  log('INFO', `[StreamBot→${chatId.slice(-8)}] rawcard: ${String(header).slice(0, 60)}`);
616
717
  if (onOutput) onOutput(header);
617
718
  return realBot.sendRawCard(chatId, header, elements);
618
719
  },
619
- sendButtons: async (_, text, buttons) => realBot.sendButtons(chatId, text, buttons),
620
- sendTyping: async () => realBot.sendTyping(chatId),
720
+ sendButtons: async (_, text, buttons) => { await waitUntilReady(); return realBot.sendButtons(chatId, text, buttons); },
721
+ sendTyping: async () => { await waitUntilReady(); return realBot.sendTyping(chatId); },
621
722
  editMessage: async (_, msgId, text) => {
723
+ await waitUntilReady();
622
724
  if (_editBroken) return false;
623
725
  log('INFO', `[StreamBot→${chatId.slice(-8)}] edit ${String(msgId).slice(-8)}: ${String(text).slice(0, 60)}`);
624
726
  try {
@@ -631,8 +733,8 @@ function createStreamForwardBot(realBot, chatId, onOutput = null) {
631
733
  return false;
632
734
  }
633
735
  },
634
- deleteMessage: async (_, msgId) => realBot.deleteMessage(chatId, msgId),
635
- sendFile: async (_, filePath, caption) => realBot.sendFile(chatId, filePath, caption),
736
+ deleteMessage: async (_, msgId) => { await waitUntilReady(); return realBot.deleteMessage(chatId, msgId); },
737
+ sendFile: async (_, filePath, caption) => { await waitUntilReady(); return realBot.sendFile(chatId, filePath, caption); },
636
738
  downloadFile: async (...args) => realBot.downloadFile(...args),
637
739
  };
638
740
  }
@@ -802,6 +904,7 @@ function dispatchTask(targetProject, message, config, replyFn, streamOptions = n
802
904
  const fullMsg = {
803
905
  id: `d_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
804
906
  from: message.from || 'unknown',
907
+ source_sender_id: String(message.source_sender_id || '').trim() || '',
805
908
  to: targetProject,
806
909
  type: message.type || 'task',
807
910
  priority: message.priority || 'normal',
@@ -859,79 +962,23 @@ function dispatchTask(targetProject, message, config, replyFn, streamOptions = n
859
962
  if (!fs.existsSync(DISPATCH_DIR)) fs.mkdirSync(DISPATCH_DIR, { recursive: true });
860
963
  fs.appendFileSync(DISPATCH_LOG, JSON.stringify({ ...fullMsg, dispatched_at: new Date().toISOString() }) + '\n', 'utf8');
861
964
 
862
- // Auto-update now/shared.md and team shared files for cross-agent visibility
965
+ // Auto-update scoped dispatch context files; only TeamTask writes shared state.
863
966
  try {
864
- const NOW_DIR = path.join(HOME, '.metame', 'memory', 'now');
865
- const SHARED_FILE = path.join(NOW_DIR, 'shared.md');
866
- const SHARED_DIR = path.join(HOME, '.metame', 'memory', 'shared');
867
- if (!fs.existsSync(NOW_DIR)) fs.mkdirSync(NOW_DIR, { recursive: true });
868
-
869
- const now = new Date();
870
- const timeStr = now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
871
- const dateStr = now.toISOString().slice(0, 10);
872
-
873
- // Get sender display name
874
- const fromProj = config && config.projects ? config.projects[fullMsg.from] : null;
875
- const fromName = fromProj ? (fromProj.name || fullMsg.from) : (fullMsg.from || 'unknown');
876
- const fromIcon = fromProj ? (fromProj.icon || '🤖') : '🤖';
877
-
878
- // Get target display name
879
- const toProj = config && config.projects ? config.projects[targetProject] : null;
880
- const toName = toProj ? (toProj.name || targetProject) : targetProject;
881
- const toIcon = toProj ? (toProj.icon || '🤖') : '🤖';
882
-
883
- const taskTitle = payload.title || '';
884
- const taskPrompt = payload.prompt || '';
885
-
886
- // Update shared.md
887
- const content = `# 共享当前状态
888
- **最后更新**: ${timeStr} **更新者**: ${fromName} (${fullMsg.from})
889
-
890
- ## 当前任务
891
- - **派发给**: ${toIcon} ${toName} (${targetProject})
892
- - **任务**: ${taskTitle || taskPrompt.slice(0, 60)}
893
- - **时间**: ${timeStr}
894
-
895
- ## 任务链
896
- ${fullMsg.chain ? fullMsg.chain.join(' → ') : fullMsg.from} → ${targetProject}
897
- `;
898
- fs.writeFileSync(SHARED_FILE, content, 'utf8');
899
-
900
- // Update tasks.md if shared directory exists
901
- const tasksFile = path.join(SHARED_DIR, 'tasks.md');
902
- if (fs.existsSync(SHARED_DIR)) {
903
- const taskLine = `- [${dateStr}] ${fromIcon} ${fromName} → ${toIcon} ${toName}: ${taskTitle || taskPrompt.slice(0, 40)}`;
904
- let tasksContent = '';
905
- if (fs.existsSync(tasksFile)) {
906
- tasksContent = fs.readFileSync(tasksFile, 'utf8');
907
- } else {
908
- tasksContent = '# 任务看板\n\n## 🔄 进行中\n\n## ✅ 已完成\n\n## 📅 待开始\n';
909
- }
910
- // Insert task under "进行中" section
911
- if (!tasksContent.includes(taskLine)) {
912
- const lines = tasksContent.split('\n');
913
- const newLines = [];
914
- let inProgress = false;
915
- for (const line of lines) {
916
- newLines.push(line);
917
- if (line.includes('## 🔄 进行中')) {
918
- inProgress = true;
919
- } else if (inProgress && line.startsWith('## ')) {
920
- newLines.push(taskLine);
921
- inProgress = false;
922
- }
923
- }
924
- if (inProgress) newLines.push(taskLine);
925
- fs.writeFileSync(tasksFile, newLines.join('\n'), 'utf8');
926
- }
927
- }
967
+ updateDispatchContextFiles({
968
+ fs,
969
+ path,
970
+ baseDir: METAME_DIR,
971
+ fullMsg,
972
+ targetProject,
973
+ config,
974
+ envelope,
975
+ logger: (msg) => log('WARN', msg),
976
+ });
928
977
  } catch (e) {
929
- log('WARN', `Failed to update shared files: ${e.message}`);
978
+ log('WARN', `Failed to update dispatch context files: ${e.message}`);
930
979
  }
931
980
 
932
- const rawPrompt = envelope
933
- ? buildPromptFromTaskEnvelope(envelope, fullMsg.payload.prompt || fullMsg.payload.title || '')
934
- : (fullMsg.payload.prompt || fullMsg.payload.title || 'No prompt provided');
981
+ const rawPrompt = buildDispatchPrompt(targetProject, fullMsg, envelope);
935
982
 
936
983
  // Inject sender identity when dispatched by another agent (not directly from user)
937
984
  const userSources = new Set(['unknown', 'claude_session', '_claude_session', 'user']);
@@ -957,10 +1004,25 @@ ${fullMsg.chain ? fullMsg.chain.join(' → ') : fullMsg.from} → ${targetProjec
957
1004
  const dispatchChatId = buildDispatchChatId(targetProject, envelope && envelope.scope_id);
958
1005
  const sessionMode = forceNew ? 'fresh session (forced)' : 'existing virtual session';
959
1006
  log('INFO', `Dispatching ${fullMsg.type} to ${targetProject} via ${sessionMode}: ${rawPrompt.slice(0, 80)}`);
1007
+ const streamReady = streamOptions?.bot && streamOptions?.chatId
1008
+ ? (() => {
1009
+ if (typeof streamOptions.preDispatch === 'function') {
1010
+ return Promise.resolve()
1011
+ .then(() => streamOptions.preDispatch())
1012
+ .catch(e => log('WARN', `Dispatch prelude failed: ${e.message}`));
1013
+ }
1014
+ if (streamOptions.sendTaskCard === false) return Promise.resolve();
1015
+ const card = buildDispatchTaskCard(fullMsg, targetProject, config);
1016
+ return Promise.resolve()
1017
+ .then(() => sendDispatchTaskCard(streamOptions.bot, streamOptions.chatId, card))
1018
+ .catch(e => log('WARN', `Dispatch task card failed: ${e.message}`));
1019
+ })()
1020
+ : Promise.resolve();
960
1021
 
961
1022
  let _taskFinalized = false;
962
1023
  const outputHandler = (output) => {
963
1024
  const outStr = typeof output === 'object' ? (output.body || JSON.stringify(output)) : String(output);
1025
+ const displayOut = envelope ? appendTeamTaskResumeHint(outStr, envelope.task_id, envelope.scope_id) : outStr;
964
1026
  log('INFO', `Dispatch output from ${targetProject}: ${outStr.slice(0, 200)}`);
965
1027
  if (envelope && taskBoard && !_taskFinalized && outStr.trim().length > 2) {
966
1028
  const status = inferTaskStatusFromOutput(outStr);
@@ -979,7 +1041,7 @@ ${fullMsg.chain ? fullMsg.chain.join(' → ') : fullMsg.from} → ${targetProjec
979
1041
  _taskFinalized = true;
980
1042
  }
981
1043
  if (replyFn && outStr.trim().length > 2) {
982
- replyFn(outStr);
1044
+ replyFn(displayOut);
983
1045
  } else if (!replyFn && fullMsg.callback && fullMsg.from && config) {
984
1046
  // Write result to sender's inbox before dispatching callback
985
1047
  try {
@@ -994,7 +1056,7 @@ ${fullMsg.chain ? fullMsg.chain.join(' → ') : fullMsg.from} → ${targetProjec
994
1056
  `TS: ${new Date().toISOString()}`,
995
1057
  `SUBJECT: ${subject}`,
996
1058
  '',
997
- outStr.trim().slice(0, 2000),
1059
+ displayOut.slice(0, 2000),
998
1060
  ].join('\n');
999
1061
  fs.writeFileSync(inboxFile, body, 'utf8');
1000
1062
  } catch (e) {
@@ -1002,12 +1064,13 @@ ${fullMsg.chain ? fullMsg.chain.join(' → ') : fullMsg.from} → ${targetProjec
1002
1064
  }
1003
1065
  dispatchTask(fullMsg.from, {
1004
1066
  from: targetProject,
1067
+ source_sender_id: fullMsg.source_sender_id || '',
1005
1068
  type: 'callback',
1006
1069
  priority: 'normal',
1007
1070
  payload: {
1008
1071
  title: `任务完成: ${fullMsg.payload.title || fullMsg.id}`,
1009
1072
  original_id: fullMsg.id,
1010
- output: outStr.slice(0, 500),
1073
+ output: displayOut.slice(0, 500),
1011
1074
  },
1012
1075
  chain: [], // reset chain for callbacks
1013
1076
  }, config);
@@ -1016,11 +1079,14 @@ ${fullMsg.chain ? fullMsg.chain.join(' → ') : fullMsg.from} → ${targetProjec
1016
1079
  // If streamOptions provided, use real bot so output appears in target's Feishu channel.
1017
1080
  // Otherwise fall back to nullBot which captures output for replyFn.
1018
1081
  const nullBot = streamOptions?.bot && streamOptions?.chatId
1019
- ? createStreamForwardBot(streamOptions.bot, streamOptions.chatId, outputHandler)
1082
+ ? createStreamForwardBot(streamOptions.bot, streamOptions.chatId, outputHandler, {
1083
+ ready: streamReady,
1084
+ stripPlan: streamOptions.stripPlan !== false,
1085
+ responseCard: streamOptions.responseCard || null,
1086
+ })
1020
1087
  : createNullBot(outputHandler);
1021
- // Permission inheritance: if daemon runs with dangerously_skip_permissions, dispatched agents
1022
- // inherit the same level they need Write access for implementation tasks.
1023
- // Otherwise fall back to readOnly (safe default for untrusted daemon configs).
1088
+ // Trusted dispatches (user / bound agent / team member) keep write access.
1089
+ // Only unknown senders are downgraded to read-only.
1024
1090
  // When forceNew=true, clear any cached session for this virtual chatId so
1025
1091
  // attachOrCreateSession in handleCommand actually creates a fresh Claude session.
1026
1092
  if (forceNew) {
@@ -1030,7 +1096,7 @@ ${fullMsg.chain ? fullMsg.chain.join(' → ') : fullMsg.from} → ${targetProjec
1030
1096
  saveState(st);
1031
1097
  }
1032
1098
  }
1033
- const dispatchReadOnly = !(config.daemon && config.daemon.dangerously_skip_permissions);
1099
+ const dispatchReadOnly = resolveDispatchReadOnly(message, config, targetProject);
1034
1100
  if (envelope && taskBoard) {
1035
1101
  taskBoard.markTaskStatus(envelope.task_id, 'running', { summary: `dispatched via ${sessionMode}` });
1036
1102
  taskBoard.appendTaskEvent(envelope.task_id, 'task_started', targetProject, { session_mode: sessionMode });
@@ -1043,7 +1109,12 @@ ${fullMsg.chain ? fullMsg.chain.join(' → ') : fullMsg.from} → ${targetProjec
1043
1109
  }
1044
1110
  });
1045
1111
 
1046
- return { success: true, id: fullMsg.id, task_id: envelope ? envelope.task_id : null };
1112
+ return {
1113
+ success: true,
1114
+ id: fullMsg.id,
1115
+ task_id: envelope ? envelope.task_id : null,
1116
+ scope_id: envelope ? envelope.scope_id : null,
1117
+ };
1047
1118
  }
1048
1119
 
1049
1120
  /**
@@ -1144,28 +1215,130 @@ function _findTeamBroadcastContext(fromKey, targetKey, config) {
1144
1215
  return null;
1145
1216
  }
1146
1217
 
1218
+ function resolveDispatchSenderChatId(item, config) {
1219
+ const requestedChatId = String(item && item.source_chat_id || '').trim();
1220
+ if (requestedChatId) return requestedChatId;
1221
+
1222
+ const feishuMap = (config && config.feishu && config.feishu.chat_agent_map) || {};
1223
+ const allowedFeishuIds = ((config && config.feishu && config.feishu.allowed_chat_ids) || []).map(String);
1224
+ const agentChatIds = new Set(Object.keys(feishuMap).map(String));
1225
+ const senderKey = String(item && (item.source_sender_key || item.from) || '').trim();
1226
+ const userSources = new Set(['', 'unknown', 'claude_session', '_claude_session', 'user']);
1227
+
1228
+ if (!userSources.has(senderKey)) {
1229
+ const directChatId = Object.entries(feishuMap).find(([, v]) => v === senderKey)?.[0] || null;
1230
+ if (directChatId) return String(directChatId);
1231
+
1232
+ const projects = (config && config.projects) || {};
1233
+ for (const [projKey, proj] of Object.entries(projects)) {
1234
+ if (!Array.isArray(proj && proj.team)) continue;
1235
+ const member = proj.team.find(m => m && m.key === senderKey);
1236
+ if (!member) continue;
1237
+ const groupChatId = Object.entries(feishuMap).find(([, v]) => v === projKey)?.[0] || null;
1238
+ if (groupChatId) return String(groupChatId);
1239
+ }
1240
+ }
1241
+
1242
+ return allowedFeishuIds.find(id => !agentChatIds.has(id)) || null;
1243
+ }
1244
+
1245
+ function writeDispatchReceiptInbox(item, receipt) {
1246
+ const senderKey = String(item && (item.source_sender_key || item.from) || '').trim();
1247
+ if (!senderKey || ['user', 'unknown', 'claude_session', '_claude_session'].includes(senderKey)) return;
1248
+ try {
1249
+ const inboxDir = path.join(os.homedir(), '.metame', 'memory', 'inbox', senderKey);
1250
+ fs.mkdirSync(inboxDir, { recursive: true });
1251
+ const tsStr = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 15);
1252
+ const targetKey = String(receipt && receipt.targetKey || item.target || 'unknown').trim() || 'unknown';
1253
+ const inboxFile = path.join(inboxDir, `${tsStr}_${targetKey}_dispatch_receipt.md`);
1254
+ const body = [
1255
+ `TYPE: dispatch_receipt`,
1256
+ `STATUS: ${receipt && receipt.status ? receipt.status : 'accepted'}`,
1257
+ `TARGET: ${targetKey}`,
1258
+ `DISPATCH_ID: ${receipt && receipt.dispatchId ? receipt.dispatchId : ''}`,
1259
+ `TS: ${new Date().toISOString()}`,
1260
+ '',
1261
+ String(receipt && receipt.text || '').trim() || '(empty receipt)',
1262
+ ].join('\n');
1263
+ fs.writeFileSync(inboxFile, body, 'utf8');
1264
+ } catch (e) {
1265
+ log('WARN', `Dispatch receipt inbox write failed: ${e.message}`);
1266
+ }
1267
+ }
1268
+
1269
+ function sendDispatchReceipt(item, config, receipt) {
1270
+ const liveBot = _dispatchBridgeRef && _dispatchBridgeRef.bot;
1271
+ const senderChatId = resolveDispatchSenderChatId(item, config);
1272
+ const text = String(receipt && receipt.text || '').trim();
1273
+ if (!text) return;
1274
+
1275
+ if (liveBot && senderChatId) {
1276
+ const send = liveBot.sendMarkdown
1277
+ ? liveBot.sendMarkdown(senderChatId, text)
1278
+ : liveBot.sendMessage(senderChatId, text);
1279
+ send.catch((e) => {
1280
+ log('WARN', `Dispatch receipt delivery failed: ${e.message}`);
1281
+ writeDispatchReceiptInbox(item, receipt);
1282
+ });
1283
+ return;
1284
+ }
1285
+
1286
+ writeDispatchReceiptInbox(item, receipt);
1287
+ }
1288
+
1289
+ function buildDispatchPrompt(targetProject, fullMsg, envelope, metameDir = METAME_DIR) {
1290
+ const promptBody = buildEnrichedPrompt(
1291
+ targetProject,
1292
+ fullMsg && fullMsg.payload ? (fullMsg.payload.prompt || fullMsg.payload.title || 'No prompt provided') : 'No prompt provided',
1293
+ metameDir,
1294
+ { includeShared: !!(envelope && envelope.task_kind === 'team') }
1295
+ );
1296
+ return envelope
1297
+ ? buildPromptFromTaskEnvelope(envelope, promptBody)
1298
+ : promptBody;
1299
+ }
1300
+
1301
+
1302
+ function resolveDispatchReadOnly(message, config, targetProject) {
1303
+ if (message && typeof message.readOnly === 'boolean') return message.readOnly;
1304
+ const senderId = String((message && message.source_sender_id) || '').trim();
1305
+ if (senderId && userAcl && typeof userAcl.resolveUserCtx === 'function') {
1306
+ try {
1307
+ const userCtx = userAcl.resolveUserCtx(senderId, config || {});
1308
+ return !!userCtx.readOnly;
1309
+ } catch { /* fall through to safe default */ }
1310
+ }
1311
+ void targetProject;
1312
+ return true;
1313
+ }
1314
+
1147
1315
  function handleDispatchItem(item, config) {
1148
1316
  if (!item.target || !item.prompt) return;
1149
- if (!(config && config.projects && config.projects[item.target])) {
1317
+ const resolvedTarget = resolveDispatchTarget(item.target, config);
1318
+ if (!resolvedTarget) {
1150
1319
  log('WARN', `dispatch: unknown target "${item.target}"`);
1151
- return;
1320
+ sendDispatchReceipt(item, config, buildDispatchReceipt(item, config, { success: false, error: 'unknown_target' }));
1321
+ return { success: false, error: 'unknown_target' };
1152
1322
  }
1323
+ const targetKey = resolvedTarget.key;
1153
1324
  // 安全护栏:禁止 agent 主动 dispatch 到 personal(防止 LLM 幻觉乱发消息给小美)
1154
1325
  // personal 只允许用户本人触发,或来源为 user/unknown 的系统任务
1155
1326
  const _agentSources = new Set(Object.keys((config.projects) || {}));
1156
1327
  const isFromAgent = _agentSources.has(item.from) || item.from === '_claude_session';
1157
- const targetProject = config.projects?.[item.target] || {};
1328
+ const targetProject = config.projects?.[targetKey] || {};
1158
1329
  if (isFromAgent && targetProject.guard === 'user-only') {
1159
- log('WARN', `dispatch: blocked agent "${item.from}" → "${item.target}" (user-only guard)`);
1160
- return;
1330
+ log('WARN', `dispatch: blocked agent "${item.from}" → "${targetKey}" (user-only guard)`);
1331
+ sendDispatchReceipt(item, config, buildDispatchReceipt(item, config, { success: false, error: 'target_guard_user_only' }));
1332
+ return { success: false, error: 'target_guard_user_only' };
1161
1333
  }
1162
- log('INFO', `Dispatch: ${item.from || '?'} → ${item.target}: ${item.prompt.slice(0, 60)}`);
1334
+ log('INFO', `Dispatch: ${item.from || '?'} → ${targetKey}: ${item.prompt.slice(0, 60)}`);
1163
1335
 
1164
1336
  // ── Team broadcast: intra-team dispatch → show in group chat ──
1165
1337
  const liveBot = _dispatchBridgeRef && _dispatchBridgeRef.bot;
1166
- const teamCtx = liveBot ? _findTeamBroadcastContext(item.from, item.target, config) : null;
1338
+ const teamCtx = liveBot ? _findTeamBroadcastContext(item.from, targetKey, config) : null;
1339
+ const responseCard = buildDispatchResponseCard(targetKey, config);
1167
1340
  if (teamCtx && teamCtx.groupChatId) {
1168
- const { senderMember, targetMember, groupChatId, parentProject } = teamCtx;
1341
+ const { senderMember, targetMember, groupChatId } = teamCtx;
1169
1342
  const sIcon = senderMember.icon || '🤖';
1170
1343
  const sName = senderMember.name || senderMember.key;
1171
1344
  const tIcon = targetMember.icon || '🤖';
@@ -1174,71 +1347,48 @@ function handleDispatchItem(item, config) {
1174
1347
  const cardTitle = `${sIcon} ${sName} → ${tIcon} ${tName}`;
1175
1348
  const cardBody = item.prompt.slice(0, 300) + (item.prompt.length > 300 ? '…' : '');
1176
1349
  const cardColor = senderMember.color || 'blue';
1177
- const sendFn = liveBot.sendCard
1350
+ const sendTaskNotice = liveBot.sendCard
1178
1351
  ? () => liveBot.sendCard(groupChatId, { title: cardTitle, body: cardBody, color: cardColor })
1179
1352
  : () => liveBot.sendMarkdown(groupChatId, `**${cardTitle}**\n\n> ${cardBody}`);
1180
- sendFn().catch(e => log('WARN', `Team broadcast failed: ${e.message}`));
1181
- // Use streamForwardBot so target's reply also shows in group
1182
- const streamOptions = { bot: liveBot, chatId: groupChatId };
1183
- dispatchTask(item.target, {
1353
+ // Use streamForwardBot so target's reply also shows in group.
1354
+ // Gate the worker output behind the task notice so the group always sees the task card first.
1355
+ const streamOptions = {
1356
+ bot: liveBot,
1357
+ chatId: groupChatId,
1358
+ preDispatch: () => sendTaskNotice().catch(e => log('WARN', `Team broadcast failed: ${e.message}`)),
1359
+ sendTaskCard: false,
1360
+ stripPlan: true,
1361
+ responseCard,
1362
+ };
1363
+ const result = dispatchTask(targetKey, {
1184
1364
  from: item.from || 'claude_session',
1365
+ source_sender_id: item.source_sender_id || '',
1185
1366
  type: 'task', priority: 'normal',
1186
1367
  payload: { title: item.prompt.slice(0, 60), prompt: item.prompt },
1187
1368
  callback: false,
1188
1369
  new_session: !!item.new_session,
1370
+ source_chat_id: item.source_chat_id || '',
1371
+ source_sender_key: item.source_sender_key || item.from || '',
1372
+ source_sender_id: item.source_sender_id || '',
1189
1373
  }, config, null, streamOptions);
1190
- return;
1374
+ sendDispatchReceipt(item, config, buildDispatchReceipt(item, config, result));
1375
+ return result;
1191
1376
  }
1192
1377
 
1193
1378
  // ── Normal dispatch (non-team or broadcast off) ──
1194
- let pendingReplyFn = null;
1379
+ let pendingReplyFn = typeof item._replyFn === 'function' ? item._replyFn : null;
1195
1380
  let streamOptions = null;
1196
1381
  if (liveBot) {
1197
1382
  const feishuMap = (config.feishu && config.feishu.chat_agent_map) || {};
1198
- const allowedFeishuIds = (config.feishu && config.feishu.allowed_chat_ids) || [];
1199
- const agentChatIds = new Set(Object.keys(feishuMap));
1200
- const targetChatId = Object.entries(feishuMap).find(([, v]) => v === item.target)?.[0] || null;
1383
+ const targetChatId = Object.entries(feishuMap).find(([, v]) => v === targetKey)?.[0] || null;
1201
1384
  if (targetChatId) {
1202
- streamOptions = { bot: liveBot, chatId: targetChatId };
1203
- const ackText = `📬 **新任务**\n\n> ${item.prompt.slice(0, 120)}${item.prompt.length > 120 ? '...' : ''}`;
1204
- liveBot.sendMarkdown(targetChatId, ackText).catch(() =>
1205
- liveBot.sendMessage(targetChatId, ackText.replace(/\*\*/g, '')).catch(e =>
1206
- log('WARN', `Dispatch ack failed: ${e.message}`)
1207
- )
1208
- );
1209
- } else {
1210
- const _userSources = new Set(['unknown', 'claude_session', '_claude_session', 'user']);
1211
- let senderChatId = null;
1212
- if (!_userSources.has(item.from)) {
1213
- // Direct match: sender is a bound agent
1214
- senderChatId = Object.entries(feishuMap).find(([, v]) => v === item.from)?.[0] || null;
1215
- // Team member fallback: if sender is a team member (e.g., jarvis_c), find parent project's chatId
1216
- if (!senderChatId) {
1217
- const projects = config.projects || {};
1218
- for (const [projKey, proj] of Object.entries(projects)) {
1219
- if (proj.team && Array.isArray(proj.team)) {
1220
- const member = proj.team.find(m => m.key === item.from);
1221
- if (member && feishuMap[projKey]) {
1222
- senderChatId = feishuMap[projKey];
1223
- break;
1224
- }
1225
- }
1226
- }
1227
- }
1228
- }
1229
- if (!senderChatId) {
1230
- senderChatId = allowedFeishuIds.map(String).find(id => !agentChatIds.has(id)) || null;
1231
- }
1385
+ streamOptions = { bot: liveBot, chatId: targetChatId, stripPlan: true, responseCard };
1386
+ } else if (!item._suppressDefaultReplyRouting) {
1387
+ const senderChatId = resolveDispatchSenderChatId(item, config);
1232
1388
  if (senderChatId) {
1233
- const targetProj = (config.projects || {})[item.target] || {};
1234
- const ackText = `📬 已接收,转发给 ${targetProj.icon || '🤖'} **${targetProj.name || item.target}**...\n\n> ${item.prompt.slice(0, 100)}${item.prompt.length > 100 ? '...' : ''}`;
1235
- liveBot.sendMarkdown(senderChatId, ackText).catch(() =>
1236
- liveBot.sendMessage(senderChatId, ackText.replace(/\*\*/g, '')).catch(e =>
1237
- log('WARN', `Dispatch ack to sender failed: ${e.message}`)
1238
- )
1239
- );
1389
+ const targetProj = resolveDispatchTarget(targetKey, config) || {};
1240
1390
  pendingReplyFn = (output) => {
1241
- const text = `${targetProj.icon || '📬'} **${targetProj.name || item.target}** 回复:\n\n${output.slice(0, 2000)}`;
1391
+ const text = `${targetProj.icon || '📬'} **${targetProj.name || targetKey}** 回复:\n\n${output.slice(0, 2000)}`;
1242
1392
  liveBot.sendMarkdown(senderChatId, text).catch(e => {
1243
1393
  log('WARN', `Dispatch reply (markdown) failed: ${e.message}`);
1244
1394
  liveBot.sendMessage(senderChatId, text.replace(/\*\*/g, '')).catch(e2 =>
@@ -1247,35 +1397,52 @@ function handleDispatchItem(item, config) {
1247
1397
  });
1248
1398
  };
1249
1399
  // Also set streamOptions so target agent's streaming replies go to the sender's group
1250
- streamOptions = { bot: liveBot, chatId: senderChatId };
1400
+ streamOptions = { bot: liveBot, chatId: senderChatId, stripPlan: true, responseCard };
1251
1401
  }
1252
1402
  }
1253
1403
  }
1254
- dispatchTask(item.target, {
1404
+ const result = dispatchTask(targetKey, {
1255
1405
  from: item.from || 'claude_session',
1406
+ source_sender_id: item.source_sender_id || '',
1256
1407
  type: 'task', priority: 'normal',
1257
1408
  payload: { title: item.prompt.slice(0, 60), prompt: item.prompt },
1258
1409
  callback: false,
1259
1410
  new_session: !!item.new_session,
1411
+ source_chat_id: item.source_chat_id || '',
1412
+ source_sender_key: item.source_sender_key || item.from || '',
1413
+ source_sender_id: item.source_sender_id || '',
1260
1414
  }, config, pendingReplyFn, streamOptions);
1415
+ sendDispatchReceipt(item, config, buildDispatchReceipt(item, config, result));
1416
+ return result;
1261
1417
  }
1262
1418
 
1263
1419
  async function handleRemoteDispatchMessage({ chatId, text, config }) {
1264
1420
  const rd = getRemoteDispatchConfig(config);
1265
1421
  if (!rd || String(chatId) !== rd.chatId) return false;
1422
+ log('INFO', `Remote dispatch intercept chat=${chatId} preview=${String(text || '').slice(0, 48).replace(/\s+/g, ' ')}`);
1266
1423
 
1267
1424
  const packet = decodeRemoteDispatchPacket(text);
1268
- if (!packet) return true;
1425
+ if (!packet) {
1426
+ log('INFO', 'Remote dispatch decode miss');
1427
+ return true;
1428
+ }
1269
1429
  if (!verifyRemoteDispatchPacket(packet, rd.secret)) {
1270
1430
  log('WARN', 'Remote dispatch ignored: invalid signature');
1271
1431
  return true;
1272
1432
  }
1273
- if (packet.from_peer === rd.selfPeer) return true;
1274
- if (packet.to_peer !== rd.selfPeer) return true;
1433
+ if (packet.from_peer === rd.selfPeer) {
1434
+ log('INFO', `Remote dispatch ignored: self echo id=${packet.id || ''}`);
1435
+ return true;
1436
+ }
1437
+ if (packet.to_peer !== rd.selfPeer) {
1438
+ log('INFO', `Remote dispatch ignored: peer mismatch id=${packet.id || ''} to=${packet.to_peer || ''} self=${rd.selfPeer}`);
1439
+ return true;
1440
+ }
1275
1441
  if (isRemoteDispatchDuplicate(packet.id)) {
1276
1442
  log('DEBUG', `Remote dispatch ignored: duplicate id=${packet.id}`);
1277
1443
  return true;
1278
1444
  }
1445
+ log('INFO', `Remote dispatch received type=${packet.type} id=${packet.id || ''} from=${packet.from_peer}:${packet.target_project || 'unknown'}`);
1279
1446
 
1280
1447
  if (packet.type === 'task') {
1281
1448
  const replyFn = async (output) => {
@@ -1286,24 +1453,89 @@ async function handleRemoteDispatchMessage({ chatId, text, config }) {
1286
1453
  target_project: packet.target_project,
1287
1454
  source_chat_id: packet.source_chat_id,
1288
1455
  source_sender_key: packet.source_sender_key || 'user',
1456
+ source_sender_id: packet.source_sender_id || '',
1289
1457
  request_id: packet.id,
1290
1458
  result: String(output || '').slice(0, 4000),
1291
1459
  }, config);
1292
1460
  if (!res.success) log('WARN', `Remote dispatch result send failed: ${res.error}`);
1293
1461
  };
1294
1462
 
1295
- handleDispatchItem({
1463
+ const dispatchRes = handleDispatchItem({
1296
1464
  target: packet.target_project,
1297
1465
  prompt: packet.prompt,
1298
1466
  from: packet.source_sender_key || `${packet.from_peer}:remote`,
1299
1467
  new_session: !!packet.new_session,
1468
+ source_chat_id: packet.source_chat_id || '',
1469
+ source_sender_key: packet.source_sender_key || '',
1470
+ source_sender_id: packet.source_sender_id || '',
1300
1471
  _replyFn: replyFn,
1301
1472
  _suppressDefaultReplyRouting: true,
1302
1473
  }, config);
1474
+ const ackRes = await sendRemoteDispatch({
1475
+ type: 'ack',
1476
+ to_peer: packet.from_peer,
1477
+ target_project: packet.target_project,
1478
+ source_chat_id: packet.source_chat_id,
1479
+ source_sender_key: packet.source_sender_key || 'user',
1480
+ source_sender_id: packet.source_sender_id || '',
1481
+ request_id: packet.id,
1482
+ dispatch_id: dispatchRes && dispatchRes.id ? dispatchRes.id : '',
1483
+ task_id: dispatchRes && dispatchRes.task_id ? dispatchRes.task_id : '',
1484
+ scope_id: dispatchRes && dispatchRes.scope_id ? dispatchRes.scope_id : '',
1485
+ status: dispatchRes && dispatchRes.success ? 'accepted' : 'failed',
1486
+ error: dispatchRes && dispatchRes.success ? '' : String(dispatchRes && dispatchRes.error || 'dispatch_failed'),
1487
+ }, config);
1488
+ if (!ackRes.success) log('WARN', `Remote dispatch ack send failed: ${ackRes.error}`);
1489
+ return true;
1490
+ }
1491
+
1492
+ if (packet.type === 'ack') {
1493
+ resolveTrackedRemoteDispatch(packet.request_id);
1494
+ log('INFO', `Remote dispatch ack id=${packet.request_id || ''} status=${packet.status} from=${packet.from_peer}:${packet.target_project || 'unknown'}`);
1495
+ const text = String(packet.status) === 'accepted'
1496
+ ? [
1497
+ '📮 远端 Dispatch 回执',
1498
+ '',
1499
+ `状态: ${packet.from_peer}:${packet.target_project || 'unknown'} 已接收并入队`,
1500
+ packet.dispatch_id ? `编号: ${packet.dispatch_id}` : '',
1501
+ packet.task_id ? buildTeamTaskResumeHint(packet.task_id, packet.scope_id) : '',
1502
+ ].filter(Boolean).join('\n')
1503
+ : [
1504
+ '❌ 远端 Dispatch 回执',
1505
+ '',
1506
+ `状态: ${packet.from_peer}:${packet.target_project || 'unknown'} 入队失败`,
1507
+ packet.error ? `错误: ${String(packet.error).slice(0, 200)}` : '',
1508
+ ].filter(Boolean).join('\n');
1509
+
1510
+ const targetChatId = String(packet.source_chat_id || '').trim();
1511
+ if (targetChatId) {
1512
+ const liveBot = _dispatchBridgeRef && _dispatchBridgeRef.bot;
1513
+ if (!liveBot) {
1514
+ writeDispatchReceiptInbox({ from: packet.source_sender_key || 'user' }, { status: packet.status, targetKey: packet.target_project, dispatchId: packet.dispatch_id, text });
1515
+ return true;
1516
+ }
1517
+ try {
1518
+ if (liveBot.sendMarkdown) await liveBot.sendMarkdown(targetChatId, text);
1519
+ else await liveBot.sendMessage(targetChatId, text);
1520
+ } catch (e) {
1521
+ log('WARN', `Remote dispatch ack delivery failed: ${e.message}`);
1522
+ writeDispatchReceiptInbox({ from: packet.source_sender_key || 'user' }, { status: packet.status, targetKey: packet.target_project, dispatchId: packet.dispatch_id, text });
1523
+ }
1524
+ return true;
1525
+ }
1526
+
1527
+ writeDispatchReceiptInbox({ from: packet.source_sender_key || 'user' }, {
1528
+ status: packet.status,
1529
+ targetKey: packet.target_project,
1530
+ dispatchId: packet.dispatch_id,
1531
+ text,
1532
+ });
1303
1533
  return true;
1304
1534
  }
1305
1535
 
1306
1536
  if (packet.type === 'result') {
1537
+ resolveTrackedRemoteDispatch(packet.request_id);
1538
+ log('INFO', `Remote dispatch result id=${packet.request_id || ''} from=${packet.from_peer}:${packet.target_project || 'unknown'}`);
1307
1539
  const targetChatId = String(packet.source_chat_id || '').trim();
1308
1540
  if (!targetChatId) {
1309
1541
  const inboxTarget = String(packet.source_sender_key || '').trim();
@@ -1361,8 +1593,8 @@ function startDispatchSocket(getConfig) {
1361
1593
  try {
1362
1594
  const item = JSON.parse(buf);
1363
1595
  const liveCfg = typeof getConfig === 'function' ? getConfig() : getConfig;
1364
- handleDispatchItem(item, liveCfg || {});
1365
- conn.write(JSON.stringify({ ok: true }) + '\n');
1596
+ const result = handleDispatchItem(item, liveCfg || {});
1597
+ conn.write(JSON.stringify({ ok: !!(result && result.success), id: result && result.id ? result.id : null, error: result && result.error ? result.error : null }) + '\n');
1366
1598
  } catch (e) {
1367
1599
  try { conn.write(JSON.stringify({ ok: false, error: e.message }) + '\n'); } catch { /* ignore */ }
1368
1600
  }
@@ -1420,9 +1652,19 @@ function physiologicalHeartbeat(config) {
1420
1652
  const liveBot = _dispatchBridgeRef && _dispatchBridgeRef.bot;
1421
1653
  for (const item of items) {
1422
1654
  if (item.relay_chat_id && item.body && liveBot && typeof liveBot.sendMessage === 'function') {
1423
- liveBot.sendMessage(item.relay_chat_id, item.body).catch(e2 =>
1424
- log('WARN', `Remote dispatch relay send failed: ${e2.message}`)
1425
- );
1655
+ const packet = decodeRemoteDispatchPacket(item.body);
1656
+ liveBot.sendMessage(item.relay_chat_id, item.body)
1657
+ .then(() => {
1658
+ if (packet) {
1659
+ log('INFO', `Remote dispatch queue sent type=${packet.type} id=${packet.id || ''} to=${packet.to_peer}:${packet.target_project || 'unknown'} via=${item.relay_chat_id}`);
1660
+ if (packet.type === 'task') trackRemoteDispatch(packet, config);
1661
+ } else {
1662
+ log('INFO', `Remote dispatch queue sent raw via=${item.relay_chat_id}`);
1663
+ }
1664
+ })
1665
+ .catch(e2 =>
1666
+ log('WARN', `Remote dispatch relay send failed: ${e2.message}`)
1667
+ );
1426
1668
  }
1427
1669
  }
1428
1670
  }
@@ -1453,7 +1695,6 @@ function physiologicalHeartbeat(config) {
1453
1695
  const CLAUDE_COOLDOWN_MS = 10000; // 10s between Claude calls per chat
1454
1696
  const STATUS_THROTTLE_MS = 3000; // Min 3s between streaming status updates
1455
1697
  const FALLBACK_THROTTLE_MS = 8000; // 8s between fallback status updates
1456
- const DEDUP_TTL_MS = 60000; // Feishu message dedup window (60s)
1457
1698
  // ─────────────────────────────────────────────────────────────────────────────
1458
1699
 
1459
1700
  // Rate limiter for /ask and /run — prevents rapid-fire Claude calls
@@ -1629,6 +1870,7 @@ async function doBindAgent(bot, chatId, agentName, agentCwd) {
1629
1870
  // ---------------------------------------------------------
1630
1871
  const {
1631
1872
  findSessionFile,
1873
+ findCodexSessionFile,
1632
1874
  clearSessionFileCache,
1633
1875
  truncateSessionToCheckpoint,
1634
1876
  listRecentSessions,
@@ -1638,15 +1880,17 @@ const {
1638
1880
  sessionRichLabel,
1639
1881
  getSessionRecentContext,
1640
1882
  buildSessionCardElements,
1641
- listProjectDirs,
1642
1883
  getSession,
1643
1884
  getSessionForEngine,
1644
1885
  createSession,
1886
+ restoreSessionFromReply,
1645
1887
  getSessionName,
1646
1888
  writeSessionName,
1647
1889
  markSessionStarted,
1648
1890
  watchSessionFiles,
1649
1891
  isEngineSessionValid,
1892
+ getCodexSessionSandboxProfile,
1893
+ getCodexSessionPermissionMode,
1650
1894
  } = createSessionStore({
1651
1895
  fs,
1652
1896
  path,
@@ -1799,6 +2043,8 @@ const getEngineRuntime = createEngineRuntimeFactory({
1799
2043
  getActiveProviderEnv,
1800
2044
  });
1801
2045
 
2046
+ let wakeRecoveryHook = null;
2047
+
1802
2048
  const {
1803
2049
  checkPrecondition,
1804
2050
  executeTask,
@@ -1827,6 +2073,7 @@ const {
1827
2073
  isInSleepMode: () => _inSleepMode,
1828
2074
  setSleepMode: (next) => { _inSleepMode = !!next; },
1829
2075
  spawnSessionSummaries,
2076
+ getWakeRecoveryHook: () => wakeRecoveryHook,
1830
2077
  skillEvolution,
1831
2078
  });
1832
2079
 
@@ -1933,7 +2180,10 @@ const { spawnClaudeAsync, askClaude } = createClaudeEngine({
1933
2180
  sendFileButtons,
1934
2181
  findSessionFile,
1935
2182
  listRecentSessions,
2183
+ getSessionRecentContext,
1936
2184
  isEngineSessionValid,
2185
+ getCodexSessionSandboxProfile,
2186
+ getCodexSessionPermissionMode,
1937
2187
  getSession,
1938
2188
  getSessionForEngine,
1939
2189
  createSession,
@@ -1993,6 +2243,7 @@ const { handleAgentCommand } = createAgentCommandHandler({
1993
2243
  sendBrowse,
1994
2244
  sendDirPicker,
1995
2245
  getSession,
2246
+ getSessionForEngine,
1996
2247
  listRecentSessions,
1997
2248
  buildSessionCardElements,
1998
2249
  sessionLabel,
@@ -2038,8 +2289,10 @@ const { handleExecCommand } = createExecCommandHandler({
2038
2289
  getSessionName,
2039
2290
  createSession,
2040
2291
  findSessionFile,
2292
+ findCodexSessionFile,
2041
2293
  loadConfig,
2042
2294
  getDistillModel,
2295
+ getDefaultEngine,
2043
2296
  });
2044
2297
 
2045
2298
  const { handleOpsCommand } = createOpsCommandHandler({
@@ -2048,9 +2301,12 @@ const { handleOpsCommand } = createOpsCommandHandler({
2048
2301
  spawn,
2049
2302
  execSync,
2050
2303
  log,
2304
+ loadConfig,
2305
+ loadState,
2051
2306
  messageQueue,
2052
2307
  activeProcesses,
2053
2308
  getSession,
2309
+ getSessionForEngine,
2054
2310
  listCheckpoints,
2055
2311
  cpDisplayLabel,
2056
2312
  truncateSessionToCheckpoint,
@@ -2061,6 +2317,7 @@ const { handleOpsCommand } = createOpsCommandHandler({
2061
2317
  cleanupCheckpoints,
2062
2318
  getNoSleepProcess: () => caffeinateProcess,
2063
2319
  setNoSleepProcess: (p) => { caffeinateProcess = p || null; },
2320
+ getDefaultEngine,
2064
2321
  });
2065
2322
 
2066
2323
  const { handleCommand } = createCommandRouter({
@@ -2097,7 +2354,7 @@ setDispatchHandler(handleCommand);
2097
2354
  // ---------------------------------------------------------
2098
2355
  // BOT BRIDGES
2099
2356
  // ---------------------------------------------------------
2100
- const { startTelegramBridge, startFeishuBridge } = createBridgeStarter({
2357
+ const { startTelegramBridge, startFeishuBridge, startImessageBridge, startSiriBridge } = createBridgeStarter({
2101
2358
  fs,
2102
2359
  path,
2103
2360
  HOME,
@@ -2107,6 +2364,7 @@ const { startTelegramBridge, startFeishuBridge } = createBridgeStarter({
2107
2364
  loadState,
2108
2365
  saveState,
2109
2366
  getSession,
2367
+ restoreSessionFromReply,
2110
2368
  handleCommand,
2111
2369
  pendingActivations,
2112
2370
  activeProcesses,
@@ -2231,7 +2489,7 @@ async function main() {
2231
2489
  }
2232
2490
 
2233
2491
  // Config validation: warn on unknown/suspect fields
2234
- const KNOWN_SECTIONS = ['daemon', 'telegram', 'feishu', 'heartbeat', 'budget', 'projects'];
2492
+ const KNOWN_SECTIONS = ['daemon', 'telegram', 'feishu', 'heartbeat', 'budget', 'projects', 'imessage', 'siri_bridge'];
2235
2493
  const KNOWN_DAEMON = [
2236
2494
  'model', // legacy (still valid as fallback)
2237
2495
  'models', // per-engine model map: { claude, codex }
@@ -2322,6 +2580,7 @@ async function main() {
2322
2580
  // Bridges
2323
2581
  let telegramBridge = null;
2324
2582
  let feishuBridge = null;
2583
+ let lastWakeBridgeRecoveryAt = 0;
2325
2584
 
2326
2585
  const notifier = createNotifier({
2327
2586
  log,
@@ -2335,6 +2594,25 @@ async function main() {
2335
2594
  // Start dispatch socket server (low-latency IPC, fallback: file polling still works)
2336
2595
  const dispatchSocket = startDispatchSocket(() => config);
2337
2596
 
2597
+ wakeRecoveryHook = async ({ sleepSeconds }) => {
2598
+ const now = Date.now();
2599
+ if (now - lastWakeBridgeRecoveryAt < 60 * 1000) {
2600
+ log('INFO', `[WAKE-DETECT] bridge recovery skipped — cooldown active (${Math.round((now - lastWakeBridgeRecoveryAt) / 1000)}s since last)`);
2601
+ return;
2602
+ }
2603
+ lastWakeBridgeRecoveryAt = now;
2604
+ const tasks = [];
2605
+ if (telegramBridge && typeof telegramBridge.reconnect === 'function') {
2606
+ log('INFO', `[WAKE-DETECT] reconnecting Telegram bridge after ${sleepSeconds}s sleep`);
2607
+ tasks.push(Promise.resolve().then(() => telegramBridge.reconnect()));
2608
+ }
2609
+ if (feishuBridge && typeof feishuBridge.reconnect === 'function') {
2610
+ log('INFO', `[WAKE-DETECT] reconnecting Feishu bridge after ${sleepSeconds}s sleep`);
2611
+ tasks.push(Promise.resolve().then(() => feishuBridge.reconnect()));
2612
+ }
2613
+ await Promise.allSettled(tasks);
2614
+ };
2615
+
2338
2616
  // Start heartbeat scheduler
2339
2617
  let heartbeatTimer = startHeartbeat(config, notifyFn, notifyPersonalFn);
2340
2618
 
@@ -2399,6 +2677,8 @@ async function main() {
2399
2677
  // Start bridges (both can run simultaneously)
2400
2678
  telegramBridge = await startTelegramBridge(config, executeTaskByName);
2401
2679
  feishuBridge = await startFeishuBridge(config, executeTaskByName);
2680
+ await startImessageBridge(config, executeTaskByName);
2681
+ await startSiriBridge(config, executeTaskByName);
2402
2682
  if (feishuBridge) _dispatchBridgeRef = feishuBridge; // store bridge, not bot, so .bot stays live after reconnects
2403
2683
 
2404
2684
  // Notify once on startup (single message, no duplicates)
@@ -2445,6 +2725,8 @@ async function main() {
2445
2725
  try { require('./qmd-client').stopDaemon(); } catch { /* ignore */ }
2446
2726
  // Kill all tracked engine process groups before exiting (covers sub-agents too)
2447
2727
  for (const [cid, proc] of activeProcesses) {
2728
+ proc.aborted = true;
2729
+ proc.abortReason = opts.restartReason ? 'daemon-restart' : 'shutdown';
2448
2730
  try { process.kill(-proc.child.pid, 'SIGKILL'); } catch { try { proc.child.kill('SIGKILL'); } catch { } }
2449
2731
  log('INFO', `Shutdown: killed engine process group for chatId ${cid}`);
2450
2732
  }
@@ -2458,6 +2740,10 @@ async function main() {
2458
2740
  process.exit(0);
2459
2741
  };
2460
2742
 
2743
+ process.on('SIGUSR2', () => {
2744
+ shutdown({ restartReason: process.env.METAME_DEPLOY_RESTART_REASON || 'external-restart' })
2745
+ .catch(() => process.exit(1));
2746
+ });
2461
2747
  process.on('SIGTERM', () => { shutdown().catch(() => process.exit(0)); });
2462
2748
  process.on('SIGINT', () => { shutdown().catch(() => process.exit(0)); });
2463
2749
 
@@ -2518,4 +2804,21 @@ if (process.argv.includes('--run')) {
2518
2804
  }
2519
2805
 
2520
2806
  // Export for testing & cross-bot dispatch
2521
- module.exports = { executeTask, loadConfig, loadState, buildProfilePreamble, parseInterval, handleRemoteDispatchMessage, sendRemoteDispatch };
2807
+ module.exports = {
2808
+ executeTask,
2809
+ loadConfig,
2810
+ loadState,
2811
+ buildProfilePreamble,
2812
+ parseInterval,
2813
+ handleRemoteDispatchMessage,
2814
+ sendRemoteDispatch,
2815
+ __test: {
2816
+ buildDispatchPrompt,
2817
+ createStreamForwardBot,
2818
+ buildDispatchTaskCard,
2819
+ stripLeadingPlanSection,
2820
+ resolveDispatchTarget,
2821
+ resolveDispatchReadOnly,
2822
+ isMacLocalOrchestratorIntent,
2823
+ },
2824
+ };