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
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);
92
+
93
+ // Require an actual automation ask. Mentioning "macOS" or "权限" alone should not route.
94
+ if (hasMacTool && hasAutomationVerb) return true;
94
95
 
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;
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,13 @@ 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, resolveDispatchActor, updateDispatchContextFiles } = require('./team-dispatch');
152
151
  const { createFileBrowser } = require('./daemon-file-browser');
153
152
  const { createPidManager, setupRuntimeWatchers } = require('./daemon-runtime-lifecycle');
154
153
  const { repairAgentLayer } = require('./agent-layer');
155
154
  const { createNotifier } = require('./daemon-notify');
156
155
  const { createClaudeEngine } = require('./daemon-claude-engine');
157
- const { createEngineRuntimeFactory, detectDefaultEngine, resolveEngineModel, ENGINE_MODEL_CONFIG, ENGINE_DISTILL_MAP, ENGINE_DEFAULT_MODEL } = require('./daemon-engine-runtime');
156
+ const { createEngineRuntimeFactory, detectDefaultEngine, resolveEngineModel, ENGINE_MODEL_CONFIG } = require('./daemon-engine-runtime');
158
157
  const { createCommandRouter } = require('./daemon-command-router');
159
158
  const { createTaskScheduler } = require('./daemon-task-scheduler');
160
159
  const { createAgentTools } = require('./daemon-agent-tools');
@@ -520,15 +519,6 @@ function recordTokens(state, tokens, meta = null) {
520
519
  }
521
520
 
522
521
 
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
522
  const taskBoard = createTaskBoard({
533
523
  logger: (msg) => log('WARN', msg),
534
524
  });
