metame-cli 1.5.23 → 1.5.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metame-cli",
3
- "version": "1.5.23",
3
+ "version": "1.5.25",
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 {
@@ -72,45 +77,16 @@ function createAgentCommandHandler(deps) {
72
77
  return available.length === 1 ? normalizeEngineName(available[0]) : getDefaultEngine();
73
78
  }
74
79
 
75
- function buildBoundSessionChatId(projectKey) {
76
- const key = String(projectKey || '').trim();
77
- return key ? `_bound_${key}` : '';
78
- }
79
-
80
80
  function getSessionRoute(chatId) {
81
- const cfg = loadConfig();
82
- const state = loadState();
83
- const chatKey = String(chatId);
84
- const agentMap = { ...(cfg.telegram ? cfg.telegram.chat_agent_map : {}), ...(cfg.feishu ? cfg.feishu.chat_agent_map : {}) };
85
- const boundKey = agentMap[chatKey] || null;
86
- const boundProj = boundKey && cfg.projects ? cfg.projects[boundKey] : null;
87
- const stickyKey = state && state.team_sticky ? state.team_sticky[chatKey] : null;
88
- const stickyMember = stickyKey && boundProj && Array.isArray(boundProj.team)
89
- ? boundProj.team.find((m) => m && m.key === stickyKey)
90
- : null;
91
-
92
- if (stickyMember) {
93
- return {
94
- sessionChatId: `_agent_${stickyMember.key}`,
95
- cwd: stickyMember.cwd ? normalizeCwd(stickyMember.cwd) : (boundProj && boundProj.cwd ? normalizeCwd(boundProj.cwd) : null),
96
- engine: normalizeEngineName(stickyMember.engine || (boundProj && boundProj.engine)),
97
- };
98
- }
99
-
100
- if (boundProj) {
101
- return {
102
- sessionChatId: buildBoundSessionChatId(boundKey),
103
- cwd: boundProj.cwd ? normalizeCwd(boundProj.cwd) : null,
104
- engine: normalizeEngineName(boundProj.engine),
105
- };
106
- }
107
-
108
- const rawSession = getSession(chatId);
109
- return {
110
- sessionChatId: String(chatId),
111
- cwd: rawSession && rawSession.cwd ? normalizeCwd(rawSession.cwd) : null,
112
- engine: inferStoredEngine(rawSession),
113
- };
81
+ return _resolveSessionRoute({
82
+ chatId,
83
+ cfg: loadConfig(),
84
+ state: loadState(),
85
+ getSession,
86
+ normalizeCwd,
87
+ normalizeEngineName,
88
+ inferStoredEngine,
89
+ });
114
90
  }
115
91
 
116
92
  function getCurrentEngine(chatId) {
@@ -170,6 +146,17 @@ function createAgentCommandHandler(deps) {
170
146
  return null;
171
147
  }
172
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
+
173
160
  async function autoCreateSessionOnEmptyResume(bot, chatId, cwd, engine) {
174
161
  const resolvedCwd = cwd ? normalizeCwd(cwd) : null;
175
162
  if (!resolvedCwd || !fs.existsSync(resolvedCwd) || typeof attachOrCreateSession !== 'function') {
@@ -389,10 +376,13 @@ function createAgentCommandHandler(deps) {
389
376
  }
390
377
  const sessionId = fullMatch.sessionId;
391
378
  const cwd = fullMatch.projectPath || (curSession && curSession.cwd) || HOME;
379
+ const targetSessionId = sessionId;
380
+ const targetCwd = cwd;
392
381
 
393
382
  const state2 = loadState();
394
383
  const cfgForEngine = loadConfig();
395
- const sessionKey = route.sessionChatId;
384
+ const resumeRoute = resolveResumeRouteForSelection(chatId, route, targetCwd, cfgForEngine, state2);
385
+ const sessionKey = resumeRoute.sessionChatId;
396
386
  const existing = state2.sessions[sessionKey] || {};
397
387
  const existingEngine = normalizeEngineName(
398
388
  existing.engine
@@ -405,8 +395,6 @@ function createAgentCommandHandler(deps) {
405
395
  && currentLogical
406
396
  && currentLogical.id
407
397
  && sessionId === currentLogical.id;
408
- const targetSessionId = sessionId;
409
- const targetCwd = cwd;
410
398
  const existingEngines = existing.engines || {};
411
399
  state2.sessions[sessionKey] = {
412
400
  ...existing,
@@ -416,6 +404,7 @@ function createAgentCommandHandler(deps) {
416
404
  engine: engineByTargetCwd,
417
405
  engines: { ...existingEngines, [engineByTargetCwd]: { id: targetSessionId, started: true } },
418
406
  };
407
+ _applyResumeRouteState(state2, chatId, resumeRoute);
419
408
  saveState(state2);
420
409
  const name = fullMatch.customTitle;
421
410
  const label = name || (fullMatch.summary || fullMatch.firstPrompt || '').slice(0, 40) || targetSessionId.slice(0, 8);
@@ -423,7 +412,7 @@ function createAgentCommandHandler(deps) {
423
412
  // 读取最近对话片段,帮助确认是否切换到正确的 session
424
413
  const recentCtx = getSessionRecentContext ? getSessionRecentContext(targetSessionId) : null;
425
414
  const recentDialogue = getSessionRecentDialogue ? getSessionRecentDialogue(targetSessionId, 4) : null;
426
- let msg = `✅ 已切换: **${label}**\n📁 ${path.basename(cwd)}`;
415
+ let msg = `✅ 已切换: **${label}**\n📁 ${path.basename(cwd)}\n🆔 \`${targetSessionId}\``;
427
416
  if (selectedLogicalCurrent) {
428
417
  msg += '\n\n已恢复当前智能体会话。';
429
418
  }
@@ -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,15 +425,17 @@ 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 &&
414
432
  cur.cwd !== projCwd &&
415
433
  !rawHasEngine;
416
- if (!cur || !curHasEngine || shouldReattachForCwdChange) {
434
+ const _isControlCmd = text && /^\/(stop|quit)$/.test(text.trim());
435
+ if (!_isControlCmd && (!cur || !curHasEngine || shouldReattachForCwdChange)) {
417
436
  const initReason = !cur ? 'no-session' : (!curHasEngine ? 'engine-missing' : 'cwd-changed');
418
437
  log('INFO', `SESSION-INIT [${String(sessionChatId).slice(-32)}] ${initReason}`);
419
- attachOrCreateSession(sessionChatId, projCwd, proj.name || mappedKey, proj.engine || getDefaultEngine());
438
+ attachOrCreateSession(sessionChatId, projCwd, proj.name || mappedKey, targetEngine);
420
439
  }
421
440
  }
422
441
 
@@ -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) {
@@ -4,6 +4,7 @@ const { classifyTaskUsage } = require('./usage-classifier');
4
4
  const { normalizeModel } = require('./daemon-task-scheduler');
5
5
  const { resolveEngineModel } = require('./daemon-engine-runtime');
6
6
  const { createCommandSessionResolver } = require('./daemon-command-session-route');
7
+ const { isThreadChatId: _isThreadChatId, rawChatId: _rawThreadChatId } = require('./core/thread-chat-id');
7
8
 
8
9
  function createExecCommandHandler(deps) {
9
10
  const {
@@ -218,7 +219,12 @@ function createExecCommandHandler(deps) {
218
219
  const _pl = pipeline && pipeline.current;
219
220
  if (_pl) {
220
221
  _pl.clearQueue(chatId);
221
- const stopped = _pl.interruptActive(chatId);
222
+ let stopped = _pl.interruptActive(chatId);
223
+ if (!stopped && _isThreadChatId(chatId)) {
224
+ // Thread-scoped /stop: fall back to raw chatId (task may be keyed at group level).
225
+ // Do NOT clearQueue(_raw) — that would discard queued tasks from other threads.
226
+ stopped = _pl.interruptActive(_rawThreadChatId(chatId));
227
+ }
222
228
  if (stopped) {
223
229
  await bot.sendMessage(chatId, '⏹ Stopping current engine task...');
224
230
  } else {
@@ -226,7 +232,8 @@ function createExecCommandHandler(deps) {
226
232
  }
227
233
  } else {
228
234
  // Fallback: direct activeProcesses manipulation (pipeline not yet initialized)
229
- const proc = activeProcesses.get(chatId);
235
+ const _raw = _isThreadChatId(chatId) ? _rawThreadChatId(chatId) : null;
236
+ const proc = activeProcesses.get(chatId) || (_raw && activeProcesses.get(_raw));
230
237
  if (proc && proc.child) {
231
238
  proc.aborted = true;
232
239
  const signal = proc.killSignal || 'SIGTERM';
@@ -248,9 +255,12 @@ function createExecCommandHandler(deps) {
248
255
  const _pl = pipeline && pipeline.current;
249
256
  if (_pl) {
250
257
  _pl.clearQueue(chatId);
251
- _pl.interruptActive(chatId);
258
+ if (!_pl.interruptActive(chatId) && _isThreadChatId(chatId)) {
259
+ _pl.interruptActive(_rawThreadChatId(chatId));
260
+ }
252
261
  } else {
253
- const proc = activeProcesses.get(chatId);
262
+ const _raw = _isThreadChatId(chatId) ? _rawThreadChatId(chatId) : null;
263
+ const proc = activeProcesses.get(chatId) || (_raw && activeProcesses.get(_raw));
254
264
  if (proc && proc.child) {
255
265
  proc.aborted = true;
256
266
  const signal = proc.killSignal || 'SIGTERM';