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
@@ -2,8 +2,10 @@
2
2
 
3
3
  let userAcl = null;
4
4
  try { userAcl = require('./daemon-user-acl'); } catch { /* optional */ }
5
- const { findTeamMember: _findTeamMember } = require('./team-dispatch');
5
+ const { findTeamMember: _findTeamMember } = require('./daemon-team-dispatch');
6
6
  const { isRemoteMember } = require('./daemon-remote-dispatch');
7
+ const imessageIO = (() => { try { return require('./daemon-siri-imessage'); } catch { return null; } })();
8
+ const siriBridgeMod = (() => { try { return require('./daemon-siri-bridge'); } catch { return null; } })();
7
9
 
8
10
  function createBridgeStarter(deps) {
9
11
  const {
@@ -16,6 +18,7 @@ function createBridgeStarter(deps) {
16
18
  loadState,
17
19
  saveState,
18
20
  getSession,
21
+ restoreSessionFromReply,
19
22
  handleCommand,
20
23
  pendingActivations, // optional — used to show smart activation hint
21
24
  activeProcesses, // optional — used for auto-dispatch to clones
@@ -82,7 +85,7 @@ function createBridgeStarter(deps) {
82
85
  return latest;
83
86
  }
84
87
 
85
- function unauthorizedMsg(chatId, useSend) {
88
+ function unauthorizedMsg(chatId) {
86
89
  const pending = getPendingActivationForChat(chatId);
87
90
  if (pending) {
88
91
  return `⚠️ 此群未授权\n\n发送以下命令激活 Agent「${pending.agentName}」:\n\`/activate\``;
@@ -90,21 +93,81 @@ function createBridgeStarter(deps) {
90
93
  return '⚠️ 此群未授权\n\n如已创建 Agent,发送 `/activate` 完成绑定。\n否则请先在主群创建 Agent。';
91
94
  }
92
95
 
96
+ function extractFeishuReplyMessageId(event) {
97
+ const candidates = [
98
+ event && event.message && event.message.parent_id,
99
+ event && event.message && event.message.parent_message_id,
100
+ event && event.message && event.message.root_id,
101
+ event && event.message && event.message.reply_in_thread_id,
102
+ event && event.event && event.event.message && event.event.message.parent_id,
103
+ event && event.event && event.event.message && event.event.message.parent_message_id,
104
+ event && event.event && event.event.message && event.event.message.root_id,
105
+ event && event.event && event.event.message && event.event.message.reply_in_thread_id,
106
+ ];
107
+ for (const value of candidates) {
108
+ const text = String(value || '').trim();
109
+ if (text) return text;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ function trackBridgeReplyMapping(messageId, payload = {}) {
115
+ const safeMessageId = String(messageId || '').trim();
116
+ if (!safeMessageId) return;
117
+ const state = loadState();
118
+ if (!state.msg_sessions) state.msg_sessions = {};
119
+ state.msg_sessions[safeMessageId] = {
120
+ ...(state.msg_sessions[safeMessageId] || {}),
121
+ ...payload,
122
+ };
123
+ saveState(state);
124
+ }
125
+
126
+ function inferSessionMapping(logicalChatId, fallback = {}) {
127
+ const chatKey = String(logicalChatId || '').trim();
128
+ if (!chatKey) return { ...fallback };
129
+ const state = loadState();
130
+ const raw = state.sessions && state.sessions[chatKey];
131
+ if (!raw || typeof raw !== 'object') {
132
+ return {
133
+ logicalChatId: chatKey,
134
+ ...fallback,
135
+ };
136
+ }
137
+ const engines = raw.engines && typeof raw.engines === 'object' ? raw.engines : {};
138
+ const preferredEngine = String(fallback.engine || '').trim().toLowerCase();
139
+ const slot = (preferredEngine && engines[preferredEngine])
140
+ || engines.codex
141
+ || engines.claude
142
+ || null;
143
+ return {
144
+ ...(slot && slot.id ? { id: String(slot.id) } : {}),
145
+ cwd: raw.cwd || fallback.cwd,
146
+ engine: preferredEngine || (engines.codex ? 'codex' : 'claude'),
147
+ logicalChatId: chatKey,
148
+ ...((slot && slot.sandboxMode) ? { sandboxMode: slot.sandboxMode } : {}),
149
+ ...((slot && slot.approvalPolicy) ? { approvalPolicy: slot.approvalPolicy } : {}),
150
+ ...((slot && slot.permissionMode) ? { permissionMode: slot.permissionMode } : {}),
151
+ ...fallback,
152
+ };
153
+ }
154
+
93
155
  // ── Team group helpers ─────────────────────────────────────────────────
94
156
  function _getBoundProject(chatId, cfg) {
95
157
  const map = {
96
- ...(cfg.telegram ? cfg.telegram.chat_agent_map || {} : {}),
97
- ...(cfg.feishu ? cfg.feishu.chat_agent_map || {} : {}),
158
+ ...(cfg.telegram ? cfg.telegram.chat_agent_map || {} : {}),
159
+ ...(cfg.feishu ? cfg.feishu.chat_agent_map || {} : {}),
160
+ ...(cfg.imessage ? cfg.imessage.chat_agent_map || {} : {}),
98
161
  };
99
162
  const key = map[String(chatId)];
100
163
  const proj = key && cfg.projects ? cfg.projects[key] : null;
101
164
  return { key: key || null, project: proj || null };
102
165
  }
103
- // _findTeamMember is imported from team-dispatch.js (shared with admin-commands)
166
+ // _findTeamMember is imported from daemon-team-dispatch.js (shared with admin-commands)
104
167
 
105
168
  // Creates a bot proxy that redirects all send methods to replyChatId
106
169
  function _createTeamProxyBot(bot, replyChatId) {
107
- const SEND = new Set(['sendMessage', 'sendMarkdown', 'sendCard', 'editMessage', 'deleteMessage', 'sendTyping', 'sendFile', 'sendButtonCard']);
170
+ const SEND = new Set(['sendMessage', 'sendMarkdown', 'sendCard', 'editMessage', 'deleteMessage', 'sendTyping', 'sendFile', 'sendButtons', 'sendButtonCard']);
108
171
  return new Proxy(bot, {
109
172
  get(target, prop) {
110
173
  const orig = target[prop];
@@ -201,6 +264,7 @@ function createBridgeStarter(deps) {
201
264
  prompt: text,
202
265
  source_chat_id: String(realChatId),
203
266
  source_sender_key: acl.senderId || 'user',
267
+ source_sender_id: acl.senderId || '',
204
268
  }, cfg).then(res => {
205
269
  if (res.success) {
206
270
  bot.sendMessage(realChatId, `📡 已发送给 ${member.icon || '🤖'} ${member.name} (${member.peer})`).catch(() => {});
@@ -260,12 +324,16 @@ function createBridgeStarter(deps) {
260
324
 
261
325
  let offset = 0;
262
326
  let running = true;
263
- const abortController = new AbortController();
327
+ let abortController = new AbortController();
328
+ let pollLoopActive = false;
329
+ let reconnectTimer = null;
264
330
 
265
- const pollLoop = async () => {
266
- while (running) {
267
- try {
268
- const updates = await bot.getUpdates(offset, 30, abortController.signal);
331
+ const pollLoop = async (signal) => {
332
+ pollLoopActive = true;
333
+ try {
334
+ while (running && signal === abortController.signal) {
335
+ try {
336
+ const updates = await bot.getUpdates(offset, 30, signal);
269
337
  for (const update of updates) {
270
338
  offset = update.update_id + 1;
271
339
 
@@ -353,6 +421,19 @@ function createBridgeStarter(deps) {
353
421
  ? `User uploaded a file to the project: ${destPath}\nUser says: "${caption}"`
354
422
  : `User uploaded a file to the project: ${destPath}\nAcknowledge receipt. Only read the file if the user asks you to.`;
355
423
 
424
+ // Respect team_sticky: route to active agent same as text messages
425
+ const _stFile = loadState();
426
+ const _chatKeyFile = String(chatId);
427
+ const { project: _boundProjFile } = _getBoundProject(chatId, liveCfg);
428
+ const _stickyKeyFile = (_stFile.team_sticky || {})[_chatKeyFile];
429
+ if (_boundProjFile && Array.isArray(_boundProjFile.team) && _boundProjFile.team.length > 0 && _stickyKeyFile) {
430
+ const _stickyMember = _boundProjFile.team.find(m => m.key === _stickyKeyFile);
431
+ if (_stickyMember) {
432
+ log('INFO', `Telegram file → sticky route to ${_stickyKeyFile}`);
433
+ _dispatchToTeamMember(_stickyMember, _boundProjFile, prompt, liveCfg, bot, chatId, executeTaskByName, acl);
434
+ continue;
435
+ }
436
+ }
356
437
  handleCommand(bot, chatId, prompt, liveCfg, executeTaskByName, acl.senderId, acl.readOnly).catch(e => {
357
438
  log('ERROR', `Telegram file handler error: ${e.message}`);
358
439
  });
@@ -377,11 +458,29 @@ function createBridgeStarter(deps) {
377
458
 
378
459
  // Team group routing for Telegram (same logic as Feishu)
379
460
  const trimmedText = text.trim();
461
+ const parentId = msg.reply_to_message && msg.reply_to_message.message_id
462
+ ? String(msg.reply_to_message.message_id)
463
+ : null;
464
+ let _replyAgentKey = null;
380
465
  const { key: _boundKey, project: _boundProj } = _getBoundProject(chatId, liveCfg);
381
466
  const _isTeamSlashCmd = trimmedText.startsWith('/') && !/^\/stop(\s|$)/i.test(trimmedText);
382
467
 
383
468
  // Load sticky state
384
469
  const _st = loadState();
470
+ if (parentId) {
471
+ const mapped = _st.msg_sessions && _st.msg_sessions[parentId];
472
+ if (mapped) {
473
+ if (typeof restoreSessionFromReply === 'function') {
474
+ restoreSessionFromReply(chatId, mapped);
475
+ } else {
476
+ if (!_st.sessions) _st.sessions = {};
477
+ _st.sessions[chatId] = { id: mapped.id, cwd: mapped.cwd, started: true };
478
+ saveState(_st);
479
+ }
480
+ log('INFO', `Telegram session restored via reply: ${mapped.id.slice(0, 8)} (${path.basename(mapped.cwd)})`);
481
+ _replyAgentKey = mapped.agentKey || null;
482
+ }
483
+ }
385
484
  const _chatKey = String(chatId);
386
485
  const _setSticky = (key) => {
387
486
  if (!_st.team_sticky) _st.team_sticky = {};
@@ -399,24 +498,55 @@ function createBridgeStarter(deps) {
399
498
  const _stopMatch = trimmedText && trimmedText.match(/^\/stop(?:\s+(.+))?$/i);
400
499
  if (_stopMatch) {
401
500
  const _stopArg = (_stopMatch[1] || '').trim();
402
- if (_stopArg) {
501
+ let _targetKey = null;
502
+ if (_replyAgentKey) {
503
+ const m = _boundProj.team.find(t => t.key === _replyAgentKey);
504
+ if (m) _targetKey = m.key;
505
+ }
506
+ if (!_targetKey && _stopArg) {
403
507
  const _sa = _stopArg.toLowerCase();
404
508
  const m = _boundProj.team.find(t =>
405
509
  (t.nicknames || []).some(n => n.toLowerCase() === _sa) || (t.name && t.name.toLowerCase() === _sa) || t.key === _sa
406
510
  );
407
- if (m) {
408
- _clearSticky();
409
- log('INFO', `Team /stop: ${_chatKey.slice(-8)} cleared sticky`);
410
- await bot.sendMessage(chatId, `⏹ 已切换回主 Agent`).catch(() => {});
511
+ if (m) _targetKey = m.key;
512
+ }
513
+ if (!_targetKey && !_stopArg) _targetKey = _stickyKey;
514
+ if (_targetKey) {
515
+ const vid = `_agent_${_targetKey}`;
516
+ const member = _boundProj.team.find(t => t.key === _targetKey);
517
+ const label = member ? `${member.icon || '🤖'} ${member.name}` : _targetKey;
518
+ if (messageQueue.has(vid)) {
519
+ const vq = messageQueue.get(vid);
520
+ if (vq && vq.timer) clearTimeout(vq.timer);
521
+ messageQueue.delete(vid);
522
+ }
523
+ const vproc = activeProcesses && activeProcesses.get(vid);
524
+ if (vproc && vproc.child) {
525
+ vproc.aborted = true;
526
+ const sig = vproc.killSignal || 'SIGTERM';
527
+ try { process.kill(-vproc.child.pid, sig); } catch { try { vproc.child.kill(sig); } catch { /* */ } }
528
+ await bot.sendMessage(chatId, `⏹ Stopping ${label}...`);
411
529
  } else {
412
- await bot.sendMessage(chatId, `❌ 未找到团队成员: ${_stopArg}`).catch(() => {});
530
+ await bot.sendMessage(chatId, `${label} 当前没有活跃任务`);
413
531
  }
414
532
  continue;
415
533
  }
416
- // Bare /stop, clear sticky
417
- _clearSticky();
418
- await bot.sendMessage(chatId, `⏹ 已切换回主 Agent`).catch(() => {});
419
- continue;
534
+ if (_stopArg) {
535
+ await bot.sendMessage(chatId, `❌ 未找到团队成员: ${_stopArg}`).catch(() => {});
536
+ continue;
537
+ }
538
+ }
539
+
540
+ // 0. Quoted reply → force route + set sticky
541
+ if (_replyAgentKey) {
542
+ const member = _boundProj.team.find(m => m.key === _replyAgentKey);
543
+ if (member) {
544
+ _setSticky(member.key);
545
+ log('INFO', `Telegram quoted reply → force route to ${_replyAgentKey} (sticky set)`);
546
+ _dispatchToTeamMember(member, _boundProj, trimmedText, liveCfg, bot, chatId, executeTaskByName, acl);
547
+ continue;
548
+ }
549
+ log('INFO', `Telegram quoted reply agentKey=${_replyAgentKey} not in team, falling through`);
420
550
  }
421
551
 
422
552
  // 1. Explicit nickname → route + set sticky
@@ -472,16 +602,21 @@ function createBridgeStarter(deps) {
472
602
  });
473
603
  }
474
604
  }
475
- } catch (e) {
476
- if (e.message === 'aborted') break;
477
- log('ERROR', `Telegram poll error: ${e.message}`);
478
- await sleep(5000);
605
+ } catch (e) {
606
+ if (e.message === 'aborted') break;
607
+ log('ERROR', `Telegram poll error: ${e.message}`);
608
+ await sleep(5000);
609
+ }
479
610
  }
611
+ } finally {
612
+ pollLoopActive = false;
480
613
  }
481
614
  };
482
615
 
483
616
  const startPoll = () => {
484
- pollLoop().catch(e => {
617
+ if (!running || pollLoopActive) return;
618
+ const signal = abortController.signal;
619
+ pollLoop(signal).catch(e => {
485
620
  if (e.message === 'aborted') return;
486
621
  log('ERROR', `pollLoop crashed: ${e.message} — restarting in 5s`);
487
622
  if (running) setTimeout(startPoll, 5000);
@@ -490,7 +625,24 @@ function createBridgeStarter(deps) {
490
625
  startPoll();
491
626
 
492
627
  return {
493
- stop() { running = false; abortController.abort(); },
628
+ stop() {
629
+ running = false;
630
+ if (reconnectTimer) clearTimeout(reconnectTimer);
631
+ abortController.abort();
632
+ },
633
+ reconnect() {
634
+ if (!running) return;
635
+ if (reconnectTimer) clearTimeout(reconnectTimer);
636
+ try { abortController.abort(); } catch { /* ignore */ }
637
+ abortController = new AbortController();
638
+ reconnectTimer = setTimeout(() => {
639
+ reconnectTimer = null;
640
+ startPoll();
641
+ }, 150);
642
+ },
643
+ isAlive() {
644
+ return running && (pollLoopActive || !abortController.signal.aborted);
645
+ },
494
646
  bot,
495
647
  };
496
648
  }
@@ -508,6 +660,12 @@ function createBridgeStarter(deps) {
508
660
  try {
509
661
  const receiver = await bot.startReceiving(async (chatId, text, event, fileInfo, senderId) => {
510
662
  const liveCfg = loadConfig();
663
+ const relayCfg = liveCfg && liveCfg.feishu && liveCfg.feishu.remote_dispatch;
664
+ const relayChatId = relayCfg && relayCfg.chat_id ? String(relayCfg.chat_id) : '';
665
+ if (relayChatId && String(chatId) === relayChatId) {
666
+ const preview = String(text || '').slice(0, 80).replace(/\s+/g, ' ');
667
+ log('INFO', `Feishu relay event chat=${chatId} sender=${senderId || 'unknown'} preview=${preview}`);
668
+ }
511
669
 
512
670
  // ── Remote dispatch interception (before ACL) ──
513
671
  if (handleRemoteDispatchMessage && text) {
@@ -556,6 +714,19 @@ function createBridgeStarter(deps) {
556
714
  ? `User uploaded a file to the project: ${destPath}\nUser says: "${text}"`
557
715
  : `User uploaded a file to the project: ${destPath}\nAcknowledge receipt. Only read the file if the user asks you to.`;
558
716
 
717
+ // Respect team_sticky: route to active agent same as text messages
718
+ const _stFile = loadState();
719
+ const _chatKeyFile = String(chatId);
720
+ const { project: _boundProjFile } = _getBoundProject(chatId, liveCfg);
721
+ const _stickyKeyFile = (_stFile.team_sticky || {})[_chatKeyFile];
722
+ if (_boundProjFile && Array.isArray(_boundProjFile.team) && _boundProjFile.team.length > 0 && _stickyKeyFile) {
723
+ const _stickyMember = _boundProjFile.team.find(m => m.key === _stickyKeyFile);
724
+ if (_stickyMember) {
725
+ log('INFO', `Feishu file → sticky route to ${_stickyKeyFile}`);
726
+ _dispatchToTeamMember(_stickyMember, _boundProjFile, prompt, liveCfg, bot, chatId, executeTaskByName, acl);
727
+ return;
728
+ }
729
+ }
559
730
  await handleCommand(bot, chatId, prompt, liveCfg, executeTaskByName, acl.senderId, acl.readOnly);
560
731
  } catch (err) {
561
732
  log('ERROR', `Feishu file download failed: ${err.message}`);
@@ -575,18 +746,31 @@ function createBridgeStarter(deps) {
575
746
  });
576
747
  if (acl.blocked) return;
577
748
  log('INFO', `Feishu message from ${chatId}: ${text.slice(0, 50)}`);
578
- const parentId = event?.message?.parent_id;
749
+ const parentId = extractFeishuReplyMessageId(event);
579
750
  let _replyAgentKey = null;
580
751
  // Load state once for the entire routing block
581
752
  const _st = loadState();
753
+ if (parentId) {
754
+ log('INFO', `Feishu reply metadata detected chat=${chatId} parentId=${parentId}`);
755
+ }
582
756
  if (parentId) {
583
757
  const mapped = _st.msg_sessions && _st.msg_sessions[parentId];
584
758
  if (mapped) {
585
- if (!_st.sessions) _st.sessions = {};
586
- _st.sessions[chatId] = { id: mapped.id, cwd: mapped.cwd, started: true };
587
- saveState(_st);
588
- log('INFO', `Session restored via reply: ${mapped.id.slice(0, 8)} (${path.basename(mapped.cwd)})`);
759
+ if (typeof restoreSessionFromReply === 'function') {
760
+ restoreSessionFromReply(chatId, mapped);
761
+ } else {
762
+ if (mapped.id) {
763
+ if (!_st.sessions) _st.sessions = {};
764
+ _st.sessions[chatId] = { id: mapped.id, cwd: mapped.cwd, started: true };
765
+ saveState(_st);
766
+ }
767
+ }
768
+ if (mapped.id) {
769
+ log('INFO', `Session restored via reply: ${mapped.id.slice(0, 8)} (${path.basename(mapped.cwd)})`);
770
+ }
589
771
  _replyAgentKey = mapped.agentKey || null;
772
+ } else {
773
+ log('INFO', `Feishu reply parentId=${parentId} had no msg_sessions mapping`);
590
774
  }
591
775
  }
592
776
 
@@ -670,18 +854,28 @@ function createBridgeStarter(deps) {
670
854
  }
671
855
  // 1. Explicit nickname → route + set sticky
672
856
  const teamMatch = _findTeamMember(trimmedText, _boundProj.team);
673
- if (teamMatch) {
674
- const { member, rest } = teamMatch;
675
- _setSticky(member.key);
676
- if (!rest) {
677
- // Pure nickname, no task — confirm member is online
678
- log('INFO', `Sticky set (pure nickname): ${_chatKey.slice(-8)} → ${member.key}`);
679
- bot.sendMarkdown(chatId, `${member.icon || '🤖'} **${member.name}** 在线`).catch(() => {});
680
- return;
681
- }
682
- log('INFO', `Sticky set: ${_chatKey.slice(-8)} → ${member.key}`);
683
- _dispatchToTeamMember(member, _boundProj, rest, liveCfg, bot, chatId, executeTaskByName, acl);
684
- return;
857
+ if (teamMatch) {
858
+ const { member, rest } = teamMatch;
859
+ _setSticky(member.key);
860
+ if (!rest) {
861
+ // Pure nickname, no task — confirm member is online
862
+ log('INFO', `Sticky set (pure nickname): ${_chatKey.slice(-8)} → ${member.key}`);
863
+ bot.sendMarkdown(chatId, `${member.icon || '🤖'} **${member.name}** 在线`)
864
+ .then((msg) => {
865
+ if (msg && msg.message_id) {
866
+ trackBridgeReplyMapping(msg.message_id, inferSessionMapping(`_agent_${member.key}`, {
867
+ agentKey: member.key,
868
+ cwd: member.cwd || _boundProj.cwd,
869
+ engine: member.engine || _boundProj.engine || 'claude',
870
+ }));
871
+ }
872
+ })
873
+ .catch(() => {});
874
+ return;
875
+ }
876
+ log('INFO', `Sticky set: ${_chatKey.slice(-8)} → ${member.key}`);
877
+ _dispatchToTeamMember(member, _boundProj, rest, liveCfg, bot, chatId, executeTaskByName, acl);
878
+ return;
685
879
  }
686
880
 
687
881
  // 1.5. Main project nickname → clear sticky, route to main
@@ -691,11 +885,22 @@ function createBridgeStarter(deps) {
691
885
  if (_mainMatch) {
692
886
  _clearSticky();
693
887
  const rest = trimmedText.slice(_mainMatch.length).replace(/^[\s,,::]+/, '');
694
- log('INFO', `Main nickname → cleared sticky, routing to main${rest ? ` (task: ${rest.slice(0, 30)})` : ''}`);
695
- if (!rest) {
696
- bot.sendMarkdown(chatId, `${_boundProj.icon || '🤖'} **${_boundProj.name}** 在线`).catch(() => {});
697
- return;
698
- }
888
+ log('INFO', `Main nickname → cleared sticky, routing to main${rest ? ` (task: ${rest.slice(0, 30)})` : ''}`);
889
+ if (!rest) {
890
+ bot.sendMarkdown(chatId, `${_boundProj.icon || '🤖'} **${_boundProj.name}** 在线`)
891
+ .then((msg) => {
892
+ if (msg && msg.message_id) {
893
+ trackBridgeReplyMapping(msg.message_id, inferSessionMapping(String(chatId), {
894
+ agentKey: _boundKey || null,
895
+ cwd: _boundProj.cwd,
896
+ engine: _boundProj.engine || 'claude',
897
+ logicalChatId: _boundKey ? `_bound_${_boundKey}` : String(chatId),
898
+ }));
899
+ }
900
+ })
901
+ .catch(() => {});
902
+ return;
903
+ }
699
904
  try {
700
905
  await handleCommand(bot, chatId, rest, liveCfg, executeTaskByName, acl.senderId, acl.readOnly);
701
906
  } catch (e) {
@@ -729,7 +934,227 @@ function createBridgeStarter(deps) {
729
934
  }
730
935
  }
731
936
 
732
- return { startTelegramBridge, startFeishuBridge };
937
+ // ── iMessage Bridge ─────────────────────────────────────────────────────────
938
+ async function startImessageBridge(config, executeTaskByName) {
939
+ const cfg = config.imessage || {};
940
+ if (!cfg.enabled) return null;
941
+ if (!imessageIO) { log('WARN', '[IMESSAGE] daemon-siri-imessage module not found'); return null; }
942
+ if (!imessageIO.isAvailable()) { log('WARN', '[IMESSAGE] chat.db not found — bridge disabled'); return null; }
943
+
944
+ const selfId = cfg.self_id || '';
945
+ const allowedSenders = cfg.allowed_senders || (selfId ? [selfId] : []);
946
+ const allowedChats = cfg.allowed_chat_ids || [];
947
+ const pollMs = cfg.poll_ms || 2000;
948
+
949
+ if (!selfId) { log('WARN', '[IMESSAGE] self_id not configured — bridge disabled'); return null; }
950
+
951
+ let lastRowId = imessageIO.getMaxRowId();
952
+ let processing = false;
953
+ let running = true;
954
+
955
+ // Per-chat persistent bot instances (preserve state across polls)
956
+ const chatBots = new Map();
957
+ const getBot = (chatTarget) => {
958
+ if (!chatBots.has(chatTarget)) {
959
+ const bot = imessageIO.createImessageBot(chatTarget, log);
960
+ // After bot sends a reply, advance lastRowId immediately + again after delay
961
+ if (bot.setOnAfterSend) {
962
+ bot.setOnAfterSend(() => {
963
+ // Immediate advance — covers fast echo
964
+ const freshNow = imessageIO.getMaxRowId();
965
+ if (freshNow > lastRowId) {
966
+ log('INFO', `[IMESSAGE] Advanced lastRowId ${lastRowId}→${freshNow} (echo skip immediate)`);
967
+ lastRowId = freshNow;
968
+ }
969
+ // Delayed advance — covers slow iCloud sync echo
970
+ setTimeout(() => {
971
+ const freshLater = imessageIO.getMaxRowId();
972
+ if (freshLater > lastRowId) {
973
+ log('INFO', `[IMESSAGE] Advanced lastRowId ${lastRowId}→${freshLater} (echo skip delayed)`);
974
+ lastRowId = freshLater;
975
+ }
976
+ }, 3000);
977
+ });
978
+ }
979
+ chatBots.set(chatTarget, bot);
980
+ }
981
+ return chatBots.get(chatTarget);
982
+ };
983
+
984
+ log('INFO', `[IMESSAGE] Bridge started (poll=${pollMs}ms, self=${selfId}, lastRowId=${lastRowId})`);
985
+
986
+ const timer = setInterval(async () => {
987
+ if (!running || processing) return;
988
+ processing = true;
989
+ try {
990
+ const rows = imessageIO.queryNewMessages(lastRowId);
991
+ if (!rows) { processing = false; return; }
992
+
993
+ for (const row of rows.split('\n').filter(Boolean)) {
994
+ const parts = row.split('\t');
995
+ const rowId = parseInt(parts[0], 10);
996
+ const text = (parts[1] || '').trim();
997
+ const sender = (parts[2] || '').trim();
998
+ const chatGuid = (parts[3] || '').trim();
999
+ const chatIdentifier = (parts[4] || '').trim();
1000
+ const chatName = (parts[5] || '').trim();
1001
+ const chatTarget = chatGuid || chatIdentifier || sender;
1002
+
1003
+ if (!rowId || rowId <= lastRowId) continue;
1004
+ lastRowId = rowId;
1005
+ if (!text) continue;
1006
+ if (!chatTarget) continue;
1007
+
1008
+ if (allowedSenders.length && !allowedSenders.includes(sender)) {
1009
+ log('INFO', `[IMESSAGE] Ignored message from ${sender} (not in allowed_senders)`);
1010
+ continue;
1011
+ }
1012
+ if (allowedChats.length && !allowedChats.includes(chatTarget) && !allowedChats.includes(chatIdentifier)) {
1013
+ log('INFO', `[IMESSAGE] Ignored chat ${chatTarget} (${chatName || sender || 'unknown'}) not in allowed_chat_ids`);
1014
+ continue;
1015
+ }
1016
+
1017
+ const chatId = chatTarget;
1018
+ const liveCfg = loadConfig();
1019
+ const bot = getBot(chatTarget);
1020
+
1021
+ // Echo fingerprint check — skip if this text matches something we recently sent
1022
+ if (bot.isEcho && bot.isEcho(text)) {
1023
+ log('INFO', `[IMESSAGE] Skipped echo: "${text.slice(0, 40)}"`);
1024
+ continue;
1025
+ }
1026
+
1027
+ const trimmedText = text.trim();
1028
+ let commandText = text;
1029
+
1030
+ log('INFO', `[IMESSAGE] Received chat=${chatTarget} sender=${sender || 'unknown'} name=${chatName || '-'}: "${text.slice(0, 60)}"`);
1031
+
1032
+ const acl = await applyUserAcl({
1033
+ bot,
1034
+ chatId,
1035
+ text,
1036
+ config: liveCfg,
1037
+ senderId: sender,
1038
+ bypassAcl: false,
1039
+ });
1040
+ if (acl.blocked) continue;
1041
+
1042
+ const { project: _boundProj } = _getBoundProject(chatId, liveCfg);
1043
+ const _isTeamSlashCmd = trimmedText.startsWith('/') && !/^\/stop(\s|$)/i.test(trimmedText);
1044
+ const _st = loadState();
1045
+ const _chatKey = String(chatId);
1046
+ const _setSticky = (key) => {
1047
+ if (!_st.team_sticky) _st.team_sticky = {};
1048
+ _st.team_sticky[_chatKey] = key;
1049
+ saveState(_st);
1050
+ };
1051
+ const _clearSticky = () => {
1052
+ if (_st.team_sticky) delete _st.team_sticky[_chatKey];
1053
+ saveState(_st);
1054
+ };
1055
+ const _stickyKey = (_st.team_sticky || {})[_chatKey] || null;
1056
+
1057
+ if (_boundProj && Array.isArray(_boundProj.team) && _boundProj.team.length > 0 && !_isTeamSlashCmd) {
1058
+ const _stopMatch = trimmedText.match(/^\/stop(?:\s+(.+))?$/i);
1059
+ if (_stopMatch) {
1060
+ const _stopArg = (_stopMatch[1] || '').trim();
1061
+ let _targetKey = null;
1062
+ if (_stopArg) {
1063
+ const _sa = _stopArg.toLowerCase();
1064
+ const m = _boundProj.team.find(t =>
1065
+ (t.nicknames || []).some(n => n.toLowerCase() === _sa) || (t.name && t.name.toLowerCase() === _sa) || t.key === _sa
1066
+ );
1067
+ if (m) _targetKey = m.key;
1068
+ }
1069
+ if (!_targetKey && !_stopArg) _targetKey = _stickyKey;
1070
+ if (_targetKey) {
1071
+ const vid = `_agent_${_targetKey}`;
1072
+ const member = _boundProj.team.find(t => t.key === _targetKey);
1073
+ const label = member ? `${member.icon || '🤖'} ${member.name}` : _targetKey;
1074
+ if (messageQueue.has(vid)) {
1075
+ const vq = messageQueue.get(vid);
1076
+ if (vq && vq.timer) clearTimeout(vq.timer);
1077
+ messageQueue.delete(vid);
1078
+ }
1079
+ const vproc = activeProcesses && activeProcesses.get(vid);
1080
+ if (vproc && vproc.child) {
1081
+ vproc.aborted = true;
1082
+ const sig = vproc.killSignal || 'SIGTERM';
1083
+ try { process.kill(-vproc.child.pid, sig); } catch { try { vproc.child.kill(sig); } catch { /* */ } }
1084
+ await bot.sendMessage(chatId, `Stopping ${label}...`);
1085
+ } else {
1086
+ await bot.sendMessage(chatId, `${label} 当前没有活跃任务`);
1087
+ }
1088
+ continue;
1089
+ }
1090
+ if (_stopArg) {
1091
+ await bot.sendMessage(chatId, `未找到团队成员: ${_stopArg}`);
1092
+ continue;
1093
+ }
1094
+ }
1095
+
1096
+ const teamMatch = _findTeamMember(trimmedText, _boundProj.team);
1097
+ if (teamMatch) {
1098
+ const { member, rest } = teamMatch;
1099
+ _setSticky(member.key);
1100
+ if (!rest) {
1101
+ await bot.sendMessage(chatId, `${member.icon || '🤖'} ${member.name} 在线`);
1102
+ continue;
1103
+ }
1104
+ log('INFO', `[IMESSAGE] Team route ${chatId} -> ${member.key}`);
1105
+ _dispatchToTeamMember(member, _boundProj, rest, liveCfg, bot, chatId, executeTaskByName, acl);
1106
+ continue;
1107
+ }
1108
+
1109
+ const _mainNicks = Array.isArray(_boundProj.nicknames) ? _boundProj.nicknames : [];
1110
+ const _trimLower = trimmedText.toLowerCase();
1111
+ const _mainMatch = _mainNicks.find(n =>
1112
+ _trimLower === n.toLowerCase()
1113
+ || _trimLower.startsWith(n.toLowerCase() + ' ')
1114
+ || _trimLower.startsWith(n.toLowerCase() + ',')
1115
+ || _trimLower.startsWith(n.toLowerCase() + ',')
1116
+ );
1117
+ if (_mainMatch) {
1118
+ _clearSticky();
1119
+ const rest = trimmedText.slice(_mainMatch.length).replace(/^[\s,,::]+/, '');
1120
+ if (!rest) {
1121
+ await bot.sendMessage(chatId, `${_boundProj.icon || '🤖'} ${_boundProj.name || 'Agent'} 在线`);
1122
+ continue;
1123
+ }
1124
+ commandText = rest;
1125
+ } else if (_stickyKey) {
1126
+ const member = _boundProj.team.find(m => m.key === _stickyKey);
1127
+ if (member) {
1128
+ log('INFO', `[IMESSAGE] Sticky route ${chatId} -> ${member.key}`);
1129
+ _dispatchToTeamMember(member, _boundProj, trimmedText, liveCfg, bot, chatId, executeTaskByName, acl);
1130
+ continue;
1131
+ }
1132
+ }
1133
+ }
1134
+
1135
+ handleCommand(bot, chatId, commandText, liveCfg, executeTaskByName, sender, false)
1136
+ .catch(e => log('ERROR', `[IMESSAGE] handleCommand error: ${e.message}`));
1137
+ }
1138
+ } catch (e) {
1139
+ log('WARN', `[IMESSAGE] poll error: ${e.message}`);
1140
+ }
1141
+ processing = false;
1142
+ }, pollMs);
1143
+
1144
+ return {
1145
+ stop: () => { running = false; clearInterval(timer); },
1146
+ bot: imessageIO.createImessageBot(selfId, log),
1147
+ };
1148
+ }
1149
+
1150
+ // ── Siri HTTP Bridge ────────────────────────────────────────────────────────
1151
+ function startSiriBridge(config, executeTaskByName) {
1152
+ if (!siriBridgeMod) { log('WARN', '[SIRI] daemon-siri-bridge module not found'); return null; }
1153
+ const bridge = siriBridgeMod.createSiriBridge({ log, loadConfig, handleCommand });
1154
+ return bridge.startSiriBridge(config, executeTaskByName);
1155
+ }
1156
+
1157
+ return { startTelegramBridge, startFeishuBridge, startImessageBridge, startSiriBridge };
733
1158
  }
734
1159
 
735
1160
  module.exports = { createBridgeStarter };