@xmanrui/dsh-im 4.9.1 → 4.10.0

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 (39) hide show
  1. package/PROACTIVE_DELIVERY.en.md +8 -0
  2. package/PROACTIVE_DELIVERY.md +8 -0
  3. package/lib/client.js +610 -56
  4. package/lib/index.js +273 -272
  5. package/package.json +12 -1
  6. package/plugin-src/client/channels/feishu/api.js +2 -0
  7. package/plugin-src/client/channels/feishu/index.js +54 -0
  8. package/plugin-src/client/global-settings.js +341 -0
  9. package/plugin-src/client/i18n.js +29 -0
  10. package/plugin-src/client/index.js +47 -6
  11. package/plugin-src/client/styles.js +49 -0
  12. package/plugin-src/host/channels/dingtalk/production.mjs +12 -0
  13. package/plugin-src/host/channels/feishu/production.mjs +14 -0
  14. package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
  15. package/plugin-src/host/channels/qq/production.mjs +12 -0
  16. package/plugin-src/host/channels/shared/production.mjs +12 -0
  17. package/plugin-src/host/channels/slack/production.mjs +12 -0
  18. package/plugin-src/host/channels/wecom/production.mjs +12 -0
  19. package/plugin-src/host/channels/weixin/production.mjs +12 -0
  20. package/plugin-src/host/channels/whatsapp/production.mjs +12 -0
  21. package/plugin-src/host/delivery-adapter.mjs +1 -0
  22. package/plugin-src/host/delivery-service.mjs +15 -0
  23. package/plugin-src/host/harness-session-coordinator.mjs +26 -5
  24. package/plugin-src/host/inbound-ttl-rpc.mjs +83 -0
  25. package/plugin-src/host/inbound-ttl-runtime.mjs +85 -0
  26. package/plugin-src/host/inbound-ttl-service.mjs +179 -0
  27. package/plugin-src/host/index.mjs +8 -0
  28. package/plugin-src/host/modern-harness-api.mjs +58 -2
  29. package/src/channels/feishu/bridge.mjs +164 -4
  30. package/src/channels/feishu/feishu-channel.mjs +45 -10
  31. package/src/channels/feishu/feishu-runtime.mjs +9 -0
  32. package/src/channels/feishu/message-utils.mjs +13 -0
  33. package/src/channels/feishu/multi-bot-controller.mjs +16 -0
  34. package/src/channels/feishu/plugin-config-store.mjs +1 -0
  35. package/src/channels/feishu/state-store.mjs +25 -0
  36. package/src/channels/shared/bot-workspace-store.mjs +6 -0
  37. package/src/channels/shared/inbound-file.mjs +117 -2
  38. package/src/channels/shared/inbound-ttl-store.mjs +110 -0
  39. package/src/channels/shared/inbound-ttl.mjs +22 -0
