metame-cli 1.5.22 → 1.5.24

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.
package/index.js CHANGED
@@ -540,11 +540,28 @@ if (syntaxErrors.length > 0) {
540
540
  const changed = syncDirFiles(group.srcDir, destDir, { fileList: group.fileList });
541
541
  return updated || changed;
542
542
  }, false);
543
- if (scriptsUpdated) {
543
+ if (scriptsUpdated) {
544
544
  console.log(`${icon("pkg")} Scripts synced to ~/.metame/.`);
545
545
  }
546
546
  }
547
547
 
548
+ try {
549
+ const runtimeEnvFile = path.join(METAME_DIR, 'runtime-env.json');
550
+ const runtimeNodeModules = path.join(__dirname, 'node_modules');
551
+ const runtimeEnvPayload = {
552
+ metameRoot: __dirname,
553
+ nodeModules: runtimeNodeModules,
554
+ generatedAt: new Date().toISOString(),
555
+ };
556
+ const nextContent = JSON.stringify(runtimeEnvPayload, null, 2) + '\n';
557
+ const prevContent = fs.existsSync(runtimeEnvFile) ? fs.readFileSync(runtimeEnvFile, 'utf8') : '';
558
+ if (prevContent !== nextContent) {
559
+ fs.writeFileSync(runtimeEnvFile, nextContent, 'utf8');
560
+ }
561
+ } catch (e) {
562
+ console.log(`${icon("warn")} Runtime env sync skipped: ${e.message}`);
563
+ }
564
+
548
565
  // Docs: lazy-load references for CLAUDE.md pointer instructions
549
566
  syncDirFiles(path.join(__dirname, 'scripts', 'docs'), path.join(METAME_DIR, 'docs'));
550
567
  // Bin: CLI tools (dispatch_to etc.)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metame-cli",
3
- "version": "1.5.22",
3
+ "version": "1.5.24",
4
4
  "description": "The Cognitive Profile Layer for Claude Code. Knows how you think, not just what you said.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -9,8 +9,9 @@
