metame-cli 1.5.2 → 1.5.3

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.
@@ -16,6 +16,8 @@ function createBridgeStarter(deps) {
16
16
  getSession,
17
17
  handleCommand,
18
18
  pendingActivations, // optional — used to show smart activation hint
19
+ activeProcesses, // optional — used for auto-dispatch to clones
20
+ messageQueue, // optional — used for /stop to clear queued messages
19
21
  } = deps;
20
22
 
21
23
  async function sendAclReply(bot, chatId, text) {
@@ -84,6 +86,156 @@ function createBridgeStarter(deps) {
84
86
  return '⚠️ 此群未授权\n\n如已创建 Agent,发送 `/activate` 完成绑定。\n否则请先在主群创建 Agent。';
85
87
  }
86
88
 
89
+ // ── Team group helpers ─────────────────────────────────────────────────
90
+ function _getBoundProject(chatId, cfg) {
91
+ const map = {
92
+ ...(cfg.telegram ? cfg.telegram.chat_agent_map || {} : {}),
93
+ ...(cfg.feishu ? cfg.feishu.chat_agent_map || {} : {}),
94
+ };
95
+ const key = map[String(chatId)];
96
+ const proj = key && cfg.projects ? cfg.projects[key] : null;
97
+ return { key: key || null, project: proj || null };
98
+ }
99
+
100
+ function _escapeRe(s) {
101
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
102
+ }
103
+
104
+ function _findTeamMember(text, team) {
105
+ const t = String(text || '').trim();
106
+ for (const member of team) {
107
+ const nicks = Array.isArray(member.nicknames) ? member.nicknames : [];
108
+ for (const nick of nicks) {
109
+ const n = String(nick || '').trim();
110
+ if (!n) continue;
111
+ if (t.toLowerCase() === n.toLowerCase()) return { member, rest: '' };
112
+ const re = new RegExp(`^${_escapeRe(n)}[\\s,,、::]+`, 'i');
113
+ const m = t.match(re);
114
+ if (m) return { member, rest: t.slice(m[0].length).trim() };
115
+ }
116
+ }
117
+ return null;
118
+ }
119
+
120
+ // Creates a bot proxy that redirects all send methods to replyChatId
121
+ function _createTeamProxyBot(bot, replyChatId) {
122
+ const SEND = new Set(['sendMessage', 'sendMarkdown', 'sendCard', 'editMessage', 'deleteMessage', 'sendTyping', 'sendFile', 'sendButtonCard']);
123
+ return new Proxy(bot, {
124
+ get(target, prop) {
125
+ const orig = target[prop];
126
+ if (typeof orig !== 'function') return orig;
127
+ if (!SEND.has(prop)) return orig.bind(target);
128
+ return function(_chatId, ...args) { return orig.call(target, replyChatId, ...args); };
129
+ },
130
+ });
131
+ }
132
+ // Get team member's working directory using subdir (not worktree).
133
+ // Creates agents/<key>/ directory and symlinks CLAUDE.md from parent.
134
+ function _getMemberCwd(parentCwd, key) {
135
+ const { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync } = require('fs');
136
+ const { execFileSync } = require('child_process');
137
+ const WIN_HIDE = process.platform === 'win32' ? { windowsHide: true } : {};
138
+
139
+ // Sanitize key to prevent path traversal
140
+ const safeKey = String(key).replace(/[^a-zA-Z0-9_\-]/g, '').slice(0, 50);
141
+ if (safeKey !== key) {
142
+ log('WARN', `Sanitized team member key: ${key} -> ${safeKey}`);
143
+ }
144
+
145
+ // Use agents/<key>/ as the working directory
146
+ const agentsDir = path.join(parentCwd, 'agents');
147
+ const memberDir = path.join(agentsDir, safeKey);
148
+
149
+ // Create agents directory if not exists
150
+ if (!existsSync(agentsDir)) {
151
+ mkdirSync(agentsDir, { recursive: true });
152
+ }
153
+
154
+ // Create member directory if not exists
155
+ if (!existsSync(memberDir)) {
156
+ mkdirSync(memberDir, { recursive: true });
157
+ log('INFO', `Created agent directory: ${memberDir}`);
158
+ }
159
+
160
+ // Initialize git for checkpoint support
161
+ const gitDir = path.join(memberDir, '.git');
162
+ if (!existsSync(gitDir)) {
163
+ try {
164
+ execFileSync('git', ['init'], { cwd: memberDir, stdio: 'ignore', ...WIN_HIDE });
165
+ log('INFO', `Git repo initialized: ${memberDir}`);
166
+ } catch (e) {
167
+ log('WARN', `Failed to init git for ${memberDir}: ${e.message}`);
168
+ }
169
+ }
170
+
171
+ // Set up CLAUDE.md: use dedicated, or template, or symlink from parent
172
+ const claudeMd = path.join(memberDir, 'CLAUDE.md');
173
+ const parentClaudeMd = path.join(parentCwd, 'CLAUDE.md');
174
+ if (!existsSync(claudeMd)) {
175
+ // Priority 1: dedicated CLAUDE.md in agents/<key>/ directory
176
+ const dedicatedPath = path.join(parentCwd, 'agents', safeKey, 'CLAUDE.md');
177
+ if (existsSync(dedicatedPath)) {
178
+ try {
179
+ // Copy instead of symlink to avoid cross-device issues
180
+ const content = readFileSync(dedicatedPath, 'utf8');
181
+ writeFileSync(claudeMd, content, 'utf8');
182
+ log('INFO', `Copied dedicated CLAUDE.md for ${safeKey}`);
183
+ } catch (e) {
184
+ log('WARN', `Failed to copy CLAUDE.md for ${safeKey}: ${e.message}`);
185
+ }
186
+ } else if (existsSync(parentClaudeMd)) {
187
+ // Priority 2: symlink to parent CLAUDE.md
188
+ try {
189
+ // Use 'junction' on Windows for directories, 'file' for files
190
+ const linkType = process.platform === 'win32' ? 'junction' : 'file';
191
+ symlinkSync(parentClaudeMd, claudeMd, linkType);
192
+ log('INFO', `Symlinked CLAUDE.md for ${safeKey}`);
193
+ } catch (e) {
194
+ // Fallback: copy file
195
+ try {
196
+ const content = readFileSync(parentClaudeMd, 'utf8');
197
+ writeFileSync(claudeMd, content, 'utf8');
198
+ log('INFO', `Copied CLAUDE.md for ${safeKey} (symlink failed)`);
199
+ } catch (e2) {
200
+ log('WARN', `Failed to create CLAUDE.md for ${safeKey}: ${e2.message}`);
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ return memberDir;
207
+ }
208
+
209
+ function _dispatchToTeamMember(member, boundProj, text, cfg, bot, realChatId, executeTaskByName, acl) {
210
+ const virtualChatId = `_agent_${member.key}`;
211
+ const parentCwd = member.cwd || boundProj.cwd;
212
+ const resolvedParentCwd = parentCwd.replace(/^~/, require('os').homedir());
213
+ const memberCwd = _getMemberCwd(resolvedParentCwd, member.key);
214
+ if (!memberCwd) {
215
+ log('ERROR', `Team [${member.key}] cannot start: directory unavailable`);
216
+ bot.sendMessage(realChatId, `❌ ${member.icon || '🤖'} ${member.name} 启动失败:工作目录创建失败`).catch(() => {});
217
+ return;
218
+ }
219
+ log('INFO', `Team [${member.key}] using cwd: ${memberCwd}`);
220
+ const teamCfg = {
221
+ ...cfg,
222
+ projects: {
223
+ ...(cfg.projects || {}),
224
+ [member.key]: {
225
+ cwd: memberCwd,
226
+ name: member.name,
227
+ icon: member.icon || '🤖',
228
+ color: member.color || 'blue',
229
+ engine: member.engine || boundProj.engine,
230
+ },
231
+ },
232
+ };
233
+ const proxyBot = _createTeamProxyBot(bot, realChatId);
234
+ handleCommand(proxyBot, virtualChatId, text, teamCfg, executeTaskByName, acl.senderId, acl.readOnly)
235
+ .catch(e => log('ERROR', `Team [${member.key}] error: ${e.message}`));
236
+ }
237
+ // ────────────────────────────────────────────────────────────────────────
238
+
87
239
  async function startTelegramBridge(config, executeTaskByName) {
88
240
  if (!config.telegram || !config.telegram.enabled) return null;
89
241
  if (!config.telegram.bot_token) {
@@ -218,6 +370,99 @@ function createBridgeStarter(deps) {
218
370
  bypassAcl: !isAllowedChat && !!isBindCmd,
219
371
  });
220
372
  if (acl.blocked) continue;
373
+
374
+ // Team group routing for Telegram (same logic as Feishu)
375
+ const trimmedText = text.trim();
376
+ const { key: _boundKey, project: _boundProj } = _getBoundProject(chatId, liveCfg);
377
+ const _isTeamSlashCmd = trimmedText.startsWith('/') && !/^\/stop(\s|$)/i.test(trimmedText);
378
+
379
+ // Load sticky state
380
+ const _st = loadState();
381
+ const _chatKey = String(chatId);
382
+ const _setSticky = (key) => {
383
+ if (!_st.team_sticky) _st.team_sticky = {};
384
+ _st.team_sticky[_chatKey] = key;
385
+ saveState(_st);
386
+ };
387
+ const _clearSticky = () => {
388
+ if (_st.team_sticky) delete _st.team_sticky[_chatKey];
389
+ saveState(_st);
390
+ };
391
+ const _stickyKey = (_st.team_sticky || {})[_chatKey] || null;
392
+
393
+ if (_boundProj && Array.isArray(_boundProj.team) && _boundProj.team.length > 0 && !_isTeamSlashCmd) {
394
+ // Team dispatch logic (same as Feishu)
395
+ const _stopMatch = trimmedText && trimmedText.match(/^\/stop(?:\s+(.+))?$/i);
396
+ if (_stopMatch) {
397
+ const _stopArg = (_stopMatch[1] || '').trim();
398
+ if (_stopArg) {
399
+ const _sa = _stopArg.toLowerCase();
400
+ const m = _boundProj.team.find(t =>
401
+ (t.nicknames || []).some(n => n.toLowerCase() === _sa) || (t.name && t.name.toLowerCase() === _sa) || t.key === _sa
402
+ );
403
+ if (m) {
404
+ _clearSticky();
405
+ log('INFO', `Team /stop: ${_chatKey.slice(-8)} → cleared sticky`);
406
+ await bot.sendMessage(chatId, `⏹ 已切换回主 Agent`).catch(() => {});
407
+ } else {
408
+ await bot.sendMessage(chatId, `❌ 未找到团队成员: ${_stopArg}`).catch(() => {});
409
+ }
410
+ continue;
411
+ }
412
+ // Bare /stop, clear sticky
413
+ _clearSticky();
414
+ await bot.sendMessage(chatId, `⏹ 已切换回主 Agent`).catch(() => {});
415
+ continue;
416
+ }
417
+
418
+ // 1. Explicit nickname → route + set sticky
419
+ const teamMatch = _findTeamMember(trimmedText, _boundProj.team);
420
+ if (teamMatch) {
421
+ const { member, rest } = teamMatch;
422
+ _setSticky(member.key);
423
+ if (!rest) {
424
+ log('INFO', `Sticky set (pure nickname): ${_chatKey.slice(-8)} → ${member.key}`);
425
+ bot.sendMarkdown(chatId, `${member.icon || '🤖'} **${member.name}** 在线`).catch(() => {});
426
+ continue;
427
+ }
428
+ log('INFO', `Sticky set: ${_chatKey.slice(-8)} → ${member.key}`);
429
+ _dispatchToTeamMember(member, _boundProj, rest, liveCfg, bot, chatId, executeTaskByName, acl);
430
+ continue;
431
+ }
432
+
433
+ // 1.5. Main project nickname → clear sticky, route to main
434
+ const _mainNicks = Array.isArray(_boundProj.nicknames) ? _boundProj.nicknames : [];
435
+ const _trimLower = trimmedText.toLowerCase();
436
+ const _mainMatch = _mainNicks.find(n => _trimLower === n.toLowerCase() || _trimLower.startsWith(n.toLowerCase() + ' ') || _trimLower.startsWith(n.toLowerCase() + ',') || _trimLower.startsWith(n.toLowerCase() + ','));
437
+ if (_mainMatch) {
438
+ _clearSticky();
439
+ const rest = trimmedText.slice(_mainMatch.length).replace(/^[\s,,::]+/, '');
440
+ log('INFO', `Main nickname → cleared sticky, routing to main${rest ? ` (task: ${rest.slice(0, 30)})` : ''}`);
441
+ if (!rest) {
442
+ bot.sendMarkdown(chatId, `${_boundProj.icon || '🤖'} **${_boundProj.name}** 在线`).catch(() => {});
443
+ continue;
444
+ }
445
+ try {
446
+ await handleCommand(bot, chatId, rest, liveCfg, executeTaskByName, acl.senderId, acl.readOnly);
447
+ } catch (e) {
448
+ log('ERROR', `Team main-route handleCommand failed: ${e.message}`);
449
+ bot.sendMessage(chatId, `❌ 执行失败: ${e.message}`).catch(() => {});
450
+ }
451
+ continue;
452
+ }
453
+
454
+ // 2. Sticky: no nickname given → route to last explicitly named member
455
+ if (_stickyKey) {
456
+ const member = _boundProj.team.find(m => m.key === _stickyKey);
457
+ if (member) {
458
+ log('INFO', `Sticky route: → ${_stickyKey}`);
459
+ _dispatchToTeamMember(member, _boundProj, trimmedText, liveCfg, bot, chatId, executeTaskByName, acl);
460
+ continue;
461
+ }
462
+ }
463
+ }
464
+
465
+ // Default: route to main project
221
466
  handleCommand(bot, chatId, text, liveCfg, executeTaskByName, acl.senderId, acl.readOnly).catch(e => {
222
467
  log('ERROR', `Telegram handler error: ${e.message}`);
223
468
  });
@@ -255,9 +500,11 @@ function createBridgeStarter(deps) {
255
500
 
256
501
  const { createBot } = require('./feishu-adapter.js');
257
502
  const bot = createBot(config.feishu);
503
+
258
504
  try {
259
505
  const receiver = await bot.startReceiving(async (chatId, text, event, fileInfo, senderId) => {
260
506
  const liveCfg = loadConfig();
507
+
261
508
  const allowedIds = (liveCfg.feishu && liveCfg.feishu.allowed_chat_ids) || [];
262
509
  const trimmedText = text && text.trim();
263
510
  const isBindCmd = trimmedText && (
@@ -319,21 +566,153 @@ function createBridgeStarter(deps) {
319
566
  if (acl.blocked) return;
320
567
  log('INFO', `Feishu message from ${chatId}: ${text.slice(0, 50)}`);
321
568
  const parentId = event?.message?.parent_id;
569
+ let _replyAgentKey = null;
570
+ // Load state once for the entire routing block
571
+ const _st = loadState();
322
572
  if (parentId) {
323
- const st = loadState();
324
- const mapped = st.msg_sessions && st.msg_sessions[parentId];
573
+ const mapped = _st.msg_sessions && _st.msg_sessions[parentId];
325
574
  if (mapped) {
326
- st.sessions[chatId] = { id: mapped.id, cwd: mapped.cwd, started: true };
327
- saveState(st);
575
+ if (!_st.sessions) _st.sessions = {};
576
+ _st.sessions[chatId] = { id: mapped.id, cwd: mapped.cwd, started: true };
577
+ saveState(_st);
328
578
  log('INFO', `Session restored via reply: ${mapped.id.slice(0, 8)} (${path.basename(mapped.cwd)})`);
579
+ _replyAgentKey = mapped.agentKey || null;
580
+ }
581
+ }
582
+
583
+ // Helper: set/clear sticky on shared state object and persist
584
+ const _chatKey = String(chatId);
585
+ const _setSticky = (key) => {
586
+ if (!_st.team_sticky) _st.team_sticky = {};
587
+ _st.team_sticky[_chatKey] = key;
588
+ saveState(_st);
589
+ };
590
+ const _clearSticky = () => {
591
+ if (_st.team_sticky) delete _st.team_sticky[_chatKey];
592
+ saveState(_st);
593
+ };
594
+ const _stickyKey = (_st.team_sticky || {})[_chatKey] || null;
595
+
596
+ // Team group routing: if bound project has a team array, check message for member nickname
597
+ // Non-/stop slash commands bypass team routing → handled by main project
598
+ const { key: _boundKey, project: _boundProj } = _getBoundProject(chatId, liveCfg);
599
+ const _isTeamSlashCmd = trimmedText.startsWith('/') && !/^\/stop(\s|$)/i.test(trimmedText);
600
+ if (_boundProj && Array.isArray(_boundProj.team) && _boundProj.team.length > 0 && !_isTeamSlashCmd) {
601
+ // ── /stop precise routing for team groups ──
602
+ const _stopMatch = trimmedText && trimmedText.match(/^\/stop(?:\s+(.+))?$/i);
603
+ if (_stopMatch) {
604
+ const _stopArg = (_stopMatch[1] || '').trim();
605
+ let _targetKey = null;
606
+ // Priority 1: quoted reply → stop that agent
607
+ if (_replyAgentKey) {
608
+ const m = _boundProj.team.find(t => t.key === _replyAgentKey);
609
+ if (m) _targetKey = m.key;
610
+ }
611
+ // Priority 2: /stop <nickname> → match team member (case-insensitive)
612
+ if (!_targetKey && _stopArg) {
613
+ const _sa = _stopArg.toLowerCase();
614
+ const m = _boundProj.team.find(t =>
615
+ (t.nicknames || []).some(n => n.toLowerCase() === _sa) || (t.name && t.name.toLowerCase() === _sa) || t.key === _sa
616
+ );
617
+ if (m) _targetKey = m.key;
618
+ }
619
+ // Priority 3: bare /stop → sticky
620
+ if (!_targetKey && !_stopArg) _targetKey = _stickyKey;
621
+ if (_targetKey) {
622
+ const vid = `_agent_${_targetKey}`;
623
+ const member = _boundProj.team.find(t => t.key === _targetKey);
624
+ const label = member ? `${member.icon || '🤖'} ${member.name}` : _targetKey;
625
+ // Clear message queue for this virtual agent
626
+ if (messageQueue.has(vid)) {
627
+ const vq = messageQueue.get(vid);
628
+ if (vq && vq.timer) clearTimeout(vq.timer);
629
+ messageQueue.delete(vid);
630
+ }
631
+ const vproc = activeProcesses && activeProcesses.get(vid);
632
+ if (vproc && vproc.child) {
633
+ vproc.aborted = true;
634
+ const sig = vproc.killSignal || 'SIGTERM';
635
+ try { process.kill(-vproc.child.pid, sig); } catch { try { vproc.child.kill(sig); } catch { /* */ } }
636
+ await bot.sendMessage(chatId, `⏹ Stopping ${label}...`);
637
+ } else {
638
+ await bot.sendMessage(chatId, `${label} 当前没有活跃任务`);
639
+ }
640
+ return;
641
+ }
642
+ // /stop <bad-nickname> → no match, report error instead of falling through
643
+ if (_stopArg) {
644
+ await bot.sendMessage(chatId, `❌ 未找到团队成员: ${_stopArg}`);
645
+ return;
646
+ }
647
+ // Bare /stop, no sticky set → fall through to handleCommand
329
648
  }
649
+
650
+ // 0. Quoted reply → force route + set sticky
651
+ if (_replyAgentKey) {
652
+ const member = _boundProj.team.find(m => m.key === _replyAgentKey);
653
+ if (member) {
654
+ _setSticky(member.key);
655
+ log('INFO', `Quoted reply → force route to ${_replyAgentKey} (sticky set)`);
656
+ _dispatchToTeamMember(member, _boundProj, trimmedText, liveCfg, bot, chatId, executeTaskByName, acl);
657
+ return;
658
+ }
659
+ log('INFO', `Quoted reply agentKey=${_replyAgentKey} not in team, falling through`);
660
+ }
661
+ // 1. Explicit nickname → route + set sticky
662
+ const teamMatch = _findTeamMember(trimmedText, _boundProj.team);
663
+ if (teamMatch) {
664
+ const { member, rest } = teamMatch;
665
+ _setSticky(member.key);
666
+ if (!rest) {
667
+ // Pure nickname, no task — confirm member is online
668
+ log('INFO', `Sticky set (pure nickname): ${_chatKey.slice(-8)} → ${member.key}`);
669
+ bot.sendMarkdown(chatId, `${member.icon || '🤖'} **${member.name}** 在线`).catch(() => {});
670
+ return;
671
+ }
672
+ log('INFO', `Sticky set: ${_chatKey.slice(-8)} → ${member.key}`);
673
+ _dispatchToTeamMember(member, _boundProj, rest, liveCfg, bot, chatId, executeTaskByName, acl);
674
+ return;
675
+ }
676
+
677
+ // 1.5. Main project nickname → clear sticky, route to main
678
+ const _mainNicks = Array.isArray(_boundProj.nicknames) ? _boundProj.nicknames : [];
679
+ const _trimLower = trimmedText.toLowerCase();
680
+ const _mainMatch = _mainNicks.find(n => _trimLower === n.toLowerCase() || _trimLower.startsWith(n.toLowerCase() + ' ') || _trimLower.startsWith(n.toLowerCase() + ',') || _trimLower.startsWith(n.toLowerCase() + ','));
681
+ if (_mainMatch) {
682
+ _clearSticky();
683
+ const rest = trimmedText.slice(_mainMatch.length).replace(/^[\s,,::]+/, '');
684
+ log('INFO', `Main nickname → cleared sticky, routing to main${rest ? ` (task: ${rest.slice(0, 30)})` : ''}`);
685
+ if (!rest) {
686
+ bot.sendMarkdown(chatId, `${_boundProj.icon || '🤖'} **${_boundProj.name}** 在线`).catch(() => {});
687
+ return;
688
+ }
689
+ try {
690
+ await handleCommand(bot, chatId, rest, liveCfg, executeTaskByName, acl.senderId, acl.readOnly);
691
+ } catch (e) {
692
+ log('ERROR', `Team main-route handleCommand failed: ${e.message}`);
693
+ bot.sendMessage(chatId, `❌ 执行失败: ${e.message}`).catch(() => {});
694
+ }
695
+ return;
696
+ }
697
+
698
+ // 2. Sticky: no nickname given → route to last explicitly named member
699
+ if (_stickyKey) {
700
+ const member = _boundProj.team.find(m => m.key === _stickyKey);
701
+ if (member) {
702
+ log('INFO', `Sticky route: → ${_stickyKey}`);
703
+ _dispatchToTeamMember(member, _boundProj, trimmedText, liveCfg, bot, chatId, executeTaskByName, acl);
704
+ return;
705
+ }
706
+ }
707
+
330
708
  }
709
+
331
710
  await handleCommand(bot, chatId, text, liveCfg, executeTaskByName, acl.senderId, acl.readOnly);
332
711
  }
333
- });
712
+ }, { log: (lvl, msg) => log(lvl, msg) });
334
713
 
335
714
  log('INFO', 'Feishu bot connected (WebSocket long connection)');
336
- return { stop: () => receiver.stop(), bot };
715
+ return { stop: () => receiver.stop(), bot, reconnect: () => receiver.reconnect(), isAlive: () => receiver.isAlive() };
337
716
  } catch (e) {
338
717
  log('ERROR', `Feishu bridge failed: ${e.message}`);
339
718
  return null;