@@ -0,0 +1,179 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ import { sweepInboundAttachments } from '../../src/channels/shared/inbound-file.mjs';
5
+
6
+ const DEFAULT_SWEEP_INTERVAL_MS = 30 * 60_000;
7
+
8
+ // A tracked directory is shielded from sweeps while its turn may still be
9
+ // running. Aborted or stalled turns never call cleanup(), so protection has
10
+ // an upper bound — far above the default reply timeout — after which the
11
+ // directory falls back under TTL control instead of leaking forever.
12
+ const TRACKED_PROTECTION_MS = 24 * 60 * 60_000;
13
+
14
+ /**
15
+ * Host-process coordinator for the global inbound attachment TTL. The staged
16
+ * registry marks directories owned by in-flight turns so a slow turn can
17
+ * outlive any TTL window without the sweeper deleting its files.
18
+ */
19
+ export function createInboundTtlService({
20
+ store,
21
+ logger = console,
22
+ intervalMs = DEFAULT_SWEEP_INTERVAL_MS,
23
+ } = {}) {
24
+ if (!store || typeof store.getTtlHours !== 'function') {
25
+ throw new TypeError('createInboundTtlService requires a store exposing getTtlHours()');
26
+ }
27
+ const tracked = new Map();
28
+ const providers = new Set();
29
+ let timer = null;
30
+
31
+ const runSweep = () => {
32
+ sweepNow().catch((error) => {
33
+ logger.error?.('[dsh-im] inbound attachment sweep failed', error);
34
+ });
35
+ };
36
+
37
+ function registerWorkspaceProvider(provider) {
38
+ if (typeof provider !== 'function') {
39
+ throw new TypeError('inbound TTL workspace provider must be a function');
40
+ }
41
+ const wasEmpty = providers.size === 0;
42
+ providers.add(provider);
43
+ // The startup sweep races channel activation and usually runs before any
44
+ // provider exists; re-sweep on the first registration so crash leftovers
45
+ // do not wait a full interval.
46
+ if (wasEmpty && timer) runSweep();
47
+ return () => {
48
+ providers.delete(provider);
49
+ };
50
+ }
51
+
52
+ function stagingRetention() {
53
+ // TTL 0 deletes at turn end; every other mode keeps files for the sweeper.
54
+ return store.getTtlHours() === 0 ? 'turn' : 'persistent';
55
+ }
56
+
57
+ function canonicalPath(directory) {
58
+ try {
59
+ return realpathSync(directory);
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function trackDirectory(directory, { now = () => Date.now() } = {}) {
66
+ if (typeof directory !== 'string' || !directory) return;
67
+ const trackedAt = now();
68
+ tracked.set(directory, trackedAt);
69
+ // The harness-reported cwd and workspaceFor() may normalize symlinks
70
+ // differently; track both spellings so either side finds the entry.
71
+ const canonical = canonicalPath(directory);
72
+ if (canonical && canonical !== directory) tracked.set(canonical, trackedAt);
73
+ }
74
+
75
+ function releaseDirectory(directory) {
76
+ tracked.delete(directory);
77
+ const canonical = canonicalPath(directory);
78
+ if (canonical && canonical !== directory) tracked.delete(canonical);
79
+ }
80
+
81
+ function isTrackedNow(directory) {
82
+ const trackedAt = tracked.get(directory);
83
+ if (trackedAt === undefined) return false;
84
+ if (Date.now() - trackedAt >= TRACKED_PROTECTION_MS) {
85
+ tracked.delete(directory);
86
+ return false;
87
+ }
88
+ return true;
89
+ }
90
+
91
+ function trackStaged(staged) {
92
+ if (!staged || typeof staged !== 'object') return staged;
93
+ const { directory } = staged;
94
+ if (typeof directory !== 'string' || !directory) return staged;
95
+ trackDirectory(directory);
96
+ const cleanup = typeof staged.cleanup === 'function' ? staged.cleanup : null;
97
+ return Object.freeze({
98
+ ...staged,
99
+ async cleanup() {
100
+ try {
101
+ await cleanup?.call(staged);
102
+ } finally {
103
+ releaseDirectory(directory);
104
+ }
105
+ },
106
+ });
107
+ }
108
+
109
+ async function sweepNow() {
110
+ const ttlHours = store.getTtlHours();
111
+ // Keep-forever never deletes, so no workspace is even inspected.
112
+ if (ttlHours === -1) return { deletedDirectories: 0, sweptWorkspaces: 0 };
113
+ const workspaces = new Set();
114
+ for (const provider of providers) {
115
+ try {
116
+ const provided = await provider();
117
+ for (const workspace of Array.isArray(provided) ? provided : []) {
118
+ if (typeof workspace === 'string' && workspace) workspaces.add(resolve(workspace));
119
+ }
120
+ } catch (error) {
121
+ logger.warn?.('[dsh-im] inbound TTL workspace provider failed', error);
122
+ }
123
+ }
124
+ const isTracked = (directory) => isTrackedNow(directory)
125
+ || (canonicalPath(directory) !== null && isTrackedNow(canonicalPath(directory)));
126
+ let deletedDirectories = 0;
127
+ let sweptWorkspaces = 0;
128
+ for (const workspace of workspaces) {
129
+ try {
130
+ const result = await sweepInboundAttachments(workspace, ttlHours, { isTracked });
131
+ deletedDirectories += result.deleted;
132
+ sweptWorkspaces += 1;
133
+ } catch (error) {
134
+ logger.warn?.(`[dsh-im] inbound attachment sweep failed under ${workspace}`, error);
135
+ }
136
+ }
137
+ if (deletedDirectories > 0) {
138
+ logger.info?.(
139
+ `[dsh-im] inbound attachment sweep deleted ${deletedDirectories} directories`
140
+ + ` across ${sweptWorkspaces} workspaces (ttl ${ttlHours}h)`,
141
+ );
142
+ }
143
+ return { deletedDirectories, sweptWorkspaces };
144
+ }
145
+
146
+ function start() {
147
+ if (timer) return;
148
+ runSweep();
149
+ timer = setInterval(runSweep, intervalMs);
150
+ timer.unref?.();
151
+ }
152
+
153
+ // A freshly saved TTL postpones the next scheduled sweep by a full
154
+ // interval so the new value is never enforced by a sweep firing moments
155
+ // after the change, and is not starved of one either.
156
+ function resetSweepSchedule() {
157
+ if (!timer) return;
158
+ clearInterval(timer);
159
+ timer = setInterval(runSweep, intervalMs);
160
+ timer.unref?.();
161
+ }
162
+
163
+ function stop() {
164
+ if (!timer) return;
165
+ clearInterval(timer);
166
+ timer = null;
167
+ }
168
+
169
+ return Object.freeze({
170
+ registerWorkspaceProvider,
171
+ stagingRetention,
172
+ trackDirectory,
173
+ trackStaged,
174
+ sweepNow,
175
+ start,
176
+ resetSweepSchedule,
177
+ stop,
178
+ });
179
+ }
@@ -13,6 +13,7 @@ import { setImHostLanguage } from '../../src/channels/shared/i18n.mjs';
13
13
  import { installDeliveryRpc } from './delivery-rpc.mjs';
14
14
  import { installDeliveryHttp } from './delivery-http.mjs';
15
15
  import { createDeliveryService } from './delivery-service.mjs';
16
+ import { installInboundTtlRpc } from './inbound-ttl-rpc.mjs';
16
17
  import { installUpdateRpc } from './update-rpc.mjs';
17
18
 
18
19
  export const name = 'dsh-im-host';
@@ -32,6 +33,7 @@ function channelConfig(config, name, deliveryService) {
32
33
 
33
34
  export function createImHostPlugin(internals = {}) {
34
35
  const startUpdate = internals.installUpdateRpc ?? installUpdateRpc;
36
+ const startInboundTtl = internals.installInboundTtlRpc ?? installInboundTtlRpc;
35
37
  const startDelivery = internals.installDeliveryRpc ?? installDeliveryRpc;
36
38
  const startDeliveryHttp = internals.installDeliveryHttp ?? installDeliveryHttp;
37
39
  const makeDeliveryService = internals.createDeliveryService ?? createDeliveryService;
@@ -68,6 +70,7 @@ export function createImHostPlugin(internals = {}) {
68
70
  deliveryService.send(botId, targetId, text, options)
69
71
  ),
70
72
  listTargets: async (botId) => (await deliveryService.listTargets(botId)).targets,
73
+ listBots: () => deliveryService.listBots(),
71
74
  }));
72
75
  }