9
9
  "files": [
10
10
  "index.js",
11
11
  "scripts/",
12
- "!scripts/*.test.js",
12
+ "!scripts/**/*.test.js",
13
13
  "!scripts/test_daemon.js",
14
+ "!scripts/sync-readme.js",
14
15
  "!scripts/hooks/test-*.js",
15
16
  "!scripts/daemon.yaml",
16
17
  "!scripts/daemon.yaml.bak",
@@ -0,0 +1,164 @@
1
+ 'use strict';
2
+
3
+ const { rawChatId: _rawChatId } = require('./thread-chat-id');
4
+
5
+ function buildBoundSessionChatId(projectKey) {
6
+ const key = String(projectKey || '').trim();
7
+ return key ? `_bound_${key}` : '';
8
+ }
9
+
10
+ function isAgentLogicalRouteForMember(logicalChatId, memberKey) {
11
+ const route = String(logicalChatId || '');
12
+ const key = String(memberKey || '').trim();
13
+ if (!route || !key) return false;
14
+ const base = `_agent_${key}`;
15
+ return route === base || route.startsWith(`${base}::`);
16
+ }
17
+
18
+ function resolveBoundTeamContext({ chatId, cfg, state, normalizeCwd }) {
19
+ const chatKey = String(chatId || '');
20
+ const rawChatKey = _rawChatId(chatKey);
21
+ const agentMap = {
22
+ ...(cfg && cfg.telegram ? cfg.telegram.chat_agent_map : {}),
23
+ ...(cfg && cfg.feishu ? cfg.feishu.chat_agent_map : {}),
24
+ ...(cfg && cfg.imessage ? cfg.imessage.chat_agent_map : {}),
25
+ ...(cfg && cfg.siri_bridge ? cfg.siri_bridge.chat_agent_map : {}),
26
+ };
27
+ const boundKey = agentMap[chatKey] || agentMap[rawChatKey] || null;
28
+ const boundProj = boundKey && cfg && cfg.projects ? cfg.projects[boundKey] : null;
29
+ const stickyKey = state && state.team_sticky
30
+ ? (state.team_sticky[chatKey] || state.team_sticky[rawChatKey] || null)
31
+ : null;
32
+ const stickyMember = stickyKey && boundProj && Array.isArray(boundProj.team)
33
+ ? boundProj.team.find((member) => member && member.key === stickyKey) || null
34
+ : null;
35
+ const stickyLogicalChatId = state && state.team_session_route
36
+ ? (state.team_session_route[chatKey] || state.team_session_route[rawChatKey] || null)
37
+ : null;
38
+ const normalizedStickyCwd = stickyMember && stickyMember.cwd ? normalizeCwd(stickyMember.cwd) : null;
39
+ const normalizedBoundCwd = boundProj && boundProj.cwd ? normalizeCwd(boundProj.cwd) : null;
40
+
41
+ return {
42
+ chatKey,
43
+ rawChatKey,
44
+ boundKey,
45
+ boundProj,
46
+ stickyKey,
47
+ stickyMember,
48
+ stickyLogicalChatId,
49
+ normalizedStickyCwd,
50
+ normalizedBoundCwd,
51
+ };
52
+ }
53
+
54
+ function resolveSessionRoute({
55
+ chatId,
56
+ cfg,
57
+ state,
58
+ getSession,
59
+ normalizeCwd,
60
+ normalizeEngineName,
61
+ inferStoredEngine,
62
+ }) {
63
+ const ctx = resolveBoundTeamContext({ chatId, cfg, state, normalizeCwd });
64
+
65
+ if (ctx.stickyMember) {
66
+ return {
67
+ sessionChatId: isAgentLogicalRouteForMember(ctx.stickyLogicalChatId, ctx.stickyMember.key)
68
+ ? ctx.stickyLogicalChatId
69
+ : `_agent_${ctx.stickyMember.key}`,
70
+ cwd: ctx.normalizedStickyCwd || ctx.normalizedBoundCwd,
71
+ engine: normalizeEngineName(ctx.stickyMember.engine || (ctx.boundProj && ctx.boundProj.engine)),
72
+ context: ctx,
73
+ };
74
+ }
75
+
76
+ if (ctx.boundProj) {
77
+ return {
78
+ sessionChatId: buildBoundSessionChatId(ctx.boundKey),
79
+ cwd: ctx.normalizedBoundCwd,
80
+ engine: normalizeEngineName(ctx.boundProj.engine),
81
+ context: ctx,
82
+ };
83
+ }
84
+
85
+ const rawSession = getSession(chatId);
86
+ return {
87
+ sessionChatId: String(chatId || ''),
88
+ cwd: rawSession && rawSession.cwd ? normalizeCwd(rawSession.cwd) : null,
89
+ engine: inferStoredEngine(rawSession),
90
+ context: ctx,
91
+ };
92
+ }
93
+
94
+ function resolveResumeRouteForTarget({
95
+ chatId,
96
+ targetCwd,
97
+ cfg,
98
+ state,
99
+ normalizeCwd,
100
+ fallbackSessionChatId,
101
+ }) {
102
+ const ctx = resolveBoundTeamContext({ chatId, cfg, state, normalizeCwd });
103
+ const normalizedTargetCwd = targetCwd ? normalizeCwd(targetCwd) : null;
104
+ if (!ctx.boundProj || !Array.isArray(ctx.boundProj.team) || !normalizedTargetCwd) {
105
+ return { sessionChatId: fallbackSessionChatId, stickyKey: null, clearSticky: false };
106
+ }
107
+
108
+ const matchedMember = ctx.boundProj.team.find((member) => {
109
+ if (!member || !member.cwd) return false;
110
+ return normalizeCwd(member.cwd) === normalizedTargetCwd;
111
+ });
112
+ if (matchedMember) {
113
+ return {
114
+ sessionChatId: `_agent_${matchedMember.key}`,
115
+ stickyKey: matchedMember.key,
116
+ clearSticky: false,
117
+ };
118
+ }
119
+
120
+ if (ctx.normalizedBoundCwd && ctx.normalizedBoundCwd === normalizedTargetCwd) {
121
+ return {
122
+ sessionChatId: buildBoundSessionChatId(ctx.boundKey),
123
+ stickyKey: ctx.stickyKey || null,
124
+ clearSticky: !!ctx.stickyKey,
125
+ };
126
+ }
127
+
128
+ return {
129
+ sessionChatId: buildBoundSessionChatId(ctx.boundKey),
130
+ stickyKey: null,
131
+ clearSticky: true,
132
+ };
133
+ }
134
+
135
+ function applyResumeRouteState(state, chatId, resumeRoute) {
136
+ const chatKey = String(chatId || '');
137
+ const rawChatKey = _rawChatId(chatKey);
138
+ if (resumeRoute.clearSticky && state.team_sticky) {
139
+ delete state.team_sticky[chatKey];
140
+ delete state.team_sticky[rawChatKey];
141
+ }
142
+ if (resumeRoute.clearSticky && state.team_session_route) {
143
+ delete state.team_session_route[chatKey];
144
+ delete state.team_session_route[rawChatKey];
145
+ }
146
+ if (!resumeRoute.stickyKey) return;
147
+ if (!state.team_sticky) state.team_sticky = {};
148
+ state.team_sticky[chatKey] = resumeRoute.stickyKey;
149
+ state.team_sticky[rawChatKey] = resumeRoute.stickyKey;
150
+ if (resumeRoute.sessionChatId && resumeRoute.sessionChatId.startsWith('_agent_')) {
151
+ if (!state.team_session_route) state.team_session_route = {};
152
+ state.team_session_route[chatKey] = resumeRoute.sessionChatId;
153
+ state.team_session_route[rawChatKey] = resumeRoute.sessionChatId;
154
+ }
155
+ }
156
+
157
+ module.exports = {
158
+ buildBoundSessionChatId,
159
+ isAgentLogicalRouteForMember,
160
+ resolveBoundTeamContext,
161
+ resolveSessionRoute,
162
+ resolveResumeRouteForTarget,
163
+ applyResumeRouteState,
164
+ };
@@ -18,6 +18,11 @@ const {
18
18
  handleSoulCommand,
19
19
  } = require('./daemon-agent-lifecycle');
20
20
  const { parseTeamMembers, createTeamWorkspace } = require('./daemon-team-workflow');
21
+ const {
22
+ resolveSessionRoute: _resolveSessionRoute,
23
+ resolveResumeRouteForTarget: _resolveResumeRouteForTarget,
24
+ applyResumeRouteState: _applyResumeRouteState,
25
+ } = require('./core/team-session-route');
21
26
 
22
27
  function createAgentCommandHandler(deps) {
23
28
  const {
@@ -39,6 +44,7 @@ function createAgentCommandHandler(deps) {
39
44
  loadSessionTags,
40
45
  sessionRichLabel,
41
46
  getSessionRecentContext,
47
+ getSessionRecentDialogue,
42
48
  pendingBinds,
43
49
  pendingAgentFlows,
44
50
  pendingTeamFlows,
@@ -71,45 +77,16 @@ function createAgentCommandHandler(deps) {
71
77
  return available.length === 1 ? normalizeEngineName(available[0]) : getDefaultEngine();
72
78
  }
73
79
 
74
- function buildBoundSessionChatId(projectKey) {
75
- const key = String(projectKey || '').trim();
76
- return key ? `_bound_${key}` : '';
77
- }
78
-
79
80
  function getSessionRoute(chatId) {
80
- const cfg = loadConfig();
81
- const state = loadState();
82
- const chatKey = String(chatId);
83
- const agentMap = { ...(cfg.telegram ? cfg.telegram.chat_agent_map : {}), ...(cfg.feishu ? cfg.feishu.chat_agent_map : {}) };
84
- const boundKey = agentMap[chatKey] || null;
85
- const boundProj = boundKey && cfg.projects ? cfg.projects[boundKey] : null;
86
- const stickyKey = state && state.team_sticky ? state.team_sticky[chatKey] : null;
87
- const stickyMember = stickyKey && boundProj && Array.isArray(boundProj.team)
88
- ? boundProj.team.find((m) => m && m.key === stickyKey)
89
- : null;
90
-
91
- if (stickyMember) {
92
- return {
93
- sessionChatId: `_agent_${stickyMember.key}`,
94
- cwd: stickyMember.cwd ? normalizeCwd(stickyMember.cwd) : (boundProj && boundProj.cwd ? normalizeCwd(boundProj.cwd) : null),
95
- engine: normalizeEngineName(stickyMember.engine || (boundProj && boundProj.engine)),
96
- };
97
- }
98
-
99
- if (boundProj) {
100
- return {
101
- sessionChatId: buildBoundSessionChatId(boundKey),
102
- cwd: boundProj.cwd ? normalizeCwd(boundProj.cwd) : null,
103
- engine: normalizeEngineName(boundProj.engine),
104
- };
105
- }
106
-
107
- const rawSession = getSession(chatId);
108
- return {
109
- sessionChatId: String(chatId),
110
- cwd: rawSession && rawSession.cwd ? normalizeCwd(rawSession.cwd) : null,
111
- engine: inferStoredEngine(rawSession),
112
- };
81
+ return _resolveSessionRoute({
82
+ chatId,
83
+ cfg: loadConfig(),
84
+ state: loadState(),
85
+ getSession,
86
+ normalizeCwd,
87
+ normalizeEngineName,
88
+ inferStoredEngine,
89
+ });
113
90
  }
114
91
 
115
92
  function getCurrentEngine(chatId) {
@@ -169,6 +146,17 @@ function createAgentCommandHandler(deps) {
169
146
  return null;
170
147
  }
171
148
 
149
+ function resolveResumeRouteForSelection(chatId, route, targetCwd, cfg, state) {
150
+ return _resolveResumeRouteForTarget({
151
+ chatId,
152
+ targetCwd,
153
+ cfg,
154
+ state,
155
+ normalizeCwd,
156
+ fallbackSessionChatId: route.sessionChatId,
157
+ });
158
+ }
159
+
172
160
  async function autoCreateSessionOnEmptyResume(bot, chatId, cwd, engine) {
173
161
  const resolvedCwd = cwd ? normalizeCwd(cwd) : null;
174
162
  if (!resolvedCwd || !fs.existsSync(resolvedCwd) || typeof attachOrCreateSession !== 'function') {
@@ -388,10 +376,13 @@ function createAgentCommandHandler(deps) {
388
376
  }
389
377
  const sessionId = fullMatch.sessionId;
390
378
  const cwd = fullMatch.projectPath || (curSession && curSession.cwd) || HOME;
379
+ const targetSessionId = sessionId;
380
+ const targetCwd = cwd;
391
381
 
392
382
  const state2 = loadState();
393
383
  const cfgForEngine = loadConfig();
394
- const sessionKey = route.sessionChatId;
384
+ const resumeRoute = resolveResumeRouteForSelection(chatId, route, targetCwd, cfgForEngine, state2);
385
+ const sessionKey = resumeRoute.sessionChatId;
395
386
  const existing = state2.sessions[sessionKey] || {};
396
387
  const existingEngine = normalizeEngineName(
397
388
  existing.engine
@@ -404,8 +395,6 @@ function createAgentCommandHandler(deps) {
404
395
  && currentLogical
405
396
  && currentLogical.id
406
397
  && sessionId === currentLogical.id;
407
- const targetSessionId = sessionId;
408
- const targetCwd = cwd;
409
398
  const existingEngines = existing.engines || {};
410
399
  state2.sessions[sessionKey] = {
411
400
  ...existing,
@@ -415,17 +404,26 @@ function createAgentCommandHandler(deps) {
415
404
  engine: engineByTargetCwd,
416
405
  engines: { ...existingEngines, [engineByTargetCwd]: { id: targetSessionId, started: true } },
417
406
  };
407
+ _applyResumeRouteState(state2, chatId, resumeRoute);
418
408
  saveState(state2);
419
409
  const name = fullMatch.customTitle;
420
410
  const label = name || (fullMatch.summary || fullMatch.firstPrompt || '').slice(0, 40) || targetSessionId.slice(0, 8);
421
411
 
422
412
  // 读取最近对话片段,帮助确认是否切换到正确的 session
423
413
  const recentCtx = getSessionRecentContext ? getSessionRecentContext(targetSessionId) : null;
424
- let msg = `✅ 已切换: **${label}**\n📁 ${path.basename(cwd)}`;
414
+ const recentDialogue = getSessionRecentDialogue ? getSessionRecentDialogue(targetSessionId, 4) : null;
415
+ let msg = `✅ 已切换: **${label}**\n📁 ${path.basename(cwd)}\n🆔 \`${targetSessionId}\``;
425
416
  if (selectedLogicalCurrent) {
426
417
  msg += '\n\n已恢复当前智能体会话。';
427
418
  }
428
- if (recentCtx) {
419
+ if (Array.isArray(recentDialogue) && recentDialogue.length > 0) {
420
+ msg += '\n\n最近对话:';
421
+ for (const item of recentDialogue) {
422
+ const marker = item.role === 'assistant' ? '🤖' : '👤';
423
+ const snippet = String(item.text || '').replace(/\n/g, ' ').slice(0, 120);
424
+ if (snippet) msg += `\n${marker} ${snippet}`;
425
+ }
426
+ } else if (recentCtx) {
429
427
  if (recentCtx.lastUser) {
430
428
  const snippet = recentCtx.lastUser.replace(/\n/g, ' ').slice(0, 80);
431
429
  msg += `\n\n💬 上次你说: _${snippet}${recentCtx.lastUser.length > 80 ? '…' : ''}_`;
@@ -5,6 +5,7 @@ try { userAcl = require('./daemon-user-acl'); } catch { /* optional */ }
5
5
  const { findTeamMember: _findTeamMember } = require('./daemon-team-dispatch');
6
6
  const { isRemoteMember } = require('./daemon-remote-dispatch');
7
7
  const { buildThreadChatId, isThreadChatId, rawChatId: _threadRawChatId } = require('./core/thread-chat-id');
8
+ const { isAgentLogicalRouteForMember } = require('./core/team-session-route');
8
9
  const imessageIO = (() => { try { return require('./daemon-siri-imessage'); } catch { return null; } })();
9
10
  const siriBridgeMod = (() => { try { return require('./daemon-siri-bridge'); } catch { return null; } })();
10
11
  const weixinBridgeMod = (() => { try { return require('./daemon-weixin-bridge'); } catch { return null; } })();
@@ -185,6 +186,19 @@ function createBridgeStarter(deps) {
185
186
  };
186
187
  }
187
188
 
189
+ function resolveReplyStopChatId(targetKey, fallbackChatId, replyMapping) {
190
+ const resolvedFallback = String(fallbackChatId || '').trim();
191
+ const mapping = replyMapping && typeof replyMapping === 'object' ? replyMapping : null;
192
+ const logicalChatId = String(mapping && mapping.logicalChatId || '').trim();
193
+ if (!logicalChatId) return resolvedFallback;
194
+ if (!targetKey) return logicalChatId;
195
+ const expectedPrefix = `_agent_${String(targetKey).trim()}`;
196
+ if (logicalChatId === expectedPrefix || logicalChatId.startsWith(`${expectedPrefix}::`)) {
197
+ return logicalChatId;
198
+ }
199
+ return resolvedFallback;
200
+ }
201
+
188
202
  // ── Team group helpers ─────────────────────────────────────────────────
189
203
  function _getBoundProject(chatId, cfg) {
190
204
  const map = {
@@ -313,9 +327,16 @@ function createBridgeStarter(deps) {
313
327
  // When dispatching from a topic thread, include the thread ID in the
314
328
  // virtual session key so each topic gets its own independent session.
315
329
  const realChatIdStr = String(realChatId || '');
316
- const virtualChatId = isThreadChatId(realChatIdStr)
317
- ? `_agent_${member.key}::${realChatIdStr}`
318
- : `_agent_${member.key}`;
330
+ const state = loadState() || {};
331
+ const routeMap = state.team_session_route || {};
332
+ const rawChatKey = _threadRawChatId(realChatIdStr);
333
+ const preferredLogicalChatId = routeMap[realChatIdStr] || routeMap[rawChatKey] || '';
334
+ const expectedBaseChatId = `_agent_${member.key}`;
335
+ const virtualChatId = isAgentLogicalRouteForMember(preferredLogicalChatId, member.key)
336
+ ? preferredLogicalChatId
337
+ : (isThreadChatId(realChatIdStr)
338
+ ? `${expectedBaseChatId}::${realChatIdStr}`
339
+ : expectedBaseChatId);
319
340
  const parentCwd = member.cwd || boundProj.cwd;
320
341
  const resolvedParentCwd = parentCwd.replace(/^~/, require('os').homedir());
321
342
  const memberCwd = _getMemberCwd(
@@ -559,9 +580,9 @@ function createBridgeStarter(deps) {
559
580
  );
560
581
  if (m) _targetKey = m.key;
561
582
  }
562
- if (!_targetKey && !_stopArg) _targetKey = _stickyKey;
563
- if (_targetKey) {
564
- const vid = `_agent_${_targetKey}`;
583
+ if (!_targetKey && !_stopArg) _targetKey = _stickyKey;
584
+ if (_targetKey) {
585
+ const vid = resolveReplyStopChatId(_targetKey, `_agent_${_targetKey}`, parentId ? (_st.msg_sessions && _st.msg_sessions[parentId]) : null);
565
586
  const member = _boundProj.team.find(t => t.key === _targetKey);
566
587
  const label = member ? `${member.icon || '🤖'} ${member.name}` : _targetKey;
567
588
  pipeline.clearQueue(vid);
@@ -783,8 +804,9 @@ function createBridgeStarter(deps) {
783
804
  // Respect team_sticky: route to active agent same as text messages
784
805
  const _stFile = loadState();
785
806
  const _chatKeyFile = String(pipelineChatId);
807
+ const _rawChatKeyFile = _threadRawChatId(_chatKeyFile);
786
808
  const { project: _boundProjFile } = _getBoundProject(chatId, liveCfg);
787
- const _stickyKeyFile = (_stFile.team_sticky || {})[_chatKeyFile];
809
+ const _stickyKeyFile = (_stFile.team_sticky || {})[_chatKeyFile] || (_stFile.team_sticky || {})[_rawChatKeyFile];
788
810
  if (_boundProjFile && Array.isArray(_boundProjFile.team) && _boundProjFile.team.length > 0 && _stickyKeyFile) {
789
811
  const _stickyMember = _boundProjFile.team.find(m => m.key === _stickyKeyFile);
790
812
  if (_stickyMember) {
@@ -814,6 +836,7 @@ function createBridgeStarter(deps) {
814
836
  log('INFO', `Feishu message from ${chatId}: ${text.slice(0, 50)}`);
815
837
  const parentId = extractFeishuReplyMessageId(event);
816
838
  let _replyAgentKey = null;
839
+ let _replyMapping = null;
817
840
  let _replyMappingFound = false; // true = mapping exists (agentKey may be null = main)
818
841
  // Load state once for the entire routing block
819
842
  const _st = loadState();
@@ -826,6 +849,7 @@ function createBridgeStarter(deps) {
826
849
  if (_isQuotedReply) {
827
850
  const mapped = _parentMapping;
828
851
  if (mapped) {
852
+ _replyMapping = mapped;
829
853
  _replyMappingFound = true;
830
854
  if (typeof restoreSessionFromReply === 'function') {
831
855
  restoreSessionFromReply(chatId, mapped);
@@ -852,16 +876,29 @@ function createBridgeStarter(deps) {
852
876
  // Helper: set/clear sticky on shared state object and persist
853
877
  // Use pipelineChatId so each topic gets independent sticky state
854
878
  const _chatKey = String(pipelineChatId);
879
+ const _rawChatKey = _threadRawChatId(_chatKey);
855
880
  const _setSticky = (key) => {
856
881
  if (!_st.team_sticky) _st.team_sticky = {};
857
882
  _st.team_sticky[_chatKey] = key;
883
+ if (_rawChatKey && _rawChatKey !== _chatKey) _st.team_sticky[_rawChatKey] = key;
884
+ if (_st.team_session_route) {
885
+ if (_st.team_session_route[_chatKey] && !isAgentLogicalRouteForMember(_st.team_session_route[_chatKey], key)) {
886
+ delete _st.team_session_route[_chatKey];
887
+ }
888
+ if (_rawChatKey && _rawChatKey !== _chatKey && _st.team_session_route[_rawChatKey] && !isAgentLogicalRouteForMember(_st.team_session_route[_rawChatKey], key)) {
889
+ delete _st.team_session_route[_rawChatKey];
890
+ }
891
+ }
858
892
  saveState(_st);
859
893
  };
860
894
  const _clearSticky = () => {
861
895
  if (_st.team_sticky) delete _st.team_sticky[_chatKey];
896
+ if (_st.team_sticky && _rawChatKey && _rawChatKey !== _chatKey) delete _st.team_sticky[_rawChatKey];
897
+ if (_st.team_session_route) delete _st.team_session_route[_chatKey];
898
+ if (_st.team_session_route && _rawChatKey && _rawChatKey !== _chatKey) delete _st.team_session_route[_rawChatKey];
862
899
  saveState(_st);
863
900
  };
864
- let _stickyKey = (_st.team_sticky || {})[_chatKey] || null;
901
+ let _stickyKey = (_st.team_sticky || {})[_chatKey] || (_st.team_sticky || {})[_rawChatKey] || null;
865
902
 
866
903
  // Team group routing: if bound project has a team array, check message for member nickname
867
904
  // Non-/stop slash commands bypass team routing → handled by main project
@@ -898,9 +935,10 @@ function createBridgeStarter(deps) {
898
935
  // Priority 3: bare /stop → sticky
899
936
  if (!_targetKey && !_stopArg) _targetKey = _stickyKey;
900
937
  if (_targetKey) {
901
- const vid = isThreadChatId(String(pipelineChatId))
938
+ const fallbackVid = isThreadChatId(String(pipelineChatId))
902
939
  ? `_agent_${_targetKey}::${pipelineChatId}`
903
940
  : `_agent_${_targetKey}`;
941
+ const vid = resolveReplyStopChatId(_targetKey, fallbackVid, _isQuotedReply ? _replyMapping : null);
904
942
  const member = _boundProj.team.find(t => t.key === _targetKey);
905
943
  const label = member ? `${member.icon || '🤖'} ${member.name}` : _targetKey;
906
944
  pipeline.clearQueue(vid);
@@ -1008,6 +1046,9 @@ function createBridgeStarter(deps) {
1008
1046
  if (_stickyKey) {
1009
1047
  const member = _boundProj.team.find(m => m.key === _stickyKey);
1010
1048
  if (member) {
1049
+ if ((_st.team_sticky || {})[_chatKey] !== _stickyKey) {
1050
+ _setSticky(_stickyKey);
1051
+ }
1011
1052
  log('INFO', `Sticky route: → ${_stickyKey}`);
1012
1053
  _dispatchToTeamMember(member, _boundProj, trimmedText, liveCfg, bot, pipelineChatId, executeTaskByName, acl);
1013
1054
  return;
@@ -1238,7 +1238,19 @@ function createClaudeEngine(deps) {
1238
1238
  ? () => bot.sendCard(chatId, { title: _ackCardHeader.title, body: '🤔', color: _ackCardHeader.color })
1239
1239
  : () => (bot.sendMarkdown ? bot.sendMarkdown(chatId, '🤔') : bot.sendMessage(chatId, '🤔'));
1240
1240
  _ackFn()
1241
- .then(msg => { if (msg && msg.message_id) statusMsgId = msg.message_id; })
1241
+ .then(msg => {
1242
+ if (!(msg && msg.message_id)) return;
1243
+ statusMsgId = msg.message_id;
1244
+ const trackedAgentKey = projectKeyFromVirtualChatId(chatId);
1245
+ const routeSession = {
1246
+ cwd: (_ackBoundProj && _ackBoundProj.cwd) || HOME,
1247
+ engine: (_ackBoundProj && _ackBoundProj.engine)
1248
+ ? normalizeEngineName(_ackBoundProj.engine)
1249
+ : getDefaultEngine(),
1250
+ logicalChatId: chatId,
1251
+ };
1252
+ trackMsgSession(msg.message_id, routeSession, trackedAgentKey, { routeOnly: true });
1253
+ })
1242
1254
  .catch(e => log('ERROR', `Failed to send ack to ${chatId}: ${e.message}`));
1243
1255
  }
1244
1256
  bot.sendTyping(chatId).catch(() => { });
@@ -163,6 +163,7 @@ function createCommandRouter(deps) {
163
163
 
164
164
  function resolveCurrentSessionContext(chatId, config) {
165
165
  const chatIdStr = String(chatId || '');
166
+ const threadScoped = isThreadChatId(chatIdStr);
166
167
  const chatAgentMap = {
167
168
  ...(config && config.telegram ? config.telegram.chat_agent_map : {}),
168
169
  ...(config && config.feishu ? config.feishu.chat_agent_map : {}),
@@ -172,10 +173,19 @@ function createCommandRouter(deps) {
172
173
  const _rawChatId = extractOriginalChatId(chatIdStr);
173
174
  const mappedKey = chatAgentMap[chatIdStr] || chatAgentMap[_rawChatId] || projectKeyFromVirtualChatId(chatIdStr);
174
175
  const mappedProject = mappedKey && config && config.projects ? config.projects[mappedKey] : null;
175
- const preferredEngine = String((mappedProject && mappedProject.engine) || getDefaultEngine()).toLowerCase();
176
176
  const state = loadState() || {};
177
177
  const sessions = state.sessions || {};
178
+ const stickyKey = state.team_sticky ? (state.team_sticky[chatIdStr] || state.team_sticky[_rawChatId]) : null;
179
+ const stickyMember = threadScoped && stickyKey && mappedProject && Array.isArray(mappedProject.team)
180
+ ? mappedProject.team.find((member) => member && member.key === stickyKey)
181
+ : null;
182
+ const preferredEngine = String(
183
+ (stickyMember && stickyMember.engine)
184
+ || (mappedProject && mappedProject.engine)
185
+ || getDefaultEngine()
186
+ ).toLowerCase();
178
187
  const candidateIds = [
188
+ stickyMember ? `_agent_${stickyMember.key}` : null,
179
189
  mappedKey ? buildSessionChatId(chatIdStr, mappedKey) : null,
180
190
  buildSessionChatId(chatIdStr),
181
191
  chatIdStr,
@@ -212,7 +222,8 @@ function createCommandRouter(deps) {
212
222
  ...(cfg.imessage ? cfg.imessage.chat_agent_map : {}),
213
223
  ...(cfg.siri_bridge ? cfg.siri_bridge.chat_agent_map : {}),
214
224
  };
215
- const key = map[String(chatId)];
225
+ const chatIdStr = String(chatId);
226
+ const key = map[chatIdStr] || map[extractOriginalChatId(chatIdStr)];
216
227
  const proj = key && cfg.projects ? cfg.projects[key] : null;
217
228
  return { key: key || null, project: proj || null };
218
229
  }
@@ -387,17 +398,23 @@ function createCommandRouter(deps) {
387
398
  };
388
399
  const _chatIdStr = String(chatId);
389
400
  const _rawChatId2 = extractOriginalChatId(_chatIdStr);
401
+ const _threadScoped = isThreadChatId(_chatIdStr);
390
402
  const mappedKey = chatAgentMap[_chatIdStr] ||
391
403
  chatAgentMap[_rawChatId2] ||
392
404
  projectKeyFromVirtualChatId(_chatIdStr);
393
405
  if (mappedKey && config.projects && config.projects[mappedKey]) {
394
406
  const proj = config.projects[mappedKey];
395
- const projCwd = normalizeCwd(proj.cwd);
396
- const sessionChatId = buildSessionChatId(chatId, mappedKey);
407
+ const stickyKey = state && state.team_sticky ? (state.team_sticky[_chatIdStr] || state.team_sticky[_rawChatId2]) : null;
408
+ const stickyMember = _threadScoped && stickyKey && Array.isArray(proj.team)
409
+ ? proj.team.find((member) => member && member.key === stickyKey)
410
+ : null;
411
+ const targetEngine = (stickyMember && stickyMember.engine) || proj.engine || getDefaultEngine();
412
+ const projCwd = normalizeCwd((stickyMember && stickyMember.cwd) || proj.cwd);
413
+ const sessionChatId = stickyMember ? `_agent_${stickyMember.key}` : buildSessionChatId(chatId, mappedKey);
397
414
  const sessions = loadState().sessions || {};
398
415
  const cur = sessions[sessionChatId];
399
416
  const rawSession = sessions[String(chatId)];
400
- const projEngine = String((proj && proj.engine) || getDefaultEngine()).toLowerCase();
417
+ const projEngine = String(targetEngine).toLowerCase();
401
418
  // Multi-engine format stores engines in cur.engines object; legacy format uses cur.engine string.
402
419
  // Check whether the session already has a slot for the project's configured engine.
403
420
  const curHasEngine = cur && (
@@ -408,6 +425,7 @@ function createCommandRouter(deps) {
408
425
  );
409
426
  const isVirtualSession = _chatIdStr.startsWith('_agent_') || _chatIdStr.startsWith('_scope_');
410
427
  const shouldReattachForCwdChange =
428
+ !stickyMember &&
411
429
  !isVirtualSession &&
412
430
  !!cur &&
413
431
  !!curHasEngine &&
@@ -416,7 +434,7 @@ function createCommandRouter(deps) {
416
434
  if (!cur || !curHasEngine || shouldReattachForCwdChange) {
417
435
  const initReason = !cur ? 'no-session' : (!curHasEngine ? 'engine-missing' : 'cwd-changed');
418
436
  log('INFO', `SESSION-INIT [${String(sessionChatId).slice(-32)}] ${initReason}`);
419
- attachOrCreateSession(sessionChatId, projCwd, proj.name || mappedKey, proj.engine || getDefaultEngine());
437
+ attachOrCreateSession(sessionChatId, projCwd, proj.name || mappedKey, targetEngine);
420
438
  }
421
439
  }
422
440
 
@@ -1,6 +1,10 @@
1
1
  'use strict';
2
2
 
3
3
  const { normalizeEngineName: _normalizeEngine } = require('./daemon-utils');
4
+ const {
5
+ buildBoundSessionChatId,
6
+ resolveSessionRoute: _resolveSessionRoute,
7
+ } = require('./core/team-session-route');
4
8
 
5
9
  function createCommandSessionResolver(deps) {
6
10
  const {
@@ -27,11 +31,6 @@ function createCommandSessionResolver(deps) {
27
31
  return available.length === 1 ? normalizeEngineName(available[0]) : getDefaultEngine();
28
32
  }
29
33
 
30
- function buildBoundSessionChatId(projectKey) {
31
- const key = String(projectKey || '').trim();
32
- return key ? `_bound_${key}` : '';
33
- }
34
-
35
34
  function normalizeRouteCwd(cwd) {
36
35
  if (!cwd) return null;
37
36
  try {
@@ -48,39 +47,15 @@ function createCommandSessionResolver(deps) {
48
47
  }
49
48
 
50
49
  function getSessionRoute(chatId) {
51
- const cfg = loadConfig();
52
- const state = loadState();
53
- const chatKey = String(chatId);
54
- const agentMap = { ...(cfg.telegram ? cfg.telegram.chat_agent_map : {}), ...(cfg.feishu ? cfg.feishu.chat_agent_map : {}) };
55
- const boundKey = agentMap[chatKey] || null;
56
- const boundProj = boundKey && cfg.projects ? cfg.projects[boundKey] : null;
57
- const stickyKey = state && state.team_sticky ? state.team_sticky[chatKey] : null;
58
- const stickyMember = stickyKey && boundProj && Array.isArray(boundProj.team)
59
- ? boundProj.team.find((m) => m && m.key === stickyKey)
60
- : null;
61
-
62
- if (stickyMember) {
63
- return {
64
- sessionChatId: `_agent_${stickyMember.key}`,
65
- cwd: normalizeRouteCwd(stickyMember.cwd || (boundProj && boundProj.cwd) || null),
66
- engine: normalizeEngineName(stickyMember.engine || (boundProj && boundProj.engine)),
67
- };
68
- }
69
-
70
- if (boundProj) {
71
- return {
72
- sessionChatId: buildBoundSessionChatId(boundKey),
73
- cwd: normalizeRouteCwd(boundProj.cwd || null),
74
- engine: normalizeEngineName(boundProj.engine),
75
- };
76
- }
77
-
78
- const rawSession = getSession(chatId);
79
- return {
80
- sessionChatId: String(chatId),
81
- cwd: rawSession && rawSession.cwd ? normalizeRouteCwd(rawSession.cwd) : null,
82
- engine: inferStoredEngine(rawSession),
83
- };
50
+ return _resolveSessionRoute({
51
+ chatId,
52
+ cfg: loadConfig(),
53
+ state: loadState(),
54
+ getSession,
55
+ normalizeCwd: normalizeRouteCwd,
56
+ normalizeEngineName,
57
+ inferStoredEngine,
58
+ });
84
59
  }
85
60
 
86
61
  function getActiveSession(chatId) {