@@ -540,12 +530,58 @@ const taskBoard = createTaskBoard({
540
530
  // Late-bound reference to handleCommand (defined later in file)
541
531
  let _handleCommand = null;
542
532
  let _dispatchBridgeRef = null; // Store bridge (not bot) so .bot is always the live object after reconnects
533
+ const _pendingRemoteDispatches = new Map();
543
534
  function setDispatchHandler(fn) { _handleCommand = fn; }
544
535
 
545
536
  function getRemoteDispatchConfig(config) {
546
537
  return normalizeRemoteDispatchConfig(config || {});
547
538
  }
548
539
 
540
+ function trackRemoteDispatch(packet) {
541
+ if (!packet || packet.type !== 'task') return;
542
+ const requestId = String(packet.id || '').trim();
543
+ const targetChatId = String(packet.source_chat_id || '').trim();
544
+ if (!requestId || !targetChatId) return;
545
+ const liveBot = _dispatchBridgeRef && _dispatchBridgeRef.bot;
546
+ const timeoutMs = 15000;
547
+ const existing = _pendingRemoteDispatches.get(requestId);
548
+ if (existing && existing.timer) clearTimeout(existing.timer);
549
+ const timer = setTimeout(async () => {
550
+ _pendingRemoteDispatches.delete(requestId);
551
+ const text = [
552
+ '⏱️ 远端 Dispatch 超时',
553
+ '',
554
+ `目标: ${packet.to_peer}:${packet.target_project || 'unknown'}`,
555
+ `请求: ${requestId}`,
556
+ `状态: 15s 内未收到回执`,
557
+ ].join('\n');
558
+ log('WARN', `Remote dispatch timeout id=${requestId} target=${packet.to_peer}:${packet.target_project || 'unknown'}`);
559
+ if (!liveBot) return;
560
+ try {
561
+ if (liveBot.sendMarkdown) await liveBot.sendMarkdown(targetChatId, text);
562
+ else await liveBot.sendMessage(targetChatId, text);
563
+ } catch (e) {
564
+ log('WARN', `Remote dispatch timeout delivery failed: ${e.message}`);
565
+ }
566
+ }, timeoutMs);
567
+ _pendingRemoteDispatches.set(requestId, {
568
+ id: requestId,
569
+ targetChatId,
570
+ targetPeer: String(packet.to_peer || '').trim(),
571
+ targetProject: String(packet.target_project || '').trim(),
572
+ timer,
573
+ });
574
+ }
575
+
576
+ function resolveTrackedRemoteDispatch(requestId) {
577
+ const key = String(requestId || '').trim();
578
+ if (!key) return null;
579
+ const tracked = _pendingRemoteDispatches.get(key) || null;
580
+ if (tracked && tracked.timer) clearTimeout(tracked.timer);
581
+ if (tracked) _pendingRemoteDispatches.delete(key);
582
+ return tracked;
583
+ }
584
+
549
585
  async function sendRemoteDispatch(packet, config) {
550
586
  const rd = getRemoteDispatchConfig(config);
551
587
  const liveBot = _dispatchBridgeRef && _dispatchBridgeRef.bot;
@@ -562,6 +598,10 @@ async function sendRemoteDispatch(packet, config) {
562
598
  from_peer: rd.selfPeer,
563
599
  }, rd.secret);
564
600
  await liveBot.sendMessage(rd.chatId, body);
601
+ log('INFO', `Remote dispatch sent type=${packet.type} id=${id} to=${packet.to_peer}:${packet.target_project || 'unknown'} via=${rd.chatId}`);
602
+ if (packet.type === 'task') {
603
+ trackRemoteDispatch({ ...packet, id }, config);
604
+ }
565
605
  return { success: true, id };
566
606
  } catch (e) {
567
607
  return { success: false, error: e.message };
@@ -587,38 +627,91 @@ function createNullBot(onOutput) {
587
627
  };
588
628
  }
589
629
 
630
+ function stripLeadingPlanSection(text) {
631
+ const src = String(text || '');
632
+ if (!src.trim()) return '';
633
+ const normalized = src.replace(/\r\n/g, '\n');
634
+ const paragraphs = normalized.split(/\n\s*\n/);
635
+ if (paragraphs.length === 0) return normalized.trim();
636
+ const first = String(paragraphs[0] || '').trim();
637
+ if (!/^计划[::]/.test(first)) return normalized.trim();
638
+ const rest = paragraphs.slice(1).join('\n\n').trim();
639
+ if (rest) return rest;
640
+ const lines = normalized.split('\n');
641
+ const remaining = lines.slice(1).join('\n').trim();
642
+ return remaining || first.replace(/^计划[::]\s*/, '').trim();
643
+ }
644
+
590
645
  /**
591
646
  * Forward bot: routes all calls to a real bot with a fixed chatId.
592
647
  * Used for dispatch tasks so Claude's streaming output appears in the target's Feishu channel.
593
648
  */
594
- function createStreamForwardBot(realBot, chatId, onOutput = null) {
649
+ function createStreamForwardBot(realBot, chatId, onOutput = null, opts = {}) {
595
650
  // Track edit-broken state independently so dispatch failures don't poison realBot's flag
596
651
  let _editBroken = false;
652
+ const ready = opts && opts.ready && typeof opts.ready.then === 'function'
653
+ ? opts.ready.catch(() => {})
654
+ : Promise.resolve();
655
+ async function waitUntilReady() {
656
+ await ready;
657
+ }
658
+ function normalizeOutput(payload) {
659
+ const text = typeof payload === 'object'
660
+ ? (payload.body || payload.title || JSON.stringify(payload))
661
+ : String(payload);
662
+ return opts.stripPlan !== false ? stripLeadingPlanSection(text) : text;
663
+ }
664
+ async function deliver(text, rawText = text) {
665
+ const displayText = normalizeOutput(text);
666
+ if (onOutput) onOutput(rawText);
667
+ if (opts.responseCard && realBot.sendCard) {
668
+ return realBot.sendCard(chatId, {
669
+ title: opts.responseCard.title,
670
+ body: displayText,
671
+ color: opts.responseCard.color || 'blue',
672
+ });
673
+ }
674
+ return realBot.sendMessage(chatId, displayText);
675
+ }
597
676
  return {
598
677
  sendMessage: async (_, text) => {
678
+ await waitUntilReady();
599
679
  log('INFO', `[StreamBot→${chatId.slice(-8)}] msg: ${String(text).slice(0, 80)}`);
600
- if (onOutput) onOutput(text);
601
- return realBot.sendMessage(chatId, text);
680
+ return deliver(text, text);
602
681
  },
603
682
  sendMarkdown: async (_, text) => {
683
+ await waitUntilReady();
604
684
  log('INFO', `[StreamBot→${chatId.slice(-8)}] md: ${String(text).slice(0, 80)}`);
685
+ if (opts.responseCard && realBot.sendCard) {
686
+ const displayText = normalizeOutput(text);
687
+ if (onOutput) onOutput(text);
688
+ return realBot.sendCard(chatId, {
689
+ title: opts.responseCard.title,
690
+ body: displayText,
691
+ color: opts.responseCard.color || 'blue',
692
+ });
693
+ }
694
+ const displayText = normalizeOutput(text);
605
695
  if (onOutput) onOutput(text);
606
- return realBot.sendMarkdown(chatId, text);
696
+ return realBot.sendMarkdown(chatId, displayText);
607
697
  },
608
698
  sendCard: async (_, card) => {
699
+ await waitUntilReady();
609
700
  const title = typeof card === 'object' ? (card.title || card.body || '').slice(0, 60) : String(card).slice(0, 60);
610
701
  log('INFO', `[StreamBot→${chatId.slice(-8)}] card: ${title}`);
611
702
  if (onOutput) onOutput(typeof card === 'object' ? (card.body || card.title || JSON.stringify(card)) : card);
612
703
  return realBot.sendCard(chatId, card);
613
704
  },
614
705
  sendRawCard: async (_, header, elements) => {
706
+ await waitUntilReady();
615
707
  log('INFO', `[StreamBot→${chatId.slice(-8)}] rawcard: ${String(header).slice(0, 60)}`);
616
708
  if (onOutput) onOutput(header);
617
709
  return realBot.sendRawCard(chatId, header, elements);
618
710
  },
619
- sendButtons: async (_, text, buttons) => realBot.sendButtons(chatId, text, buttons),
620
- sendTyping: async () => realBot.sendTyping(chatId),
711
+ sendButtons: async (_, text, buttons) => { await waitUntilReady(); return realBot.sendButtons(chatId, text, buttons); },
712
+ sendTyping: async () => { await waitUntilReady(); return realBot.sendTyping(chatId); },
621
713
  editMessage: async (_, msgId, text) => {
714
+ await waitUntilReady();
622
715
  if (_editBroken) return false;
623
716
  log('INFO', `[StreamBot→${chatId.slice(-8)}] edit ${String(msgId).slice(-8)}: ${String(text).slice(0, 60)}`);
624
717
  try {
@@ -631,8 +724,8 @@ function createStreamForwardBot(realBot, chatId, onOutput = null) {
631
724
  return false;
632
725
  }
633
726
  },
634
- deleteMessage: async (_, msgId) => realBot.deleteMessage(chatId, msgId),
635
- sendFile: async (_, filePath, caption) => realBot.sendFile(chatId, filePath, caption),
727
+ deleteMessage: async (_, msgId) => { await waitUntilReady(); return realBot.deleteMessage(chatId, msgId); },
728
+ sendFile: async (_, filePath, caption) => { await waitUntilReady(); return realBot.sendFile(chatId, filePath, caption); },
636
729
  downloadFile: async (...args) => realBot.downloadFile(...args),
637
730
  };
638
731
  }
@@ -802,6 +895,7 @@ function dispatchTask(targetProject, message, config, replyFn, streamOptions = n
802
895
  const fullMsg = {
803
896
  id: `d_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
804
897
  from: message.from || 'unknown',
898
+ source_sender_id: String(message.source_sender_id || '').trim() || '',
805
899
  to: targetProject,
806
900
  type: message.type || 'task',
807
901
  priority: message.priority || 'normal',
@@ -859,79 +953,23 @@ function dispatchTask(targetProject, message, config, replyFn, streamOptions = n
859
953
  if (!fs.existsSync(DISPATCH_DIR)) fs.mkdirSync(DISPATCH_DIR, { recursive: true });
860
954
  fs.appendFileSync(DISPATCH_LOG, JSON.stringify({ ...fullMsg, dispatched_at: new Date().toISOString() }) + '\n', 'utf8');
861
955
 
862
- // Auto-update now/shared.md and team shared files for cross-agent visibility
956
+ // Auto-update scoped dispatch context files; only TeamTask writes shared state.
863
957
  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
- }
958
+ updateDispatchContextFiles({
959
+ fs,
960
+ path,
961
+ baseDir: METAME_DIR,
962
+ fullMsg,
963
+ targetProject,
964
+ config,
965
+ envelope,
966
+ logger: (msg) => log('WARN', msg),
967
+ });
928
968
  } catch (e) {
929
- log('WARN', `Failed to update shared files: ${e.message}`);
969
+ log('WARN', `Failed to update dispatch context files: ${e.message}`);
930
970
  }
931
971
 
932
- const rawPrompt = envelope
933
- ? buildPromptFromTaskEnvelope(envelope, fullMsg.payload.prompt || fullMsg.payload.title || '')
934
- : (fullMsg.payload.prompt || fullMsg.payload.title || 'No prompt provided');
972
+ const rawPrompt = buildDispatchPrompt(targetProject, fullMsg, envelope);
935
973
 
936
974
  // Inject sender identity when dispatched by another agent (not directly from user)
937
975
  const userSources = new Set(['unknown', 'claude_session', '_claude_session', 'user']);
@@ -957,10 +995,25 @@ ${fullMsg.chain ? fullMsg.chain.join(' → ') : fullMsg.from} → ${targetProjec
957
995
  const dispatchChatId = buildDispatchChatId(targetProject, envelope && envelope.scope_id);
958
996
  const sessionMode = forceNew ? 'fresh session (forced)' : 'existing virtual session';
959
997
  log('INFO', `Dispatching ${fullMsg.type} to ${targetProject} via ${sessionMode}: ${rawPrompt.slice(0, 80)}`);
998
+ const streamReady = streamOptions?.bot && streamOptions?.chatId
999
+ ? (() => {
1000
+ if (typeof streamOptions.preDispatch === 'function') {
1001
+ return Promise.resolve()
1002
+ .then(() => streamOptions.preDispatch())
1003
+ .catch(e => log('WARN', `Dispatch prelude failed: ${e.message}`));
1004
+ }
1005
+ if (streamOptions.sendTaskCard === false) return Promise.resolve();
1006
+ const card = buildDispatchTaskCard(fullMsg, targetProject, config);
1007
+ return Promise.resolve()
1008
+ .then(() => sendDispatchTaskCard(streamOptions.bot, streamOptions.chatId, card))
1009
+ .catch(e => log('WARN', `Dispatch task card failed: ${e.message}`));
1010
+ })()
1011
+ : Promise.resolve();
960
1012
 
961
1013
  let _taskFinalized = false;
962
1014
  const outputHandler = (output) => {
963
1015
  const outStr = typeof output === 'object' ? (output.body || JSON.stringify(output)) : String(output);
1016
+ const displayOut = envelope ? appendTeamTaskResumeHint(outStr, envelope.task_id, envelope.scope_id) : outStr;
964
1017
  log('INFO', `Dispatch output from ${targetProject}: ${outStr.slice(0, 200)}`);
965
1018
  if (envelope && taskBoard && !_taskFinalized && outStr.trim().length > 2) {
966
1019
  const status = inferTaskStatusFromOutput(outStr);
@@ -979,7 +1032,7 @@ ${fullMsg.chain ? fullMsg.chain.join(' → ') : fullMsg.from} → ${targetProjec
979
1032
  _taskFinalized = true;
980
1033
  }
981
1034
  if (replyFn && outStr.trim().length > 2) {
982
- replyFn(outStr);
1035
+ replyFn(displayOut);
983
1036
  } else if (!replyFn && fullMsg.callback && fullMsg.from && config) {
984
1037
  // Write result to sender's inbox before dispatching callback
985
1038
  try {
@@ -994,7 +1047,7 @@ ${fullMsg.chain ? fullMsg.chain.join(' → ') : fullMsg.from} → ${targetProjec
994
1047
  `TS: ${new Date().toISOString()}`,
995
1048
  `SUBJECT: ${subject}`,
996
1049
  '',
997
- outStr.trim().slice(0, 2000),
1050
+ displayOut.slice(0, 2000),
998
1051
  ].join('\n');
999
1052
  fs.writeFileSync(inboxFile, body, 'utf8');
1000
1053
  } catch (e) {
@@ -1002,12 +1055,13 @@ ${fullMsg.chain ? fullMsg.chain.join(' → ') : fullMsg.from} → ${targetProjec
1002
1055
  }
1003
1056
  dispatchTask(fullMsg.from, {
1004
1057
  from: targetProject,
1058
+ source_sender_id: fullMsg.source_sender_id || '',
1005
1059
  type: 'callback',
1006
1060
  priority: 'normal',
1007
1061
  payload: {
1008
1062
  title: `任务完成: ${fullMsg.payload.title || fullMsg.id}`,
1009
1063
  original_id: fullMsg.id,
1010
- output: outStr.slice(0, 500),
1064
+ output: displayOut.slice(0, 500),
1011
1065
  },
1012
1066
  chain: [], // reset chain for callbacks
1013
1067
  }, config);
@@ -1016,11 +1070,14 @@ ${fullMsg.chain ? fullMsg.chain.join(' → ') : fullMsg.from} → ${targetProjec
1016
1070
  // If streamOptions provided, use real bot so output appears in target's Feishu channel.
1017
1071
  // Otherwise fall back to nullBot which captures output for replyFn.
1018
1072
  const nullBot = streamOptions?.bot && streamOptions?.chatId
1019
- ? createStreamForwardBot(streamOptions.bot, streamOptions.chatId, outputHandler)
1073
+ ? createStreamForwardBot(streamOptions.bot, streamOptions.chatId, outputHandler, {
1074
+ ready: streamReady,
1075
+ stripPlan: streamOptions.stripPlan !== false,
1076
+ responseCard: streamOptions.responseCard || null,
1077
+ })
1020
1078
  : 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).
1079
+ // Trusted dispatches (user / bound agent / team member) keep write access.
1080
+ // Only unknown senders are downgraded to read-only.
1024
1081
  // When forceNew=true, clear any cached session for this virtual chatId so
1025
1082
  // attachOrCreateSession in handleCommand actually creates a fresh Claude session.
1026
1083
  if (forceNew) {
@@ -1030,7 +1087,7 @@ ${fullMsg.chain ? fullMsg.chain.join(' → ') : fullMsg.from} → ${targetProjec
1030
1087
  saveState(st);
1031
1088
  }
1032
1089
  }
1033
- const dispatchReadOnly = !(config.daemon && config.daemon.dangerously_skip_permissions);
1090
+ const dispatchReadOnly = resolveDispatchReadOnly(message, config, targetProject);
1034
1091
  if (envelope && taskBoard) {
1035
1092
  taskBoard.markTaskStatus(envelope.task_id, 'running', { summary: `dispatched via ${sessionMode}` });
1036
1093
  taskBoard.appendTaskEvent(envelope.task_id, 'task_started', targetProject, { session_mode: sessionMode });
@@ -1043,7 +1100,12 @@ ${fullMsg.chain ? fullMsg.chain.join(' → ') : fullMsg.from} → ${targetProjec
1043
1100
  }
1044
1101
  });
1045
1102
 
1046
- return { success: true, id: fullMsg.id, task_id: envelope ? envelope.task_id : null };
1103
+ return {
1104
+ success: true,
1105
+ id: fullMsg.id,
1106
+ task_id: envelope ? envelope.task_id : null,
1107
+ scope_id: envelope ? envelope.scope_id : null,
1108
+ };
1047
1109
  }
1048
1110
 
1049
1111
  /**
@@ -1144,28 +1206,265 @@ function _findTeamBroadcastContext(fromKey, targetKey, config) {
1144
1206
  return null;
1145
1207
  }
1146
1208
 
1209
+ function resolveDispatchSenderChatId(item, config) {
1210
+ const requestedChatId = String(item && item.source_chat_id || '').trim();
1211
+ if (requestedChatId) return requestedChatId;
1212
+
1213
+ const feishuMap = (config && config.feishu && config.feishu.chat_agent_map) || {};
1214
+ const allowedFeishuIds = ((config && config.feishu && config.feishu.allowed_chat_ids) || []).map(String);
1215
+ const agentChatIds = new Set(Object.keys(feishuMap).map(String));
1216
+ const senderKey = String(item && (item.source_sender_key || item.from) || '').trim();
1217
+ const userSources = new Set(['', 'unknown', 'claude_session', '_claude_session', 'user']);
1218
+
1219
+ if (!userSources.has(senderKey)) {
1220
+ const directChatId = Object.entries(feishuMap).find(([, v]) => v === senderKey)?.[0] || null;
1221
+ if (directChatId) return String(directChatId);
1222
+
1223
+ const projects = (config && config.projects) || {};
1224
+ for (const [projKey, proj] of Object.entries(projects)) {
1225
+ if (!Array.isArray(proj && proj.team)) continue;
1226
+ const member = proj.team.find(m => m && m.key === senderKey);
1227
+ if (!member) continue;
1228
+ const groupChatId = Object.entries(feishuMap).find(([, v]) => v === projKey)?.[0] || null;
1229
+ if (groupChatId) return String(groupChatId);
1230
+ }
1231
+ }
1232
+
1233
+ return allowedFeishuIds.find(id => !agentChatIds.has(id)) || null;
1234
+ }
1235
+
1236
+ function writeDispatchReceiptInbox(item, receipt) {
1237
+ const senderKey = String(item && (item.source_sender_key || item.from) || '').trim();
1238
+ if (!senderKey || ['user', 'unknown', 'claude_session', '_claude_session'].includes(senderKey)) return;
1239
+ try {
1240
+ const inboxDir = path.join(os.homedir(), '.metame', 'memory', 'inbox', senderKey);
1241
+ fs.mkdirSync(inboxDir, { recursive: true });
1242
+ const tsStr = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 15);
1243
+ const targetKey = String(receipt && receipt.targetKey || item.target || 'unknown').trim() || 'unknown';
1244
+ const inboxFile = path.join(inboxDir, `${tsStr}_${targetKey}_dispatch_receipt.md`);
1245
+ const body = [
1246
+ `TYPE: dispatch_receipt`,
1247
+ `STATUS: ${receipt && receipt.status ? receipt.status : 'accepted'}`,
1248
+ `TARGET: ${targetKey}`,
1249
+ `DISPATCH_ID: ${receipt && receipt.dispatchId ? receipt.dispatchId : ''}`,
1250
+ `TS: ${new Date().toISOString()}`,
1251
+ '',
1252
+ String(receipt && receipt.text || '').trim() || '(empty receipt)',
1253
+ ].join('\n');
1254
+ fs.writeFileSync(inboxFile, body, 'utf8');
1255
+ } catch (e) {
1256
+ log('WARN', `Dispatch receipt inbox write failed: ${e.message}`);
1257
+ }
1258
+ }
1259
+
1260
+ function sendDispatchReceipt(item, config, receipt) {
1261
+ const liveBot = _dispatchBridgeRef && _dispatchBridgeRef.bot;
1262
+ const senderChatId = resolveDispatchSenderChatId(item, config);
1263
+ const text = String(receipt && receipt.text || '').trim();
1264
+ if (!text) return;
1265
+
1266
+ if (liveBot && senderChatId) {
1267
+ const send = liveBot.sendMarkdown
1268
+ ? liveBot.sendMarkdown(senderChatId, text)
1269
+ : liveBot.sendMessage(senderChatId, text);
1270
+ send.catch((e) => {
1271
+ log('WARN', `Dispatch receipt delivery failed: ${e.message}`);
1272
+ writeDispatchReceiptInbox(item, receipt);
1273
+ });
1274
+ return;
1275
+ }
1276
+
1277
+ writeDispatchReceiptInbox(item, receipt);
1278
+ }
1279
+
1280
+ function buildTeamTaskResumeHint(taskId, scopeId) {
1281
+ const safeTaskId = String(taskId || '').trim();
1282
+ if (!safeTaskId) return '';
1283
+ const safeScopeId = String(scopeId || '').trim();
1284
+ const lines = [
1285
+ '',
1286
+ `TeamTask: ${safeTaskId}`,
1287
+ ];
1288
+ if (safeScopeId && safeScopeId !== safeTaskId) lines.push(`Scope: ${safeScopeId}`);
1289
+ lines.push(`如需复工,请使用: /TeamTask resume ${safeTaskId}`);
1290
+ return lines.join('\n');
1291
+ }
1292
+
1293
+ function appendTeamTaskResumeHint(text, taskId, scopeId) {
1294
+ const base = String(text || '').trim();
1295
+ const hint = buildTeamTaskResumeHint(taskId, scopeId);
1296
+ if (!hint) return base;
1297
+ return `${base}${hint}`;
1298
+ }
1299
+
1300
+ function buildDispatchPrompt(targetProject, fullMsg, envelope, metameDir = METAME_DIR) {
1301
+ const promptBody = buildEnrichedPrompt(
1302
+ targetProject,
1303
+ fullMsg && fullMsg.payload ? (fullMsg.payload.prompt || fullMsg.payload.title || 'No prompt provided') : 'No prompt provided',
1304
+ metameDir,
1305
+ { includeShared: !!(envelope && envelope.task_kind === 'team') }
1306
+ );
1307
+ return envelope
1308
+ ? buildPromptFromTaskEnvelope(envelope, promptBody)
1309
+ : promptBody;
1310
+ }
1311
+
1312
+ function resolveDispatchTarget(targetKey, config) {
1313
+ const rawKey = String(targetKey || '').trim();
1314
+ const projects = (config && config.projects) || {};
1315
+ if (!rawKey) return null;
1316
+ if (projects[rawKey]) {
1317
+ const proj = projects[rawKey];
1318
+ return {
1319
+ key: rawKey,
1320
+ name: proj.name || rawKey,
1321
+ icon: proj.icon || '🤖',
1322
+ color: proj.color || 'blue',
1323
+ parentKey: rawKey,
1324
+ parentProject: proj,
1325
+ member: null,
1326
+ isTeamMember: false,
1327
+ };
1328
+ }
1329
+ for (const [parentKey, parent] of Object.entries(projects)) {
1330
+ if (!Array.isArray(parent && parent.team)) continue;
1331
+ const member = parent.team.find(m => m && m.key === rawKey);
1332
+ if (!member) continue;
1333
+ return {
1334
+ key: rawKey,
1335
+ name: member.name || rawKey,
1336
+ icon: member.icon || parent.icon || '🤖',
1337
+ color: member.color || parent.color || 'blue',
1338
+ parentKey,
1339
+ parentProject: parent,
1340
+ member,
1341
+ isTeamMember: true,
1342
+ };
1343
+ }
1344
+ return null;
1345
+ }
1346
+
1347
+ function buildDispatchResponseCard(targetKey, config) {
1348
+ const target = resolveDispatchTarget(targetKey, config);
1349
+ if (!target) return null;
1350
+ return {
1351
+ title: `${target.icon} ${target.name}`,
1352
+ color: target.color || 'blue',
1353
+ };
1354
+ }
1355
+
1356
+ function buildDispatchTaskCard(fullMsg, targetProject, config) {
1357
+ const projects = (config && config.projects) || {};
1358
+ const actor = resolveDispatchActor(
1359
+ (fullMsg && fullMsg.source_sender_key) || (fullMsg && fullMsg.from),
1360
+ projects
1361
+ );
1362
+ const target = resolveDispatchTarget(targetProject, config) || {
1363
+ icon: '🤖',
1364
+ name: targetProject,
1365
+ color: 'blue',
1366
+ };
1367
+ const prompt = String(fullMsg && fullMsg.payload && (fullMsg.payload.prompt || fullMsg.payload.title) || '').trim();
1368
+ const preview = prompt ? `${prompt.slice(0, 300)}${prompt.length > 300 ? '…' : ''}` : '(empty)';
1369
+ const lines = [
1370
+ `发起: ${actor.icon} ${actor.name}`,
1371
+ `目标: ${target.icon} ${target.name}`,
1372
+ `编号: ${fullMsg.id}`,
1373
+ ];
1374
+ if (fullMsg.task_id) lines.push(`TeamTask: ${fullMsg.task_id}`);
1375
+ if (fullMsg.scope_id && fullMsg.scope_id !== fullMsg.task_id) lines.push(`Scope: ${fullMsg.scope_id}`);
1376
+ lines.push('', preview);
1377
+ return {
1378
+ title: '📬 新任务',
1379
+ body: lines.join('\n'),
1380
+ color: target.color || 'blue',
1381
+ markdown: `## 📬 新任务\n\n${lines.join('\n')}\n\n---\n${preview}`,
1382
+ text: `📬 新任务\n\n${lines.join('\n')}\n\n${preview}`,
1383
+ };
1384
+ }
1385
+
1386
+ function resolveDispatchReadOnly(message, config, targetProject) {
1387
+ if (message && typeof message.readOnly === 'boolean') return message.readOnly;
1388
+ const senderId = String((message && message.source_sender_id) || '').trim();
1389
+ if (senderId && userAcl && typeof userAcl.resolveUserCtx === 'function') {
1390
+ try {
1391
+ const userCtx = userAcl.resolveUserCtx(senderId, config || {});
1392
+ return !!userCtx.readOnly;
1393
+ } catch { /* fall through to safe default */ }
1394
+ }
1395
+ void targetProject;
1396
+ return true;
1397
+ }
1398
+
1399
+ async function sendDispatchTaskCard(bot, chatId, card) {
1400
+ if (!bot || !chatId || !card) return null;
1401
+ if (bot.sendCard) return bot.sendCard(chatId, { title: card.title, body: card.body, color: card.color || 'blue' });
1402
+ if (bot.sendMarkdown) return bot.sendMarkdown(chatId, card.markdown);
1403
+ return bot.sendMessage(chatId, card.text);
1404
+ }
1405
+
1406
+ function buildDispatchReceipt(item, config, result, opts = {}) {
1407
+ const targetKey = String(opts.targetKey || item.target || '').trim() || 'unknown';
1408
+ const target = resolveDispatchTarget(targetKey, config) || {
1409
+ icon: '🤖',
1410
+ name: targetKey,
1411
+ };
1412
+ const actor = resolveDispatchActor(
1413
+ String(item && (item.source_sender_key || item.from) || 'user').trim() || 'user',
1414
+ (config && config.projects) || {}
1415
+ );
1416
+ const prompt = String(item && item.prompt || '').trim();
1417
+ const preview = prompt ? `${prompt.slice(0, 120)}${prompt.length > 120 ? '...' : ''}` : '(empty)';
1418
+ const isFailed = !result || !result.success;
1419
+ const title = isFailed ? '❌ Dispatch 回执' : '📮 Dispatch 回执';
1420
+ const statusLine = isFailed
1421
+ ? `状态: 入队失败 (${String(result && result.error || 'unknown_error').slice(0, 120)})`
1422
+ : '状态: 目标端已接收并入队';
1423
+ const lines = [
1424
+ title,
1425
+ '',
1426
+ statusLine,
1427
+ `发起: ${actor.icon} ${actor.name}`,
1428
+ `目标: ${target.icon} ${target.name}`,
1429
+ ];
1430
+ if (result && result.id) lines.push(`编号: ${result.id}`);
1431
+ lines.push(`摘要: ${preview}`);
1432
+ if (result && result.task_id) lines.push(buildTeamTaskResumeHint(result.task_id, result.scope_id));
1433
+ return {
1434
+ status: isFailed ? 'failed' : 'accepted',
1435
+ dispatchId: result && result.id ? result.id : '',
1436
+ targetKey,
1437
+ text: lines.join('\n'),
1438
+ };
1439
+ }
1440
+
1147
1441
  function handleDispatchItem(item, config) {
1148
1442
  if (!item.target || !item.prompt) return;
1149
- if (!(config && config.projects && config.projects[item.target])) {
1443
+ const resolvedTarget = resolveDispatchTarget(item.target, config);
1444
+ if (!resolvedTarget) {
1150
1445
  log('WARN', `dispatch: unknown target "${item.target}"`);
1151
- return;
1446
+ sendDispatchReceipt(item, config, buildDispatchReceipt(item, config, { success: false, error: 'unknown_target' }));
1447
+ return { success: false, error: 'unknown_target' };
1152
1448
  }
1449
+ const targetKey = resolvedTarget.key;
1153
1450
  // 安全护栏:禁止 agent 主动 dispatch 到 personal(防止 LLM 幻觉乱发消息给小美)
1154
1451
  // personal 只允许用户本人触发,或来源为 user/unknown 的系统任务
1155
1452
  const _agentSources = new Set(Object.keys((config.projects) || {}));
1156
1453
  const isFromAgent = _agentSources.has(item.from) || item.from === '_claude_session';
1157
- const targetProject = config.projects?.[item.target] || {};
1454
+ const targetProject = config.projects?.[targetKey] || {};
1158
1455
  if (isFromAgent && targetProject.guard === 'user-only') {
1159
- log('WARN', `dispatch: blocked agent "${item.from}" → "${item.target}" (user-only guard)`);
1160
- return;
1456
+ log('WARN', `dispatch: blocked agent "${item.from}" → "${targetKey}" (user-only guard)`);
1457
+ sendDispatchReceipt(item, config, buildDispatchReceipt(item, config, { success: false, error: 'target_guard_user_only' }));
1458
+ return { success: false, error: 'target_guard_user_only' };
1161
1459
  }
1162
- log('INFO', `Dispatch: ${item.from || '?'} → ${item.target}: ${item.prompt.slice(0, 60)}`);
1460
+ log('INFO', `Dispatch: ${item.from || '?'} → ${targetKey}: ${item.prompt.slice(0, 60)}`);
1163
1461
 
1164
1462
  // ── Team broadcast: intra-team dispatch → show in group chat ──
1165
1463
  const liveBot = _dispatchBridgeRef && _dispatchBridgeRef.bot;
1166
- const teamCtx = liveBot ? _findTeamBroadcastContext(item.from, item.target, config) : null;
1464
+ const teamCtx = liveBot ? _findTeamBroadcastContext(item.from, targetKey, config) : null;
1465
+ const responseCard = buildDispatchResponseCard(targetKey, config);
1167
1466
  if (teamCtx && teamCtx.groupChatId) {
1168
- const { senderMember, targetMember, groupChatId, parentProject } = teamCtx;
1467
+ const { senderMember, targetMember, groupChatId } = teamCtx;
1169
1468
  const sIcon = senderMember.icon || '🤖';
1170
1469
  const sName = senderMember.name || senderMember.key;
1171
1470
  const tIcon = targetMember.icon || '🤖';
@@ -1174,71 +1473,48 @@ function handleDispatchItem(item, config) {
1174
1473
  const cardTitle = `${sIcon} ${sName} → ${tIcon} ${tName}`;
1175
1474
  const cardBody = item.prompt.slice(0, 300) + (item.prompt.length > 300 ? '…' : '');
1176
1475
  const cardColor = senderMember.color || 'blue';
1177
- const sendFn = liveBot.sendCard
1476
+ const sendTaskNotice = liveBot.sendCard
1178
1477
  ? () => liveBot.sendCard(groupChatId, { title: cardTitle, body: cardBody, color: cardColor })
1179
1478
  : () => 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, {
1479
+ // Use streamForwardBot so target's reply also shows in group.
1480
+ // Gate the worker output behind the task notice so the group always sees the task card first.
1481
+ const streamOptions = {
1482
+ bot: liveBot,
1483
+ chatId: groupChatId,
1484
+ preDispatch: () => sendTaskNotice().catch(e => log('WARN', `Team broadcast failed: ${e.message}`)),
1485
+ sendTaskCard: false,
1486
+ stripPlan: true,
1487
+ responseCard,
1488
+ };
1489
+ const result = dispatchTask(targetKey, {
1184
1490
  from: item.from || 'claude_session',
1491
+ source_sender_id: item.source_sender_id || '',
1185
1492
  type: 'task', priority: 'normal',
1186
1493
  payload: { title: item.prompt.slice(0, 60), prompt: item.prompt },
1187
1494
  callback: false,
1188
1495
  new_session: !!item.new_session,
1496
+ source_chat_id: item.source_chat_id || '',
1497
+ source_sender_key: item.source_sender_key || item.from || '',
1498
+ source_sender_id: item.source_sender_id || '',
1189
1499
  }, config, null, streamOptions);
1190
- return;
1500
+ sendDispatchReceipt(item, config, buildDispatchReceipt(item, config, result));
1501
+ return result;
1191
1502
  }
1192
1503
 
1193
1504
  // ── Normal dispatch (non-team or broadcast off) ──
1194
- let pendingReplyFn = null;
1505
+ let pendingReplyFn = typeof item._replyFn === 'function' ? item._replyFn : null;
1195
1506
  let streamOptions = null;
1196
1507
  if (liveBot) {
1197
1508
  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;
1509
+ const targetChatId = Object.entries(feishuMap).find(([, v]) => v === targetKey)?.[0] || null;
1201
1510
  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
- }
1511
+ streamOptions = { bot: liveBot, chatId: targetChatId, stripPlan: true, responseCard };
1512
+ } else if (!item._suppressDefaultReplyRouting) {
1513
+ const senderChatId = resolveDispatchSenderChatId(item, config);
1232
1514
  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
- );
1515
+ const targetProj = resolveDispatchTarget(targetKey, config) || {};
1240
1516
  pendingReplyFn = (output) => {
1241
- const text = `${targetProj.icon || '📬'} **${targetProj.name || item.target}** 回复:\n\n${output.slice(0, 2000)}`;
1517
+ const text = `${targetProj.icon || '📬'} **${targetProj.name || targetKey}** 回复:\n\n${output.slice(0, 2000)}`;
1242
1518
  liveBot.sendMarkdown(senderChatId, text).catch(e => {
1243
1519
  log('WARN', `Dispatch reply (markdown) failed: ${e.message}`);
1244
1520
  liveBot.sendMessage(senderChatId, text.replace(/\*\*/g, '')).catch(e2 =>
@@ -1247,35 +1523,52 @@ function handleDispatchItem(item, config) {
1247
1523
  });
1248
1524
  };
1249
1525
  // Also set streamOptions so target agent's streaming replies go to the sender's group
1250
- streamOptions = { bot: liveBot, chatId: senderChatId };
1526
+ streamOptions = { bot: liveBot, chatId: senderChatId, stripPlan: true, responseCard };
1251
1527
  }
1252
1528
  }
1253
1529
  }
1254
- dispatchTask(item.target, {
1530
+ const result = dispatchTask(targetKey, {
1255
1531
  from: item.from || 'claude_session',
1532
+ source_sender_id: item.source_sender_id || '',
1256
1533
  type: 'task', priority: 'normal',
1257
1534
  payload: { title: item.prompt.slice(0, 60), prompt: item.prompt },
1258
1535
  callback: false,
1259
1536
  new_session: !!item.new_session,
1537
+ source_chat_id: item.source_chat_id || '',
1538
+ source_sender_key: item.source_sender_key || item.from || '',
1539
+ source_sender_id: item.source_sender_id || '',
1260
1540
  }, config, pendingReplyFn, streamOptions);
1541
+ sendDispatchReceipt(item, config, buildDispatchReceipt(item, config, result));
1542
+ return result;
1261
1543
  }
1262
1544
 
1263
1545
  async function handleRemoteDispatchMessage({ chatId, text, config }) {
1264
1546
  const rd = getRemoteDispatchConfig(config);
1265
1547
  if (!rd || String(chatId) !== rd.chatId) return false;
1548
+ log('INFO', `Remote dispatch intercept chat=${chatId} preview=${String(text || '').slice(0, 48).replace(/\s+/g, ' ')}`);
1266
1549
 
1267
1550
  const packet = decodeRemoteDispatchPacket(text);
1268
- if (!packet) return true;
1551
+ if (!packet) {
1552
+ log('INFO', 'Remote dispatch decode miss');
1553
+ return true;
1554
+ }
1269
1555
  if (!verifyRemoteDispatchPacket(packet, rd.secret)) {
1270
1556
  log('WARN', 'Remote dispatch ignored: invalid signature');
1271
1557
  return true;
1272
1558
  }
1273
- if (packet.from_peer === rd.selfPeer) return true;
1274
- if (packet.to_peer !== rd.selfPeer) return true;
1559
+ if (packet.from_peer === rd.selfPeer) {
1560
+ log('INFO', `Remote dispatch ignored: self echo id=${packet.id || ''}`);
1561
+ return true;
1562
+ }
1563
+ if (packet.to_peer !== rd.selfPeer) {
1564
+ log('INFO', `Remote dispatch ignored: peer mismatch id=${packet.id || ''} to=${packet.to_peer || ''} self=${rd.selfPeer}`);
1565
+ return true;
1566
+ }
1275
1567
  if (isRemoteDispatchDuplicate(packet.id)) {
1276
1568
  log('DEBUG', `Remote dispatch ignored: duplicate id=${packet.id}`);
1277
1569
  return true;
1278
1570
  }
1571
+ log('INFO', `Remote dispatch received type=${packet.type} id=${packet.id || ''} from=${packet.from_peer}:${packet.target_project || 'unknown'}`);
1279
1572
 
1280
1573
  if (packet.type === 'task') {
1281
1574
  const replyFn = async (output) => {
@@ -1286,24 +1579,89 @@ async function handleRemoteDispatchMessage({ chatId, text, config }) {
1286
1579
  target_project: packet.target_project,
1287
1580
  source_chat_id: packet.source_chat_id,
1288
1581
  source_sender_key: packet.source_sender_key || 'user',
1582
+ source_sender_id: packet.source_sender_id || '',
1289
1583
  request_id: packet.id,
1290
1584
  result: String(output || '').slice(0, 4000),
1291
1585
  }, config);
1292
1586
  if (!res.success) log('WARN', `Remote dispatch result send failed: ${res.error}`);
1293
1587
  };
1294
1588
 
1295
- handleDispatchItem({
1589
+ const dispatchRes = handleDispatchItem({
1296
1590
  target: packet.target_project,
1297
1591
  prompt: packet.prompt,
1298
1592
  from: packet.source_sender_key || `${packet.from_peer}:remote`,
1299
1593
  new_session: !!packet.new_session,
1594
+ source_chat_id: packet.source_chat_id || '',
1595
+ source_sender_key: packet.source_sender_key || '',
1596
+ source_sender_id: packet.source_sender_id || '',
1300
1597
  _replyFn: replyFn,
1301
1598
  _suppressDefaultReplyRouting: true,
1302
1599
  }, config);
1600
+ const ackRes = await sendRemoteDispatch({
1601
+ type: 'ack',
1602
+ to_peer: packet.from_peer,
1603
+ target_project: packet.target_project,
1604
+ source_chat_id: packet.source_chat_id,
1605
+ source_sender_key: packet.source_sender_key || 'user',
1606
+ source_sender_id: packet.source_sender_id || '',
1607
+ request_id: packet.id,
1608
+ dispatch_id: dispatchRes && dispatchRes.id ? dispatchRes.id : '',
1609
+ task_id: dispatchRes && dispatchRes.task_id ? dispatchRes.task_id : '',
1610
+ scope_id: dispatchRes && dispatchRes.scope_id ? dispatchRes.scope_id : '',
1611
+ status: dispatchRes && dispatchRes.success ? 'accepted' : 'failed',
1612
+ error: dispatchRes && dispatchRes.success ? '' : String(dispatchRes && dispatchRes.error || 'dispatch_failed'),
1613
+ }, config);
1614
+ if (!ackRes.success) log('WARN', `Remote dispatch ack send failed: ${ackRes.error}`);
1615
+ return true;
1616
+ }
1617
+
1618
+ if (packet.type === 'ack') {
1619
+ resolveTrackedRemoteDispatch(packet.request_id);
1620
+ log('INFO', `Remote dispatch ack id=${packet.request_id || ''} status=${packet.status} from=${packet.from_peer}:${packet.target_project || 'unknown'}`);
1621
+ const text = String(packet.status) === 'accepted'
1622
+ ? [
1623
+ '📮 远端 Dispatch 回执',
1624
+ '',
1625
+ `状态: ${packet.from_peer}:${packet.target_project || 'unknown'} 已接收并入队`,
1626
+ packet.dispatch_id ? `编号: ${packet.dispatch_id}` : '',
1627
+ packet.task_id ? buildTeamTaskResumeHint(packet.task_id, packet.scope_id) : '',
1628
+ ].filter(Boolean).join('\n')
1629
+ : [
1630
+ '❌ 远端 Dispatch 回执',
1631
+ '',
1632
+ `状态: ${packet.from_peer}:${packet.target_project || 'unknown'} 入队失败`,
1633
+ packet.error ? `错误: ${String(packet.error).slice(0, 200)}` : '',
1634
+ ].filter(Boolean).join('\n');
1635
+
1636
+ const targetChatId = String(packet.source_chat_id || '').trim();
1637
+ if (targetChatId) {
1638
+ const liveBot = _dispatchBridgeRef && _dispatchBridgeRef.bot;
1639
+ if (!liveBot) {
1640
+ writeDispatchReceiptInbox({ from: packet.source_sender_key || 'user' }, { status: packet.status, targetKey: packet.target_project, dispatchId: packet.dispatch_id, text });
1641
+ return true;
1642
+ }
1643
+ try {
1644
+ if (liveBot.sendMarkdown) await liveBot.sendMarkdown(targetChatId, text);
1645
+ else await liveBot.sendMessage(targetChatId, text);
1646
+ } catch (e) {
1647
+ log('WARN', `Remote dispatch ack delivery failed: ${e.message}`);
1648
+ writeDispatchReceiptInbox({ from: packet.source_sender_key || 'user' }, { status: packet.status, targetKey: packet.target_project, dispatchId: packet.dispatch_id, text });
1649
+ }
1650
+ return true;
1651
+ }
1652
+
1653
+ writeDispatchReceiptInbox({ from: packet.source_sender_key || 'user' }, {
1654
+ status: packet.status,
1655
+ targetKey: packet.target_project,
1656
+ dispatchId: packet.dispatch_id,
1657
+ text,
1658
+ });
1303
1659
  return true;
1304
1660
  }
1305
1661
 
1306
1662
  if (packet.type === 'result') {
1663
+ resolveTrackedRemoteDispatch(packet.request_id);
1664
+ log('INFO', `Remote dispatch result id=${packet.request_id || ''} from=${packet.from_peer}:${packet.target_project || 'unknown'}`);
1307
1665
  const targetChatId = String(packet.source_chat_id || '').trim();
1308
1666
  if (!targetChatId) {
1309
1667
  const inboxTarget = String(packet.source_sender_key || '').trim();
@@ -1361,8 +1719,8 @@ function startDispatchSocket(getConfig) {
1361
1719
  try {
1362
1720
  const item = JSON.parse(buf);
1363
1721
  const liveCfg = typeof getConfig === 'function' ? getConfig() : getConfig;
1364
- handleDispatchItem(item, liveCfg || {});
1365
- conn.write(JSON.stringify({ ok: true }) + '\n');
1722
+ const result = handleDispatchItem(item, liveCfg || {});
1723
+ conn.write(JSON.stringify({ ok: !!(result && result.success), id: result && result.id ? result.id : null, error: result && result.error ? result.error : null }) + '\n');
1366
1724
  } catch (e) {
1367
1725
  try { conn.write(JSON.stringify({ ok: false, error: e.message }) + '\n'); } catch { /* ignore */ }
1368
1726
  }
@@ -1420,9 +1778,19 @@ function physiologicalHeartbeat(config) {
1420
1778
  const liveBot = _dispatchBridgeRef && _dispatchBridgeRef.bot;
1421
1779
  for (const item of items) {
1422
1780
  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
- );
1781
+ const packet = decodeRemoteDispatchPacket(item.body);
1782
+ liveBot.sendMessage(item.relay_chat_id, item.body)
1783
+ .then(() => {
1784
+ if (packet) {
1785
+ 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}`);
1786
+ if (packet.type === 'task') trackRemoteDispatch(packet, config);
1787
+ } else {
1788
+ log('INFO', `Remote dispatch queue sent raw via=${item.relay_chat_id}`);
1789
+ }
1790
+ })
1791
+ .catch(e2 =>
1792
+ log('WARN', `Remote dispatch relay send failed: ${e2.message}`)
1793
+ );
1426
1794
  }
1427
1795
  }
1428
1796
  }
@@ -1453,7 +1821,6 @@ function physiologicalHeartbeat(config) {
1453
1821
  const CLAUDE_COOLDOWN_MS = 10000; // 10s between Claude calls per chat
1454
1822
  const STATUS_THROTTLE_MS = 3000; // Min 3s between streaming status updates
1455
1823
  const FALLBACK_THROTTLE_MS = 8000; // 8s between fallback status updates
1456
- const DEDUP_TTL_MS = 60000; // Feishu message dedup window (60s)
1457
1824
  // ─────────────────────────────────────────────────────────────────────────────
1458
1825
 
1459
1826
  // Rate limiter for /ask and /run — prevents rapid-fire Claude calls
@@ -1629,6 +1996,7 @@ async function doBindAgent(bot, chatId, agentName, agentCwd) {
1629
1996
  // ---------------------------------------------------------
1630
1997
  const {
1631
1998
  findSessionFile,
1999
+ findCodexSessionFile,
1632
2000
  clearSessionFileCache,
1633
2001
  truncateSessionToCheckpoint,
1634
2002
  listRecentSessions,
@@ -1638,15 +2006,17 @@ const {
1638
2006
  sessionRichLabel,
1639
2007
  getSessionRecentContext,
1640
2008
  buildSessionCardElements,
1641
- listProjectDirs,
1642
2009
  getSession,
1643
2010
  getSessionForEngine,
1644
2011
  createSession,
2012
+ restoreSessionFromReply,
1645
2013
  getSessionName,
1646
2014
  writeSessionName,
1647
2015
  markSessionStarted,
1648
2016
  watchSessionFiles,
1649
2017
  isEngineSessionValid,
2018
+ getCodexSessionSandboxProfile,
2019
+ getCodexSessionPermissionMode,
1650
2020
  } = createSessionStore({
1651
2021
  fs,
1652
2022
  path,
@@ -1799,6 +2169,8 @@ const getEngineRuntime = createEngineRuntimeFactory({
1799
2169
  getActiveProviderEnv,
1800
2170
  });
1801
2171
 
2172
+ let wakeRecoveryHook = null;
2173
+
1802
2174
  const {
1803
2175
  checkPrecondition,
1804
2176
  executeTask,
@@ -1827,6 +2199,7 @@ const {
1827
2199
  isInSleepMode: () => _inSleepMode,
1828
2200
  setSleepMode: (next) => { _inSleepMode = !!next; },
1829
2201
  spawnSessionSummaries,
2202
+ getWakeRecoveryHook: () => wakeRecoveryHook,
1830
2203
  skillEvolution,
1831
2204
  });
1832
2205
 
@@ -1933,7 +2306,10 @@ const { spawnClaudeAsync, askClaude } = createClaudeEngine({
1933
2306
  sendFileButtons,
1934
2307
  findSessionFile,
1935
2308
  listRecentSessions,
2309
+ getSessionRecentContext,
1936
2310
  isEngineSessionValid,
2311
+ getCodexSessionSandboxProfile,
2312
+ getCodexSessionPermissionMode,
1937
2313
  getSession,
1938
2314
  getSessionForEngine,
1939
2315
  createSession,
@@ -1993,6 +2369,7 @@ const { handleAgentCommand } = createAgentCommandHandler({
1993
2369
  sendBrowse,
1994
2370
  sendDirPicker,
1995
2371
  getSession,
2372
+ getSessionForEngine,
1996
2373
  listRecentSessions,
1997
2374
  buildSessionCardElements,
1998
2375
  sessionLabel,
@@ -2038,8 +2415,10 @@ const { handleExecCommand } = createExecCommandHandler({
2038
2415
  getSessionName,
2039
2416
  createSession,
2040
2417
  findSessionFile,
2418
+ findCodexSessionFile,
2041
2419
  loadConfig,
2042
2420
  getDistillModel,
2421
+ getDefaultEngine,
2043
2422
  });
2044
2423
 
2045
2424
  const { handleOpsCommand } = createOpsCommandHandler({
@@ -2048,9 +2427,12 @@ const { handleOpsCommand } = createOpsCommandHandler({
2048
2427
  spawn,
2049
2428
  execSync,
2050
2429
  log,
2430
+ loadConfig,
2431
+ loadState,
2051
2432
  messageQueue,
2052
2433
  activeProcesses,
2053
2434
  getSession,
2435
+ getSessionForEngine,
2054
2436
  listCheckpoints,
2055
2437
  cpDisplayLabel,
2056
2438
  truncateSessionToCheckpoint,
@@ -2061,6 +2443,7 @@ const { handleOpsCommand } = createOpsCommandHandler({
2061
2443
  cleanupCheckpoints,
2062
2444
  getNoSleepProcess: () => caffeinateProcess,
2063
2445
  setNoSleepProcess: (p) => { caffeinateProcess = p || null; },
2446
+ getDefaultEngine,
2064
2447
  });
2065
2448
 
2066
2449
  const { handleCommand } = createCommandRouter({
@@ -2097,7 +2480,7 @@ setDispatchHandler(handleCommand);
2097
2480
  // ---------------------------------------------------------
2098
2481
  // BOT BRIDGES
2099
2482
  // ---------------------------------------------------------
2100
- const { startTelegramBridge, startFeishuBridge } = createBridgeStarter({
2483
+ const { startTelegramBridge, startFeishuBridge, startImessageBridge, startSiriBridge } = createBridgeStarter({
2101
2484
  fs,
2102
2485
  path,
2103
2486
  HOME,
@@ -2107,6 +2490,7 @@ const { startTelegramBridge, startFeishuBridge } = createBridgeStarter({
2107
2490
  loadState,
2108
2491
  saveState,
2109
2492
  getSession,
2493
+ restoreSessionFromReply,
2110
2494
  handleCommand,
2111
2495
  pendingActivations,
2112
2496
  activeProcesses,
@@ -2231,7 +2615,7 @@ async function main() {
2231
2615
  }
2232
2616
 
2233
2617
  // Config validation: warn on unknown/suspect fields
2234
- const KNOWN_SECTIONS = ['daemon', 'telegram', 'feishu', 'heartbeat', 'budget', 'projects'];
2618
+ const KNOWN_SECTIONS = ['daemon', 'telegram', 'feishu', 'heartbeat', 'budget', 'projects', 'imessage', 'siri_bridge'];
2235
2619
  const KNOWN_DAEMON = [
2236
2620
  'model', // legacy (still valid as fallback)
2237
2621
  'models', // per-engine model map: { claude, codex }
@@ -2322,6 +2706,7 @@ async function main() {
2322
2706
  // Bridges
2323
2707
  let telegramBridge = null;
2324
2708
  let feishuBridge = null;
2709
+ let lastWakeBridgeRecoveryAt = 0;
2325
2710
 
2326
2711
  const notifier = createNotifier({
2327
2712
  log,
@@ -2335,6 +2720,25 @@ async function main() {
2335
2720
  // Start dispatch socket server (low-latency IPC, fallback: file polling still works)
2336
2721
  const dispatchSocket = startDispatchSocket(() => config);
2337
2722
 
2723
+ wakeRecoveryHook = async ({ sleepSeconds }) => {
2724
+ const now = Date.now();
2725
+ if (now - lastWakeBridgeRecoveryAt < 60 * 1000) {
2726
+ log('INFO', `[WAKE-DETECT] bridge recovery skipped — cooldown active (${Math.round((now - lastWakeBridgeRecoveryAt) / 1000)}s since last)`);
2727
+ return;
2728
+ }
2729
+ lastWakeBridgeRecoveryAt = now;
2730
+ const tasks = [];
2731
+ if (telegramBridge && typeof telegramBridge.reconnect === 'function') {
2732
+ log('INFO', `[WAKE-DETECT] reconnecting Telegram bridge after ${sleepSeconds}s sleep`);
2733
+ tasks.push(Promise.resolve().then(() => telegramBridge.reconnect()));
2734
+ }
2735
+ if (feishuBridge && typeof feishuBridge.reconnect === 'function') {
2736
+ log('INFO', `[WAKE-DETECT] reconnecting Feishu bridge after ${sleepSeconds}s sleep`);
2737
+ tasks.push(Promise.resolve().then(() => feishuBridge.reconnect()));
2738
+ }
2739
+ await Promise.allSettled(tasks);
2740
+ };
2741
+
2338
2742
  // Start heartbeat scheduler
2339
2743
  let heartbeatTimer = startHeartbeat(config, notifyFn, notifyPersonalFn);
2340
2744
 
@@ -2399,6 +2803,8 @@ async function main() {
2399
2803
  // Start bridges (both can run simultaneously)
2400
2804
  telegramBridge = await startTelegramBridge(config, executeTaskByName);
2401
2805
  feishuBridge = await startFeishuBridge(config, executeTaskByName);
2806
+ await startImessageBridge(config, executeTaskByName);
2807
+ await startSiriBridge(config, executeTaskByName);
2402
2808
  if (feishuBridge) _dispatchBridgeRef = feishuBridge; // store bridge, not bot, so .bot stays live after reconnects
2403
2809
 
2404
2810
  // Notify once on startup (single message, no duplicates)
@@ -2445,6 +2851,8 @@ async function main() {
2445
2851
  try { require('./qmd-client').stopDaemon(); } catch { /* ignore */ }
2446
2852
  // Kill all tracked engine process groups before exiting (covers sub-agents too)
2447
2853
  for (const [cid, proc] of activeProcesses) {
2854
+ proc.aborted = true;
2855
+ proc.abortReason = opts.restartReason ? 'daemon-restart' : 'shutdown';
2448
2856
  try { process.kill(-proc.child.pid, 'SIGKILL'); } catch { try { proc.child.kill('SIGKILL'); } catch { } }
2449
2857
  log('INFO', `Shutdown: killed engine process group for chatId ${cid}`);
2450
2858
  }
@@ -2458,6 +2866,10 @@ async function main() {
2458
2866
  process.exit(0);
2459
2867
  };
2460
2868
 
2869
+ process.on('SIGUSR2', () => {
2870
+ shutdown({ restartReason: process.env.METAME_DEPLOY_RESTART_REASON || 'external-restart' })
2871
+ .catch(() => process.exit(1));
2872
+ });
2461
2873
  process.on('SIGTERM', () => { shutdown().catch(() => process.exit(0)); });
2462
2874
  process.on('SIGINT', () => { shutdown().catch(() => process.exit(0)); });
2463
2875
 
@@ -2518,4 +2930,21 @@ if (process.argv.includes('--run')) {
2518
2930
  }
2519
2931
 
2520
2932
  // Export for testing & cross-bot dispatch
2521
- module.exports = { executeTask, loadConfig, loadState, buildProfilePreamble, parseInterval, handleRemoteDispatchMessage, sendRemoteDispatch };
2933
+ module.exports = {
2934
+ executeTask,
2935
+ loadConfig,
2936
+ loadState,
2937
+ buildProfilePreamble,
2938
+ parseInterval,
2939
+ handleRemoteDispatchMessage,
2940
+ sendRemoteDispatch,
2941
+ __test: {
2942
+ buildDispatchPrompt,
2943
+ createStreamForwardBot,
2944
+ buildDispatchTaskCard,
2945
+ stripLeadingPlanSection,
2946
+ resolveDispatchTarget,
2947
+ resolveDispatchReadOnly,
2948
+ isMacLocalOrchestratorIntent,
2949
+ },
2950
+ };