73
76
  const activate = async (readyCtx) => {
@@ -109,6 +112,11 @@ export function createImHostPlugin(internals = {}) {
109
112
  } catch (error) {
110
113
  logger.error?.('[dsh-im] failed to activate update management; continuing with channels', error);
111
114
  }
115
+ try {
116
+ startInboundTtl(ctx, { config });
117
+ } catch (error) {
118
+ logger.error?.('[dsh-im] failed to activate inbound TTL settings; continuing with channels', error);
119
+ }
112
120
  try {
113
121
  startDelivery(ctx, deliveryService, { authority: config.rpcAuthority });
114
122
  } catch (error) {
@@ -200,6 +200,7 @@ class ModernHarnessApi {
200
200
  #pendingQuestions = new Map();
201
201
  #pendingApprovals = new Map();
202
202
  #sessionCursors = new Map();
203
+ #assistantStreams = new Map();
203
204
  #disposers = [];
204
205
  #disposed = false;
205
206
 
@@ -261,6 +262,13 @@ class ModernHarnessApi {
261
262
  if (Number.isSafeInteger(event.seq)) this.#rememberCursor(sessionId, event.seq);
262
263
  this.#broadcast({ type: 'session/event', sessionId, event });
263
264
  }, { global: true }));
265
+ this.#disposers.push(ctx.on('agent/assistant-stream', ({ agent, frame }) => {
266
+ this.#acceptAssistantStream(agent, frame);
267
+ }, { global: true }));
268
+ this.#disposers.push(ctx.on('agent/disposed', ({ agent }) => {
269
+ const sessionId = agent?.session?.id;
270
+ if (typeof sessionId === 'string') this.#assistantStreams.delete(sessionId);
271
+ }, { global: true }));
264
272
  this.#disposers.push(ctx.on(
265
273
  'approval/request',
266
274
  (request, next) => this.#requestApproval(request, next),
@@ -328,7 +336,7 @@ class ModernHarnessApi {
328
336
  cursor = this.#sessionCursors.get(sessionId) ?? snapshot.cursor;
329
337
  if (beforeSeq === undefined && cursor === snapshot.cursor) {
330
338
  return {
331
- events: historyEntries(snapshot.records),
339
+ events: this.#withAssistantStream(sessionId, historyEntries(snapshot.records)),
332
340
  hasMore: snapshot.hasMore === true,
333
341
  ...(snapshot.projections === undefined ? {} : { projections: snapshot.projections }),
334
342
  };
@@ -342,7 +350,54 @@ class ModernHarnessApi {
342
350
  ...(beforeSeq === undefined ? {} : { beforeSeq }),
343
351
  },
344
352
  }, signal);
345
- return { events: historyEntries(page.records), hasMore: page.hasMore === true };
353
+ return {
354
+ events: beforeSeq === undefined
355
+ ? this.#withAssistantStream(sessionId, historyEntries(page.records))
356
+ : historyEntries(page.records),
357
+ hasMore: page.hasMore === true,
358
+ };
359
+ }
360
+
361
+ #acceptAssistantStream(agent, frame) {
362
+ const sessionId = agent?.session?.id;
363
+ if (typeof sessionId !== 'string' || !frame || typeof frame !== 'object') return;
364
+ if (frame.type === 'start') {
365
+ const nextSeq = agent?.session?.seq;
366
+ const startedAfterSeq = Number.isSafeInteger(nextSeq) && nextSeq >= 0
367
+ ? nextSeq - 1
368
+ : this.#sessionCursors.get(sessionId) ?? -1;
369
+ this.#assistantStreams.set(sessionId, {
370
+ attemptId: frame.attemptId,
371
+ turn: frame.turn,
372
+ step: frame.step,
373
+ startedAfterSeq,
374
+ entries: [],
375
+ });
376
+ return;
377
+ }
378
+ const stream = this.#assistantStreams.get(sessionId);
379
+ if (!stream || stream.attemptId !== frame.attemptId) return;
380
+ if (frame.type === 'end') {
381
+ this.#assistantStreams.delete(sessionId);
382
+ return;
383
+ }
384
+ if (frame.type !== 'chunk' || frame.index !== stream.entries.length) return;
385
+ stream.entries.push({
386
+ type: 'transient',
387
+ event: {
388
+ type: 'assistant/chunk',
389
+ // Match DSH's Web client ordering: live chunks sit in the numeric gap
390
+ // before the durable assistant/message that replaces them.
391
+ seq: stream.startedAfterSeq + 1 - 1 / (frame.index + 2),
392
+ time: frame.time,
393
+ data: { turn: stream.turn, step: stream.step, chunk: frame.chunk },
394
+ },
395
+ });
396
+ }
397
+
398
+ #withAssistantStream(sessionId, entries) {
399
+ const transient = this.#assistantStreams.get(sessionId)?.entries ?? [];
400
+ return transient.length === 0 ? entries : [...entries, ...transient];
346
401
  }
347
402
 
348
403
  #rememberCursor(sessionId, cursor) {
@@ -556,6 +611,7 @@ class ModernHarnessApi {
556
611
  if (this.#disposed) return;
557
612
  this.#disposed = true;
558
613
  for (const subscription of [...this.#mux]) subscription.close();
614
+ this.#assistantStreams.clear();
559
615
  for (const pending of [...this.#pendingApprovals.values()]) pending.settle('cancelled');
560
616
  for (const pending of [...this.#pendingQuestions.values()]) pending.settle(
561
617
  'cancelled',
@@ -5,6 +5,8 @@ import {
5
5
  extractText,
6
6
  isAllowedSender,
7
7
  isBotSender,
8
+ isTopicGroupKey,
9
+ managedGroupKey,
8
10
  splitText,
9
11
  } from './message-utils.mjs';
10
12
  import {
@@ -101,6 +103,10 @@ import {
101
103
  // Lazily evaluated: t() must run after setImHostLanguage, not at import time.
102
104
  const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处理,无需再次回答。');
103
105
  const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
106
+ /** Managed-topic root candidates expire without a returned thread_id. */
107
+ const ROOT_CANDIDATE_TTL_MS = 30 * 60_000;
108
+ /** Bound main-feed roots awaiting a topic so silent drops cannot grow them. */
109
+ const MAX_ROOT_CANDIDATES = 512;
104
110
 
105
111
  const MENU_COMMAND = /^\/m(?:enu)?$/i;
106
112
  const REPAIR_COMMAND_PREFIX = /^\/repair(?:\s|$)/i;
@@ -480,6 +486,14 @@ export class FeishuHarnessBridge {
480
486
  #appId;
481
487
  #botOpenId;
482
488
  #groupResponseMode;
489
+ /** When true, group replies that belong to a Feishu topic ask reply_in_thread. */
490
+ #groupTopicReply = false;
491
+ /** Group chats this bot has seen; only these may use topic replies. */
492
+ #groupChatIds = new Set();
493
+ /** Anchor message id → whether its replies should stay in a Feishu topic. */
494
+ #anchorTopicReply = new Map();
495
+ /** Main-feed roots this bot intends to auto-open as topics, awaiting thread_id. */
496
+ #rootCandidates = new Map();
483
497
  #repair;
484
498
  #repairAttempt = null;
485
499
  #repairMonitorVersion = 0;
@@ -516,6 +530,7 @@ export class FeishuHarnessBridge {
516
530
  appId,
517
531
  botOpenId,
518
532
  groupResponseMode = FEISHU_GROUP_RESPONSE_MODES.ALL,
533
+ groupTopicReply = false,
519
534
  repair,
520
535
  repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
521
536
  repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
@@ -555,6 +570,7 @@ export class FeishuHarnessBridge {
555
570
  this.#appId = nonEmptyString(appId);
556
571
  this.#botOpenId = nonEmptyString(botOpenId);
557
572
  this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
573
+ this.#groupTopicReply = groupTopicReply === true;
558
574
  this.#repair = repair ?? null;
559
575
  this.#repairPollIntervalMs = repairPollIntervalMs;
560
576
  this.#repairLinkWaitMs = repairLinkWaitMs;
@@ -576,6 +592,10 @@ export class FeishuHarnessBridge {
576
592
  this.#groupResponseMode = normalizeFeishuGroupResponseMode(value);
577
593
  }
578
594
 
595
+ setGroupTopicReply(value) {
596
+ this.#groupTopicReply = value === true;
597
+ }
598
+
579
599
  #isAddressed(event) {
580
600
  if (event?.message?.chat_type === 'p2p') return true;
581
601
  const mentions = Array.isArray(event?.message?.mentions) ? event.message.mentions : [];
@@ -584,6 +604,106 @@ export class FeishuHarnessBridge {
584
604
  || mention?.open_id === this.#botOpenId);
585
605
  }
586
606
 
607
+ /**
608
+ * Conversation key for one inbound event. When group-topic replies are on,
609
+ * a main-feed question addressed to the bot opens a fresh managed topic
610
+ * session instead of joining the shared group session, and messages inside
611
+ * a topic we already opened resolve back to that same managed session.
612
+ */
613
+ #resolveKey(event) {
614
+ const chatType = event?.message?.chat_type;
615
+ const chatId = nonEmptyString(event?.message?.chat_id);
616
+ if (chatType !== 'group') return conversationKey(event);
617
+ const messageId = nonEmptyString(event?.message?.message_id);
618
+ const threadId = nonEmptyString(event?.message?.thread_id);
619
+ if (threadId) {
620
+ const root = this.#state?.topicRootFor?.(threadId) ?? null;
621
+ return root && chatId
622
+ ? managedGroupKey(chatId, root.rootMessageId)
623
+ : `group:${chatId}:thread:${threadId}`;
624
+ }
625
+ if (this.#groupTopicReply && chatId && messageId && this.#isAddressed(event)) {
626
+ return managedGroupKey(chatId, messageId);
627
+ }
628
+ return `group:${chatId}`;
629
+ }
630
+
631
+ /**
632
+ * Record whether replies anchored on `messageId` should stay in a topic.
633
+ * `asRoot` marks a main-feed question this bot will auto-open as a topic.
634
+ */
635
+ #rememberTopicReply(messageId, key) {
636
+ if (!nonEmptyString(messageId)) return;
637
+ this.#anchorTopicReply.set(messageId, this.#groupTopicReply && isTopicGroupKey(key));
638
+ if (this.#anchorTopicReply.size > 2048) {
639
+ const oldest = this.#anchorTopicReply.keys().next().value;
640
+ if (oldest !== undefined) this.#anchorTopicReply.delete(oldest);
641
+ }
642
+ if (typeof key === 'string' && key.startsWith('group:')) {
643
+ const chatId = key.split(':')[1];
644
+ if (chatId) this.#groupChatIds.add(chatId);
645
+ }
646
+ }
647
+
648
+ /** Whether a reply anchored on `replyTo` must ask Feishu to keep it in a topic. */
649
+ #replyInThreadFor(replyTo) {
650
+ return this.#groupTopicReply
651
+ && nonEmptyString(replyTo)
652
+ && this.#anchorTopicReply.get(replyTo) === true;
653
+ }
654
+
655
+ /** Drop expired root candidates so a silently dropped reply cannot leak memory. */
656
+ #pruneRootCandidates(now = Date.now()) {
657
+ for (const [messageId, expiresAt] of this.#rootCandidates) {
658
+ if (expiresAt <= now) this.#rootCandidates.delete(messageId);
659
+ }
660
+ }
661
+
662
+ /** Track a main-feed root this bot will auto-open as a topic, bounded + TTL'd. */
663
+ #rememberTopicRoot(messageId) {
664
+ if (!nonEmptyString(messageId)) return;
665
+ const now = Date.now();
666
+ this.#pruneRootCandidates(now);
667
+ this.#rootCandidates.delete(messageId);
668
+ this.#rootCandidates.set(messageId, now + ROOT_CANDIDATE_TTL_MS);
669
+ while (this.#rootCandidates.size > MAX_ROOT_CANDIDATES) {
670
+ const oldest = this.#rootCandidates.keys().next().value;
671
+ if (oldest === undefined) break;
672
+ this.#rootCandidates.delete(oldest);
673
+ }
674
+ }
675
+
676
+ /** Forget a root candidate once its topic opened or the reply degraded. */
677
+ #forgetTopicRoot(messageId) {
678
+ if (!nonEmptyString(messageId)) return;
679
+ this.#rootCandidates.delete(messageId);
680
+ }
681
+
682
+ /** Persist thread_id once Feishu auto-opened a topic from a managed root. */
683
+ async #registerTopicFromReply(replyTo, chatId, response) {
684
+ const threadId = nonEmptyString(response?.data?.thread_id);
685
+ if (threadId) {
686
+ await this.#registerTopicThreadId(threadId, replyTo, chatId);
687
+ } else {
688
+ // No thread_id means the topic did not open; drop the candidate so a
689
+ // later addressed question opens a fresh session, not a stale slot.
690
+ this.#forgetTopicRoot(replyTo);
691
+ }
692
+ }
693
+
694
+ /** Persist thread_id when the reply anchor is a root this bot auto-opened. */
695
+ async #registerTopicThreadId(threadId, rootMessageId, chatId) {
696
+ this.#pruneRootCandidates();
697
+ if (!threadId || !this.#rootCandidates.has(rootMessageId)) return;
698
+ this.#rootCandidates.delete(rootMessageId);
699
+ if (!chatId || typeof this.#state?.setTopic !== 'function') return;
700
+ try {
701
+ await this.#state.setTopic(threadId, { rootMessageId, chatId });
702
+ } catch (error) {
703
+ this.#logger.warn?.('[dsh-feishu] failed to record managed topic:', error?.message ?? String(error));
704
+ }
705
+ }
706
+
587
707
  accept(event) {
588
708
  if (this.#signal?.aborted) return Promise.resolve();
589
709
  const messageId = nonEmptyString(event?.message?.message_id);
@@ -606,12 +726,13 @@ export class FeishuHarnessBridge {
606
726
 
607
727
  let key;
608
728
  try {
609
- key = conversationKey(event);
729
+ key = this.#resolveKey(event);
610
730
  } catch {
611
731
  this.#status.messagesRejected += 1;
612
732
  this.#status.lastRejectedAt = new Date().toISOString();
613
733
  return Promise.resolve();
614
734
  }
735
+ this.#rememberTopicReply(messageId, key);
615
736
 
616
737
  const commandMessage = extractInboundMessage(event, this.#client);
617
738
  const commandText = nonEmptyString(commandMessage.content) ?? '';
@@ -635,6 +756,15 @@ export class FeishuHarnessBridge {
635
756
  this.#acceptedMessageIds.set(messageId, null);
636
757
  return this.#finishAccessDecision(event, messageId, access);
637
758
  }
759
+ // Register a managed-topic root only after access is allowed, so a
760
+ // rejected or ignored question never reserves a candidate slot.
761
+ if (this.#groupTopicReply
762
+ && event?.message?.chat_type === 'group'
763
+ && !nonEmptyString(event?.message?.thread_id)
764
+ && isTopicGroupKey(key)
765
+ && key.includes(':managed:')) {
766
+ this.#rememberTopicRoot(messageId);
767
+ }
638
768
  if (event.message.chat_type === 'p2p') {
639
769
  const chatId = nonEmptyString(event.message.chat_id);
640
770
  if (chatId) rememberConnectionTestTarget(this.#state, { chatId });
@@ -1910,6 +2040,7 @@ export class FeishuHarnessBridge {
1910
2040
  }) {
1911
2041
  // Confirmations triggered by a card interaction stay anchored to the
1912
2042
  // card's message so they land inside the same Feishu topic.
2043
+ this.#rememberTopicReply(messageId, key);
1913
2044
  const reply = (text) => this.#send(chatId, text, { replyTo: messageId });
1914
2045
  // Approval card buttons: approve:<approvalId> / reject:<approvalId>
1915
2046
  if (action.startsWith('approve:') || action.startsWith('reject:')) {
@@ -2383,18 +2514,25 @@ export class FeishuHarnessBridge {
2383
2514
  try {
2384
2515
  const response = await this.#client.im.v1.message.reply({
2385
2516
  path: { message_id: replyTo },
2386
- data: { msg_type: 'interactive', content },
2517
+ data: {
2518
+ msg_type: 'interactive',
2519
+ content,
2520
+ ...(this.#replyInThreadFor(replyTo) ? { reply_in_thread: true } : {}),
2521
+ },
2387
2522
  });
2388
2523
  if (response?.code && response.code !== 0) {
2389
2524
  throw new Error(`Feishu card reply failed: ${response.msg || response.code}`);
2390
2525
  }
2391
2526
  const repliedMessageId = nonEmptyString(response?.data?.message_id);
2527
+ await this.#registerTopicFromReply(replyTo, chatId, response);
2392
2528
  if (repliedMessageId) {
2393
2529
  this.#rememberCardRoute(repliedMessageId, chatId, options);
2394
2530
  return repliedMessageId;
2395
2531
  }
2396
2532
  } catch (error) {
2397
2533
  this.#logger.warn?.('[dsh-feishu] threaded card reply failed; sending a plain card:', error?.message ?? String(error));
2534
+ // The topic never opened; a managed root candidate must not linger.
2535
+ this.#forgetTopicRoot(replyTo);
2398
2536
  }
2399
2537
  }
2400
2538
  const response = await this.#client.im.v1.message.create({
@@ -3206,6 +3344,10 @@ export class FeishuHarnessBridge {
3206
3344
  || (validLastSeq(entry.lastSeq) && entry.lastSeq >= event.seq)
3207
3345
  || (validEventSeq(failedSeq) && event.seq > failedSeq)) continue;
3208
3346
  try {
3347
+ // Re-derive topic intent at delivery time: the push may outlive the
3348
+ // bounded in-memory anchor cache, but the conversation key still tells
3349
+ // us whether the completion belongs to a Feishu topic.
3350
+ this.#rememberTopicReply(entry.replyToMessageId ?? null, key);
3209
3351
  await this.#sendCard(
3210
3352
  entry.chatId,
3211
3353
  completionCard(sessionId, entry.title, reason),
@@ -3346,12 +3488,18 @@ export class FeishuHarnessBridge {
3346
3488
  ? (file) => this.#channel.sendImage(chatId, file, {
3347
3489
  replyTo,
3348
3490
  signal: this.#signal,
3491
+ ...(this.#replyInThreadFor(replyTo)
3492
+ ? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicThreadId(threadId, replyTo, chatId) }
3493
+ : {}),
3349
3494
  })
3350
3495
  : undefined,
3351
3496
  sendFile: typeof this.#channel?.sendFile === 'function'
3352
3497
  ? (file) => this.#channel.sendFile(chatId, file, {
3353
3498
  replyTo,
3354
3499
  signal: this.#signal,
3500
+ ...(this.#replyInThreadFor(replyTo)
3501
+ ? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicThreadId(threadId, replyTo, chatId) }
3502
+ : {}),
3355
3503
  })
3356
3504
  : undefined,
3357
3505
  onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
@@ -3499,7 +3647,12 @@ export class FeishuHarnessBridge {
3499
3647
  completedArtifacts = completed.artifacts ?? [];
3500
3648
  await controller.setContent(answerTextForDelivery(completedAnswer, completedArtifacts));
3501
3649
  },
3502
- }, { replyTo: messageId });
3650
+ }, {
3651
+ replyTo: messageId,
3652
+ ...(this.#replyInThreadFor(messageId)
3653
+ ? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicThreadId(threadId, messageId, chatId) }
3654
+ : {}),
3655
+ });
3503
3656
  } catch (error) {
3504
3657
  this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1;
3505
3658
  if (completedAnswer || completedArtifacts.length > 0) {
@@ -4007,11 +4160,16 @@ export class FeishuHarnessBridge {
4007
4160
  try {
4008
4161
  const response = await this.#client.im.v1.message.reply({
4009
4162
  path: { message_id: replyTo },
4010
- data: { msg_type: 'text', content },
4163
+ data: {
4164
+ msg_type: 'text',
4165
+ content,
4166
+ ...(this.#replyInThreadFor(replyTo) ? { reply_in_thread: true } : {}),
4167
+ },
4011
4168
  });
4012
4169
  if (response?.code && response.code !== 0) {
4013
4170
  throw new Error(`Feishu reply failed: ${response.msg || response.code}`);
4014
4171
  }
4172
+ await this.#registerTopicFromReply(replyTo, chatId, response);
4015
4173
  return nonEmptyString(response?.data?.message_id);
4016
4174
  } catch (error) {
4017
4175
  // The referenced message may be gone (recalled/deleted); keep the
@@ -4020,6 +4178,8 @@ export class FeishuHarnessBridge {
4020
4178
  '[dsh-feishu] threaded reply failed; falling back to a plain message:',
4021
4179
  error?.message ?? String(error),
4022
4180
  );
4181
+ // The topic never opened; a managed root candidate must not linger.
4182
+ this.#forgetTopicRoot(replyTo);
4023
4183
  }
4024
4184
  }
4025
4185
  const response = await this.#client.im.v1.message.create({