@xmanrui/dsh-im 2.0.1 → 2.1.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.
@@ -44,10 +44,18 @@ import {
44
44
  } from '../shared/semantic/delivery.mjs';
45
45
  import {
46
46
  MENU_PAGE_SIZE,
47
+ PRESET_FOLLOW_DEFAULT_SENTINEL,
48
+ STEER_CUSTOM_SENTINEL,
47
49
  completionCard,
50
+ customSteerCard,
51
+ helpCard,
48
52
  menuCard,
49
53
  menuHelpText,
54
+ modelCard,
55
+ presetCard,
50
56
  sessionListCard,
57
+ statusCard,
58
+ steerCard,
51
59
  watchListCard,
52
60
  workspaceListCard,
53
61
  } from './feishu-cards.mjs';
@@ -74,6 +82,22 @@ const NUMBER_REPLY = /^\d{1,2}$/;
74
82
  /** A displayed menu stays number-tappable for this long. */
75
83
  const MENU_TTL_MS = 10 * 60_000;
76
84
  const MAX_TRACKED_MENUS = 50;
85
+ /** Bound callback work per conversation so retries cannot exhaust Host RPCs. */
86
+ const MAX_PENDING_CARD_ACTIONS_PER_KEY = 8;
87
+ /** Bound actual stop/steer submissions independently from ordinary card UI work. */
88
+ const MAX_PENDING_CARD_CONTROLS_PER_KEY = 8;
89
+ /** Provider retries may arrive after the original callback has already settled. */
90
+ const CARD_ACTION_DEDUPE_TTL_MS = 10 * 60_000;
91
+ const MAX_COMPLETED_CARD_ACTIONS = 400;
92
+ /** Keep pre-persistence completion arrivals long enough for a new watch to baseline. */
93
+ const COMPLETION_OBSERVATION_TTL_MS = 10 * 60_000;
94
+ const MAX_OBSERVED_COMPLETION_SESSIONS = 100;
95
+ const MAX_OBSERVED_COMPLETIONS_PER_SESSION = 50;
96
+ const MAX_OBSERVED_COMPLETIONS = 500;
97
+ /** Collapse callback-flood notices instead of amplifying overload into more API calls. */
98
+ const CARD_OVERLOAD_NOTICE_COOLDOWN_MS = 5_000;
99
+ /** Cards should degrade promptly when one optional Host data source is slow. */
100
+ const CARD_DATA_TIMEOUT_MS = 5_000;
77
101
  const REPAIR_LINK_WAIT_MS = 15_000;
78
102
  const REPAIR_POLL_INTERVAL_MS = 1_000;
79
103
  const REPAIR_ACTIVE_STATES = new Set([
@@ -89,39 +113,16 @@ const REPAIR_URL_HOSTS = new Set([
89
113
  'open.larksuite.com',
90
114
  ]);
91
115
 
92
- // Built lazily: t() must run after setImHostLanguage, not at import time.
93
- function helpText() {
94
- return [
95
- t('北汇星河 AIOS 已连接 DeepSeek Harness。'),
96
- '',
97
- t('直接发送文字、图片或文件即可继续当前会话。'),
98
- t('/new 开启一个全新会话'),
99
- t('/compact 压缩当前会话的较早上下文'),
100
- t('/workspace 工作区绝对路径 切换工作区'),
101
- t('/workspacelist 列出工作区绝对路径'),
102
- t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
103
- t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
104
- t('/models 按序号列出所有可用模型'),
105
- t('/model [序号或完整模型ID] 查看或切换当前会话模型'),
106
- t('示例:先发 /models,再发 /model 2'),
107
- t('/presetlist 按序号列出可用 Agent Preset'),
108
- t('/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset'),
109
- t('纯数字 ID:/preset id:<ID>'),
110
- t('/preset --default 跟随 Host 默认'),
111
- t('/stop 停止当前任务'),
112
- t('/steer 补充指令 纠偏当前任务'),
113
- t('/status 检查连接状态'),
114
- t('/repair 修复卡片按钮回调'),
115
- t('/m(或 /menu) 打开交互卡片菜单'),
116
- t('/watch [Session ID 或序号] 关注会话,任务完成自动推送'),
117
- t('/unwatch [Session ID 或序号] 取消关注'),
118
- t('/watchlist 查看关注列表'),
119
- t('/archived on|off 会话列表是否包含归档会话'),
120
- t('/help 显示本帮助'),
121
- ].join('\n');
122
- }
123
-
124
116
  const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
117
+ /** Matches fast card commands that should not be queued behind a running task. */
118
+ const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|sessionlist(?:\s|$)|workspacelist|watchlist|archived(?:\s+(on|off))?)$/i;
119
+
120
+ /** Canonical workspace/session help advertised by every bridge family. */
121
+ const WORKSPACE_HELP_LINES = [
122
+ '/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
123
+ '/workspacelist 列出工作区绝对路径',
124
+ '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
125
+ ];
125
126
 
126
127
  /** Safe user-facing text for bind/workspace failures (no raw messages). */
127
128
  function safeErrorText(error) {
@@ -170,10 +171,88 @@ function nonEmptyString(value) {
170
171
  return typeof value === 'string' && value.trim() ? value.trim() : null;
171
172
  }
172
173
 
174
+ /** Accept SDK payload fields that may already be objects or JSON strings. */
175
+ function callbackObject(value) {
176
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value;
177
+ if (typeof value !== 'string' || !value.trim()) return {};
178
+ try {
179
+ const parsed = JSON.parse(value);
180
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
181
+ } catch {
182
+ return {};
183
+ }
184
+ }
185
+
186
+ /** Normalize a single-select value without corrupting valid commas in an id/path. */
187
+ function callbackSingleOption(value) {
188
+ if (value === null || value === undefined) return null;
189
+ if (Array.isArray(value)) {
190
+ for (const entry of value) {
191
+ const selected = callbackSingleOption(entry);
192
+ if (selected !== null) return selected;
193
+ }
194
+ return null;
195
+ }
196
+ if (value && typeof value === 'object') {
197
+ if ('value' in value) return callbackSingleOption(value.value);
198
+ if ('option' in value) return callbackSingleOption(value.option);
199
+ return null;
200
+ }
201
+ if (typeof value !== 'string') return null;
202
+ const source = value.trim();
203
+ if (!source) return null;
204
+ if (source.startsWith('[') || source.startsWith('{') || source.startsWith('"')) {
205
+ try {
206
+ const parsed = JSON.parse(source);
207
+ if (parsed !== value) return callbackSingleOption(parsed);
208
+ } catch { /* plain value below */ }
209
+ }
210
+ return source;
211
+ }
212
+
213
+ /** Normalize multi-select values across current and legacy SDK shapes. */
214
+ function callbackMultiOptionValues(value) {
215
+ if (value === null || value === undefined) return [];
216
+ if (Array.isArray(value)) return value.flatMap((entry) => callbackMultiOptionValues(entry));
217
+ if (value && typeof value === 'object') {
218
+ if ('value' in value) return callbackMultiOptionValues(value.value);
219
+ if ('option' in value) return callbackMultiOptionValues(value.option);
220
+ return [];
221
+ }
222
+ if (typeof value !== 'string') return [];
223
+ const source = value.trim();
224
+ if (!source) return [];
225
+ if (source.startsWith('[') || source.startsWith('{') || source.startsWith('"')) {
226
+ try {
227
+ const parsed = JSON.parse(source);
228
+ if (parsed !== value) return callbackMultiOptionValues(parsed);
229
+ } catch { /* plain value below */ }
230
+ }
231
+ return source.split(',').map((entry) => entry.trim()).filter(Boolean);
232
+ }
233
+
234
+ function validLastSeq(value) {
235
+ return Number.isSafeInteger(value) && value >= -1;
236
+ }
237
+
238
+ function validEventSeq(value) {
239
+ return Number.isSafeInteger(value) && value >= 0;
240
+ }
241
+
242
+ function watchBoundary(entry) {
243
+ return Number.isSafeInteger(entry?.watchStartedAt) && entry.watchStartedAt >= 0
244
+ ? entry.watchStartedAt
245
+ : null;
246
+ }
247
+
248
+ function watchNeedsBaseline(entry) {
249
+ return entry && (watchBoundary(entry) !== null || !validLastSeq(entry.lastSeq));
250
+ }
251
+
173
252
  function orderedHistoryEvents(history) {
174
253
  return (Array.isArray(history?.events) ? history.events : [])
175
254
  .map((entry) => entry?.event ?? entry)
176
- .filter((entry) => entry && typeof entry === 'object' && Number.isFinite(entry.seq))
255
+ .filter((entry) => entry && typeof entry === 'object' && validEventSeq(entry.seq))
177
256
  .sort((left, right) => left.seq - right.seq);
178
257
  }
179
258
 
@@ -293,6 +372,26 @@ export class FeishuHarnessBridge {
293
372
  #acceptedMessageIds = new Set();
294
373
  #interactionTasks = new Set();
295
374
  #commandTasks = new Set();
375
+ /** All accepted card work, including tasks waiting behind an earlier click. */
376
+ #cardActionTasks = new Set();
377
+ /** Per-conversation navigation/configuration serialization tails. */
378
+ #cardActionTails = new Map();
379
+ /** Per-conversation serialization for actual stop/steer side effects. */
380
+ #cardControlTails = new Map();
381
+ /** Pending callback count per conversation, used for bounded backpressure. */
382
+ #cardActionCounts = new Map();
383
+ /** Pending control count per conversation, isolated from ordinary UI work. */
384
+ #cardControlCounts = new Map();
385
+ /** Same callback retry joins the original task instead of repeating side effects. */
386
+ #cardActionInFlight = new Map();
387
+ /** Stop is idempotent per conversation; coalesce floods while one stop is pending. */
388
+ #cardStopInFlight = new Map();
389
+ /** Stable ids coalesced into a stop move to the completed cache when it settles. */
390
+ #cardStopFollowers = new Map();
391
+ /** Settled provider event ids stay deduplicated for a bounded retry window. */
392
+ #completedCardActions = new Map();
393
+ /** Last overload notice time per conversation. */
394
+ #cardOverloadNoticeAt = new Map();
296
395
  #approvals;
297
396
  #status;
298
397
  #allowedSenderOpenIds;
@@ -315,10 +414,15 @@ export class FeishuHarnessBridge {
315
414
  #cardKeys = new Map();
316
415
  /** The global event-mux watcher (one per bridge). */
317
416
  #eventWatcher = null;
318
- /** Serializes live completions and reconnect compensation. */
319
- #eventTail = Promise.resolve();
417
+ /** Serializes completion work per session without blocking unrelated sessions. */
418
+ #eventTails = new Map();
419
+ /** Coalesces baseline compensation and records whether a trailing pass is needed. */
420
+ #pendingCompensations = new Map();
421
+ /** Bounded live arrivals bridge target-list, persistence, and history-projection races. */
422
+ #observedCompletionEvents = new Map();
320
423
  /** Earliest completion that still needs delivery for each watch. */
321
424
  #failedWatchSeqs = new Map();
425
+ #cardDataTimeoutMs;
322
426
 
323
427
  constructor({
324
428
  client,
@@ -335,6 +439,7 @@ export class FeishuHarnessBridge {
335
439
  repairOwnerOpenIds,
336
440
  repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
337
441
  repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
442
+ cardDataTimeoutMs = CARD_DATA_TIMEOUT_MS,
338
443
  replyTimeoutMs = 600_000,
339
444
  logger = console,
340
445
  signal,
@@ -353,8 +458,9 @@ export class FeishuHarnessBridge {
353
458
  }
354
459
  }
355
460
  if (!Number.isFinite(repairPollIntervalMs) || repairPollIntervalMs <= 0
356
- || !Number.isFinite(repairLinkWaitMs) || repairLinkWaitMs <= 0) {
357
- throw new TypeError('Feishu repair timing values must be positive numbers');
461
+ || !Number.isFinite(repairLinkWaitMs) || repairLinkWaitMs <= 0
462
+ || !Number.isFinite(cardDataTimeoutMs) || cardDataTimeoutMs <= 0) {
463
+ throw new TypeError('Feishu timing values must be positive numbers');
358
464
  }
359
465
  this.#client = client;
360
466
  this.#channel = channel;
@@ -373,6 +479,7 @@ export class FeishuHarnessBridge {
373
479
  );
374
480
  this.#repairPollIntervalMs = repairPollIntervalMs;
375
481
  this.#repairLinkWaitMs = repairLinkWaitMs;
482
+ this.#cardDataTimeoutMs = cardDataTimeoutMs;
376
483
  this.#replyTimeoutMs = replyTimeoutMs;
377
484
  this.#logger = logger;
378
485
  this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
@@ -435,6 +542,21 @@ export class FeishuHarnessBridge {
435
542
  const processingReaction = this.#addReaction(messageId, 'OnIt');
436
543
  const commandMessage = extractInboundMessage(event, this.#client);
437
544
  const commandText = nonEmptyString(commandMessage.content) ?? '';
545
+ // Card commands (/m, /help, /status, etc.) bypass the queue so they
546
+ // respond immediately even when a harness task is still streaming.
547
+ if (CARD_COMMAND.test(commandText)) {
548
+ const processing = Promise.resolve()
549
+ .then(() => this.#handle(event, key, { alreadyRecorded: false }))
550
+ .then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
551
+ .catch((error) => this.#handleMessageFailure(
552
+ event,
553
+ messageId,
554
+ processingReaction,
555
+ error,
556
+ ))
557
+ .finally(() => this.#acceptedMessageIds.delete(messageId));
558
+ return processing;
559
+ }
438
560
  const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
439
561
  ? runControlCommand
440
562
  : (isModelCommand(commandText)
@@ -621,15 +743,39 @@ export class FeishuHarnessBridge {
621
743
  }
622
744
 
623
745
  async waitForIdle() {
624
- await Promise.allSettled([
625
- ...this.#queues.values(),
626
- ...[...this.#pendingInteractions.values()].flatMap((pending) => (
627
- pending.queue ? [pending.queue] : []
628
- )),
629
- ...this.#interactionTasks,
630
- ...this.#commandTasks,
631
- this.#eventTail,
632
- ]);
746
+ // Drain to a fixed point: awaited work can register compensation or
747
+ // another serialized tail before it settles.
748
+ for (;;) {
749
+ const tasks = [
750
+ ...this.#queues.values(),
751
+ ...[...this.#pendingInteractions.values()].flatMap((pending) => (
752
+ pending.queue ? [pending.queue] : []
753
+ )),
754
+ ...this.#interactionTasks,
755
+ ...this.#commandTasks,
756
+ ...this.#cardActionTasks,
757
+ ...this.#eventTails.values(),
758
+ ...[...this.#pendingCompensations.values()].map((pending) => pending.promise),
759
+ ];
760
+ if (tasks.length === 0) return;
761
+ await Promise.allSettled(tasks);
762
+ if (this.#queues.size === 0
763
+ && this.#interactionTasks.size === 0
764
+ && this.#commandTasks.size === 0
765
+ && this.#cardActionTasks.size === 0
766
+ && this.#eventTails.size === 0
767
+ && this.#pendingCompensations.size === 0
768
+ && ![...this.#pendingInteractions.values()].some((pending) => pending.queue)) return;
769
+ }
770
+ }
771
+
772
+ #cardDataSignal() {
773
+ const timeout = AbortSignal.timeout(this.#cardDataTimeoutMs);
774
+ return this.#signal ? AbortSignal.any([this.#signal, timeout]) : timeout;
775
+ }
776
+
777
+ #hasPendingInteraction(key) {
778
+ return this.#pendingInteractions.has(key) || this.#approvals.hasPending(key);
633
779
  }
634
780
 
635
781
  async #processFastCommand(event, messageId, key, message, runner) {
@@ -647,8 +793,7 @@ export class FeishuHarnessBridge {
647
793
  signal: this.#signal,
648
794
  hasImages: hasInboundImages(message),
649
795
  hasFiles: hasInboundFiles(message),
650
- pendingInteraction: this.#pendingInteractions.has(key)
651
- || this.#approvals.hasPending(key),
796
+ pendingInteraction: this.#hasPendingInteraction(key),
652
797
  control: { owner: this, key },
653
798
  },
654
799
  );
@@ -689,22 +834,35 @@ export class FeishuHarnessBridge {
689
834
  return;
690
835
  }
691
836
  if (commandText === '/help') {
692
- await this.#send(event.message.chat_id, helpText());
837
+ await this.#send(event.message.chat_id, menuHelpText());
693
838
  return;
694
839
  }
695
840
  if (MENU_COMMAND.test(commandText)) {
696
- this.#rememberMenu(key, { kind: 'menu', chatId: event.message.chat_id });
697
- await this.#sendCard(event.message.chat_id, menuCard(), { key });
841
+ await this.#sendMenuCard(key, event.message.chat_id);
698
842
  return;
699
843
  }
700
844
  if (commandText === '/new') {
845
+ if (this.#queues.has(key) || this.#hasPendingInteraction(key)) {
846
+ await this.#send(
847
+ event.message.chat_id,
848
+ t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'),
849
+ );
850
+ return;
851
+ }
701
852
  await this.#state.clearSession(key);
702
853
  await this.#send(event.message.chat_id, t('已开启全新 Harness 会话。'));
854
+ await this.#sendMenuCard(key, event.message.chat_id);
703
855
  return;
704
856
  }
705
857
  if (commandText === '/status') {
706
- await this.#harness.ensureRunning({ signal: this.#signal });
707
- await this.#send(event.message.chat_id, t('飞书机器人与 DeepSeek Harness 连接正常。'));
858
+ await this.#showStatusText(key, event.message.chat_id);
859
+ return;
860
+ }
861
+ if (commandText === '/compact') {
862
+ const compactCommand = await runCompactCommand(commandText, this.#harness, this.#state, key, { signal: this.#signal });
863
+ if (compactCommand) {
864
+ await this.#send(event.message.chat_id, compactCommand.message);
865
+ }
708
866
  return;
709
867
  }
710
868
  if (SESSION_LIST_PREFIX.test(commandText)) {
@@ -1124,77 +1282,477 @@ export class FeishuHarnessBridge {
1124
1282
  // Keep accepting the legacy nested shape while preferring the current
1125
1283
  // card.action.trigger v2 payload used by the official SDK.
1126
1284
  ?? nonEmptyString(event?.operator?.operator_id?.open_id)
1127
- ?? nonEmptyString(event?.operator?.operator_id?.user_id);
1285
+ ?? nonEmptyString(event?.operator?.operator_id?.user_id)
1286
+ ?? nonEmptyString(event?.open_id)
1287
+ ?? nonEmptyString(event?.user_id);
1128
1288
  const operatorAllowed = operatorOpenId !== null
1129
1289
  && (this.#allowedSenderOpenIds.has('*') || this.#allowedSenderOpenIds.has(operatorOpenId));
1130
1290
  if (!operatorAllowed) {
1131
1291
  this.#logger.warn?.('[dsh-feishu] ignoring card action from an unallowed sender');
1132
1292
  return Promise.resolve();
1133
1293
  }
1134
- const action = typeof event?.action?.value?.action === 'string'
1135
- ? event.action.value.action
1136
- : null;
1294
+ const actionValue = callbackObject(event?.action?.value);
1295
+ const formValue = callbackObject(event?.action?.form_value);
1296
+ const action = nonEmptyString(actionValue.action)
1297
+ ?? nonEmptyString(event?.action?.action);
1137
1298
  if (!action) return Promise.resolve();
1138
- const messageId = nonEmptyString(event?.context?.open_message_id);
1139
- const entry = messageId ? this.#cardKeys.get(messageId) : null;
1140
- if (!entry) {
1299
+ // select_static dropdown: resolve pickers to their target actions
1300
+ const option = callbackSingleOption(event?.action?.option)
1301
+ ?? (action.endsWith('_pick') ? callbackMultiOptionValues(event?.action?.options)[0] : null);
1302
+ // The official Card 2.0 callback currently uses a comma-separated string
1303
+ // for multi-select values; older SDKs emitted arrays or value objects.
1304
+ const multiValues = [...new Set([
1305
+ ...callbackMultiOptionValues(actionValue.options),
1306
+ ...callbackMultiOptionValues(event?.action?.options),
1307
+ ...callbackMultiOptionValues(formValue[action]),
1308
+ ...(action === 'watch_add' || action === 'watch_remove'
1309
+ ? callbackMultiOptionValues(event?.action?.option)
1310
+ : []),
1311
+ ])];
1312
+ const resolvedAction = action === 'workspace_pick' && typeof option === 'string'
1313
+ ? `workspace:${option}`
1314
+ : action === 'session_pick' && typeof option === 'string'
1315
+ ? `use:${option}`
1316
+ : action === 'preset_pick' && typeof option === 'string'
1317
+ ? `preset:select:${option}`
1318
+ : action === 'model_pick' && typeof option === 'string'
1319
+ ? `model:select:${option}`
1320
+ : action === 'archive_pick' && typeof option === 'string'
1321
+ ? `archive:${option}`
1322
+ : action === 'steer_pick' && typeof option === 'string'
1323
+ ? `steer:${option}`
1324
+ : action;
1325
+ const messageId = nonEmptyString(event?.context?.open_message_id)
1326
+ ?? nonEmptyString(event?.open_message_id)
1327
+ ?? nonEmptyString(event?.message_id);
1328
+ const route = messageId ? this.#cardKeys.get(messageId) : null;
1329
+ if (!route) {
1141
1330
  // The card predates this process (the in-memory mapping resets on
1142
1331
  // restart) or never came from us: nudge instead of staying silent.
1143
- const chatId = nonEmptyString(event?.context?.open_chat_id);
1332
+ const chatId = nonEmptyString(event?.context?.open_chat_id)
1333
+ ?? nonEmptyString(event?.open_chat_id)
1334
+ ?? nonEmptyString(event?.chat_id);
1144
1335
  if (chatId) {
1145
1336
  this.#send(chatId, t('这个菜单已过期,请回复 /m 重新打开。')).catch(() => undefined);
1146
1337
  }
1147
1338
  return Promise.resolve();
1148
1339
  }
1149
- // The promise is returned so tests (and future callers) can await the
1150
- // action; the runtime dispatcher ignores it.
1151
- return this.#handleCardAction(action, entry).catch((error) => {
1152
- this.#logger.warn?.('[dsh-feishu] card action failed:', error.message);
1340
+ // A used card is recent even if it was first created long ago.
1341
+ this.#cardKeys.delete(messageId);
1342
+ this.#cardKeys.set(messageId, route);
1343
+ const entry = { ...route, messageId, selections: multiValues, operatorOpenId };
1344
+ const source = nonEmptyString(actionValue.source);
1345
+ const formText = nonEmptyString(formValue.steer_text);
1346
+ const eventId = nonEmptyString(event?.event_id)
1347
+ ?? nonEmptyString(event?.header?.event_id)
1348
+ ?? nonEmptyString(event?.uuid)
1349
+ ?? nonEmptyString(event?.header?.uuid);
1350
+ const identity = JSON.stringify({
1351
+ messageId,
1352
+ resolvedAction,
1353
+ option,
1354
+ multiValues,
1355
+ source,
1356
+ formText,
1357
+ });
1358
+ const isStop = resolvedAction === 'stop';
1359
+ const rawSteer = resolvedAction.startsWith('steer:')
1360
+ ? resolvedAction.slice('steer:'.length)
1361
+ : null;
1362
+ const isCustomSteer = rawSteer === 'custom' || rawSteer === STEER_CUSTOM_SENTINEL;
1363
+ const isRealSteer = (action === 'steer'
1364
+ && source === 'quick'
1365
+ && option !== null
1366
+ && option !== STEER_CUSTOM_SENTINEL)
1367
+ || (action === 'steer' && source === 'form' && formText !== null)
1368
+ || (rawSteer !== null && rawSteer !== '' && !isCustomSteer);
1369
+
1370
+ return this.#queueCardAction(entry, identity, async () => {
1371
+ // 补充指令卡片:快捷下拉(source=quick, option=指令) / 表单提交(source=form)
1372
+ if (action === 'steer') {
1373
+ if (source === 'quick' && option) {
1374
+ if (option === STEER_CUSTOM_SENTINEL) {
1375
+ await this.#sendCard(entry.chatId, customSteerCard(), {
1376
+ key: entry.key,
1377
+ updateMessageId: entry.messageId,
1378
+ });
1379
+ return;
1380
+ }
1381
+ await this.#sendSteer(entry, option);
1382
+ return;
1383
+ }
1384
+ if (source === 'form') {
1385
+ if (formText) {
1386
+ await this.#sendSteer(entry, formText);
1387
+ return;
1388
+ }
1389
+ await this.#send(entry.chatId, t('请输入补充指令后再提交。'));
1390
+ return;
1391
+ }
1392
+ }
1393
+ await this.#handleCardAction(resolvedAction, entry);
1394
+ }, {
1395
+ lane: isStop || isRealSteer ? 'control' : 'regular',
1396
+ coalesceStop: isStop,
1397
+ eventId,
1398
+ operatorOpenId,
1153
1399
  });
1154
1400
  }
1155
1401
 
1156
- async #handleCardAction(action, { chatId, key, sessionWorkspace = null }) {
1402
+ #pruneCompletedCardActions(now = Date.now()) {
1403
+ for (const [key, expiresAt] of this.#completedCardActions) {
1404
+ if (expiresAt <= now) this.#completedCardActions.delete(key);
1405
+ }
1406
+ while (this.#completedCardActions.size > MAX_COMPLETED_CARD_ACTIONS) {
1407
+ const oldest = this.#completedCardActions.keys().next().value;
1408
+ if (oldest === undefined) break;
1409
+ this.#completedCardActions.delete(oldest);
1410
+ }
1411
+ }
1412
+
1413
+ #rememberCompletedCardAction(key, now = Date.now()) {
1414
+ if (!key) return;
1415
+ this.#completedCardActions.delete(key);
1416
+ this.#completedCardActions.set(key, now + CARD_ACTION_DEDUPE_TTL_MS);
1417
+ this.#pruneCompletedCardActions(now);
1418
+ }
1419
+
1420
+ #notifyCardOverflow(entry) {
1421
+ const conversation = entry.key;
1422
+ const now = Date.now();
1423
+ const existing = this.#cardOverloadNoticeAt.get(conversation);
1424
+ if (existing?.task || (existing && now - existing.at < CARD_OVERLOAD_NOTICE_COOLDOWN_MS)) {
1425
+ return existing?.task ?? Promise.resolve();
1426
+ }
1427
+ this.#logger.warn?.('[dsh-feishu] card action queue is full; dropping callbacks');
1428
+ let tracked;
1429
+ tracked = this.#send(entry.chatId, t('操作过于频繁,请稍后再试。'))
1430
+ .catch(() => undefined)
1431
+ .finally(() => {
1432
+ this.#cardActionTasks.delete(tracked);
1433
+ const current = this.#cardOverloadNoticeAt.get(conversation);
1434
+ if (current?.task === tracked) this.#cardOverloadNoticeAt.set(conversation, { at: current.at, task: null });
1435
+ });
1436
+ this.#cardOverloadNoticeAt.delete(conversation);
1437
+ this.#cardOverloadNoticeAt.set(conversation, { at: now, task: tracked });
1438
+ this.#cardActionTasks.add(tracked);
1439
+ while (this.#cardOverloadNoticeAt.size > 200) {
1440
+ const oldest = this.#cardOverloadNoticeAt.keys().next().value;
1441
+ if (oldest === undefined) break;
1442
+ this.#cardOverloadNoticeAt.delete(oldest);
1443
+ }
1444
+ return tracked;
1445
+ }
1446
+
1447
+ #queueCardAction(entry, identity, task, {
1448
+ lane = 'regular',
1449
+ coalesceStop = false,
1450
+ eventId = null,
1451
+ operatorOpenId = null,
1452
+ } = {}) {
1453
+ const conversation = entry.key;
1454
+ const completedKey = eventId
1455
+ ? `${conversation}\0${operatorOpenId ?? ''}\0event:${eventId}`
1456
+ : null;
1457
+ const dedupeKey = completedKey
1458
+ ?? `${conversation}\0${operatorOpenId ?? ''}\0action:${identity}`;
1459
+ const now = Date.now();
1460
+ this.#pruneCompletedCardActions(now);
1461
+ if (completedKey && (this.#completedCardActions.get(completedKey) ?? 0) > now) {
1462
+ return Promise.resolve();
1463
+ }
1464
+ const duplicate = this.#cardActionInFlight.get(dedupeKey);
1465
+ if (duplicate) return duplicate;
1466
+ if (coalesceStop) {
1467
+ const pendingStop = this.#cardStopInFlight.get(conversation);
1468
+ if (pendingStop) {
1469
+ if (completedKey) {
1470
+ // Remember only a bounded LRU of provider ids while the shared stop
1471
+ // is unresolved. They become completed only after that stop settles.
1472
+ this.#cardStopFollowers.delete(completedKey);
1473
+ this.#cardStopFollowers.set(completedKey, pendingStop);
1474
+ while (this.#cardStopFollowers.size > MAX_COMPLETED_CARD_ACTIONS) {
1475
+ const oldest = this.#cardStopFollowers.keys().next().value;
1476
+ if (oldest === undefined) break;
1477
+ this.#cardStopFollowers.delete(oldest);
1478
+ }
1479
+ }
1480
+ return pendingStop;
1481
+ }
1482
+ }
1483
+
1484
+ const control = lane === 'control';
1485
+ const tails = control ? this.#cardControlTails : this.#cardActionTails;
1486
+ const counts = control ? this.#cardControlCounts : this.#cardActionCounts;
1487
+ const limit = control ? MAX_PENDING_CARD_CONTROLS_PER_KEY : MAX_PENDING_CARD_ACTIONS_PER_KEY;
1488
+ const pending = counts.get(conversation) ?? 0;
1489
+ // Reserve one bounded control slot for stop. All further stop clicks join
1490
+ // that task, so a flood cannot grow the queue beyond limit + 1.
1491
+ const pendingLimit = coalesceStop ? limit + 1 : limit;
1492
+ if (pending >= pendingLimit) {
1493
+ return this.#notifyCardOverflow(entry);
1494
+ }
1495
+
1496
+ const previous = tails.get(conversation) ?? Promise.resolve();
1497
+ counts.set(conversation, pending + 1);
1498
+
1499
+ let tracked;
1500
+ let started = false;
1501
+ tracked = previous
1502
+ .catch(() => undefined)
1503
+ .then(async () => {
1504
+ this.#signal?.throwIfAborted();
1505
+ started = true;
1506
+ await task();
1507
+ })
1508
+ .catch(async (error) => {
1509
+ if (this.#signal?.aborted) return;
1510
+ this.#logger.warn?.('[dsh-feishu] card action failed:', error?.message ?? String(error));
1511
+ this.#status.lastError = error?.message ?? String(error);
1512
+ await this.#send(entry.chatId, t('卡片操作失败,请稍后重试。')).catch(() => undefined);
1513
+ })
1514
+ .finally(() => {
1515
+ if (this.#cardActionInFlight.get(dedupeKey) === tracked) {
1516
+ this.#cardActionInFlight.delete(dedupeKey);
1517
+ }
1518
+ if (started && completedKey) {
1519
+ this.#rememberCompletedCardAction(completedKey);
1520
+ }
1521
+ if (coalesceStop && this.#cardStopInFlight.get(conversation) === tracked) {
1522
+ this.#cardStopInFlight.delete(conversation);
1523
+ }
1524
+ if (coalesceStop) {
1525
+ const settledAt = Date.now();
1526
+ for (const [followerKey, pendingStop] of this.#cardStopFollowers) {
1527
+ if (pendingStop !== tracked) continue;
1528
+ this.#cardStopFollowers.delete(followerKey);
1529
+ this.#rememberCompletedCardAction(followerKey, settledAt);
1530
+ }
1531
+ }
1532
+ if (tails.get(conversation) === tracked) tails.delete(conversation);
1533
+ const remaining = (counts.get(conversation) ?? 1) - 1;
1534
+ if (remaining > 0) counts.set(conversation, remaining);
1535
+ else counts.delete(conversation);
1536
+ this.#cardActionTasks.delete(tracked);
1537
+ });
1538
+ this.#cardActionInFlight.set(dedupeKey, tracked);
1539
+ if (coalesceStop) this.#cardStopInFlight.set(conversation, tracked);
1540
+ this.#cardActionTasks.add(tracked);
1541
+ tails.set(conversation, tracked);
1542
+ return tracked;
1543
+ }
1544
+
1545
+ async #handleCardAction(action, {
1546
+ chatId,
1547
+ key,
1548
+ messageId = null,
1549
+ sessionWorkspace = null,
1550
+ sessionPage = 0,
1551
+ selections = [],
1552
+ }) {
1157
1553
  if (action === 'sessions' || /^sessions:\d+$/.test(action)) {
1158
1554
  const page = action === 'sessions' ? 0 : Number(action.slice('sessions:'.length));
1159
- await this.#showSessions({ chatId, key }, sessionWorkspace, page);
1555
+ await this.#showSessions(
1556
+ { chatId, key },
1557
+ sessionWorkspace,
1558
+ page,
1559
+ { updateMessageId: messageId },
1560
+ );
1160
1561
  return;
1161
1562
  }
1162
1563
  if (action === 'workspaces') {
1163
- await this.#showWorkspaces({ chatId, key });
1564
+ await this.#showWorkspaces({ chatId, key }, { updateMessageId: messageId });
1164
1565
  return;
1165
1566
  }
1166
1567
  if (action === 'watchlist') {
1167
- await this.#showWatchList(key, chatId);
1568
+ await this.#showWatchList(key, chatId, { updateMessageId: messageId });
1569
+ return;
1570
+ }
1571
+ // 多选关注下拉:action=watch_add / watch_remove,选中项在 selections 数组
1572
+ if (action === 'watch_add' || action === 'watch_remove') {
1573
+ if (selections.length === 0) {
1574
+ await this.#send(chatId, t('请先选择至少一个会话。'));
1575
+ return;
1576
+ }
1577
+ let changed = 0;
1578
+ let failed = 0;
1579
+ if (action === 'watch_add') {
1580
+ let freshTargets = new Map();
1581
+ try {
1582
+ freshTargets = await this.#freshWatchTargets(sessionWorkspace);
1583
+ } catch (error) {
1584
+ this.#logger.warn?.('[dsh-feishu] batch watch validation failed:', error.message);
1585
+ }
1586
+ for (const sessionId of selections) {
1587
+ const validatedTarget = freshTargets.get(sessionId);
1588
+ if (!validatedTarget) {
1589
+ failed += 1;
1590
+ continue;
1591
+ }
1592
+ const result = await this.#runWatch(key, chatId, sessionId, {
1593
+ notify: false,
1594
+ validatedTarget,
1595
+ });
1596
+ if (result.changed) changed += 1;
1597
+ else if (!result.ok) failed += 1;
1598
+ }
1599
+ } else {
1600
+ for (const sessionId of selections) {
1601
+ const result = await this.#runUnwatch(key, chatId, sessionId, { notify: false });
1602
+ if (result.changed) changed += 1;
1603
+ else if (!result.ok) failed += 1;
1604
+ }
1605
+ }
1606
+ const summary = changed > 0 && failed > 0
1607
+ ? action === 'watch_add'
1608
+ ? t('已批量关注 {count} 个会话,另有 {failed} 个未成功。', { count: changed, failed })
1609
+ : t('已取消关注 {count} 个会话,另有 {failed} 个未成功。', { count: changed, failed })
1610
+ : changed > 0
1611
+ ? action === 'watch_add'
1612
+ ? t('已批量关注 {count} 个会话。', { count: changed })
1613
+ : t('已取消关注 {count} 个会话。', { count: changed })
1614
+ : failed > 0
1615
+ ? t('所选会话均未处理成功,请稍后重试。')
1616
+ : action === 'watch_add'
1617
+ ? t('所选会话已在关注列表中。')
1618
+ : t('所选会话已不在关注列表中。');
1619
+ try {
1620
+ await this.#showWatchList(key, chatId, { updateMessageId: messageId });
1621
+ } catch (error) {
1622
+ this.#logger.warn?.('[dsh-feishu] watch list refresh failed:', error.message);
1623
+ }
1624
+ await this.#send(chatId, summary).catch((error) => {
1625
+ this.#logger.warn?.('[dsh-feishu] watch batch summary failed:', error.message);
1626
+ });
1168
1627
  return;
1169
1628
  }
1170
1629
  if (action === 'new') {
1630
+ if (this.#queues.has(key) || this.#hasPendingInteraction(key)) {
1631
+ await this.#send(chatId, t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'));
1632
+ return;
1633
+ }
1171
1634
  await this.#state.clearSession(key);
1172
1635
  await this.#send(chatId, t('已开启全新 Harness 会话。'));
1636
+ await this.#sendMenuCard(key, chatId, { updateMessageId: messageId });
1637
+ return;
1638
+ }
1639
+ if (action === 'use:current') {
1640
+ const sessionId = this.#state.sessionFor(key);
1641
+ if (typeof sessionId !== 'string' || !sessionId) {
1642
+ await this.#send(chatId, t('当前没有绑定的会话,请先从会话列表选择。'));
1643
+ return;
1644
+ }
1645
+ await this.#send(chatId, t('已就绪,直接发消息即可继续当前会话。'));
1646
+ return;
1647
+ }
1648
+ if (action === 'archive_toggle' || action === 'archive:on' || action === 'archive:off') {
1649
+ const next = action === 'archive:on' ? true : action === 'archive:off' ? false : !(this.#state?.includesArchivedSessions?.() ?? false);
1650
+ await this.#state?.setIncludeArchivedSessions?.(next);
1651
+ await this.#send(
1652
+ chatId,
1653
+ next ? t('已开启:会话列表包含归档会话。') : t('已关闭:会话列表隐藏归档会话。'),
1654
+ );
1655
+ await this.#sendMenuCard(key, chatId, { updateMessageId: messageId });
1656
+ return;
1657
+ }
1658
+ if (action === 'repair') {
1659
+ await this.#send(chatId, t('修复需在私聊中验证接入者身份,请直接发送 /repair 开始。'));
1660
+ return;
1661
+ }
1662
+ if (action === 'compact') {
1663
+ await this.#handleCompact(key, chatId);
1664
+ return;
1665
+ }
1666
+ if (action === 'stop') {
1667
+ await this.#handleStop(key, chatId);
1668
+ return;
1669
+ }
1670
+ if (action === 'steer') {
1671
+ await this.#showSteerCard(key, chatId, { updateMessageId: messageId });
1672
+ return;
1673
+ }
1674
+ // 主菜单「补充指令」下拉:option = steer:<指令> / steer:custom
1675
+ if (action.startsWith('steer:')) {
1676
+ const raw = action.slice('steer:'.length);
1677
+ if (raw === 'custom') {
1678
+ await this.#sendCard(chatId, customSteerCard(), { key, updateMessageId: messageId });
1679
+ return;
1680
+ }
1681
+ await this.#sendSteer({ key, chatId }, raw);
1682
+ return;
1683
+ }
1684
+ if (action === 'presets') {
1685
+ await this.#showPresetCard(key, chatId, { updateMessageId: messageId });
1686
+ return;
1687
+ }
1688
+ if (action === 'models') {
1689
+ await this.#showModelCard(key, chatId, { updateMessageId: messageId });
1173
1690
  return;
1174
1691
  }
1175
1692
  if (action === 'status') {
1176
- await this.#harness.ensureRunning({ signal: this.#signal });
1177
- await this.#send(chatId, t('飞书机器人与 DeepSeek Harness 连接正常。'));
1693
+ await this.#showStatusCard(key, chatId, { updateMessageId: messageId });
1178
1694
  return;
1179
1695
  }
1180
1696
  if (action === 'help') {
1181
- await this.#send(chatId, menuHelpText());
1697
+ await this.#showHelpCard(key, chatId, { updateMessageId: messageId });
1698
+ return;
1699
+ }
1700
+ if (action === 'back_to_menu') {
1701
+ await this.#sendMenuCard(key, chatId, { updateMessageId: messageId });
1702
+ return;
1703
+ }
1704
+ if (action === 'preset_default') {
1705
+ await this.#handlePresetDefault(key, chatId, { updateMessageId: messageId });
1706
+ return;
1707
+ }
1708
+ if (action.startsWith('preset:select:')) {
1709
+ const presetId = action.slice('preset:select:'.length);
1710
+ // 哨兵值 = 用户在预设下拉里选了「跟随默认」
1711
+ if (presetId === PRESET_FOLLOW_DEFAULT_SENTINEL) {
1712
+ await this.#handlePresetDefault(key, chatId, { updateMessageId: messageId });
1713
+ return;
1714
+ }
1715
+ await this.#handlePresetSelect(key, chatId, presetId, { updateMessageId: messageId });
1716
+ return;
1717
+ }
1718
+ if (action.startsWith('model:select:')) {
1719
+ const modelId = action.slice('model:select:'.length);
1720
+ await this.#handleModelSelect(key, chatId, modelId, { updateMessageId: messageId });
1182
1721
  return;
1183
1722
  }
1184
1723
  if (action.startsWith('use:')) {
1185
- await this.#bindSession(key, chatId, action.slice('use:'.length));
1724
+ await this.#bindSession(key, chatId, action.slice('use:'.length), { updateMessageId: messageId });
1186
1725
  return;
1187
1726
  }
1188
1727
  if (action.startsWith('workspace:')) {
1189
- await this.#switchWorkspace(key, chatId, action.slice('workspace:'.length));
1728
+ await this.#switchWorkspace(key, chatId, action.slice('workspace:'.length), { updateMessageId: messageId });
1190
1729
  return;
1191
1730
  }
1192
1731
  if (action.startsWith('unwatch:')) {
1193
- await this.#runUnwatch(key, chatId, action.slice('unwatch:'.length));
1732
+ const result = await this.#runUnwatch(key, chatId, action.slice('unwatch:'.length));
1733
+ if (result.ok && messageId) {
1734
+ await this.#showSessions(
1735
+ { chatId, key },
1736
+ sessionWorkspace,
1737
+ sessionPage,
1738
+ { updateMessageId: messageId },
1739
+ );
1740
+ }
1194
1741
  return;
1195
1742
  }
1196
1743
  if (action.startsWith('watch:')) {
1197
- await this.#runWatch(key, chatId, action.slice('watch:'.length));
1744
+ const sessionId = action.slice('watch:'.length);
1745
+ const result = await this.#runWatch(key, chatId, sessionId, {
1746
+ workspaceHint: sessionWorkspace,
1747
+ });
1748
+ if (result.ok && messageId) {
1749
+ await this.#showSessions(
1750
+ { chatId, key },
1751
+ sessionWorkspace,
1752
+ sessionPage,
1753
+ { updateMessageId: messageId },
1754
+ );
1755
+ }
1198
1756
  }
1199
1757
  }
1200
1758
 
@@ -1219,7 +1777,10 @@ export class FeishuHarnessBridge {
1219
1777
 
1220
1778
  async #handleMenuPick(menu, number, { chatId, key, event }) {
1221
1779
  if (menu.kind === 'menu') {
1222
- const action = ['sessions', 'workspaces', 'new', 'status', 'help', 'repair', 'watchlist'][number - 1];
1780
+ // Number fallback for the total menu:
1781
+ // 1=工作区列表 2=新会话 3=会话列表 4=状态 5=修复 6=帮助
1782
+ const actions = ['workspaces', 'new', 'sessions', 'status', 'repair', 'help'];
1783
+ const action = actions[number - 1];
1223
1784
  if (!action) {
1224
1785
  await this.#send(chatId, t('菜单没有这个编号,回复 /m 重新打开。'));
1225
1786
  return;
@@ -1268,14 +1829,20 @@ export class FeishuHarnessBridge {
1268
1829
  return sessions;
1269
1830
  }
1270
1831
 
1271
- async #showSessions({ chatId, key }, selector, page = 0) {
1832
+ async #showSessions(
1833
+ { chatId, key },
1834
+ selector,
1835
+ page = 0,
1836
+ { updateMessageId = null } = {},
1837
+ ) {
1272
1838
  try {
1273
- const resolved = await resolveSessionListWorkspace(selector ?? '', this.#harness);
1839
+ const signal = this.#cardDataSignal();
1840
+ const resolved = await resolveSessionListWorkspace(selector ?? '', this.#harness, { signal });
1274
1841
  if (resolved.error) {
1275
1842
  await this.#send(chatId, resolved.error);
1276
1843
  return;
1277
1844
  }
1278
- const listed = await this.#harness.listWorkspaceSessions(resolved.workspace);
1845
+ const listed = await this.#harness.listWorkspaceSessions(resolved.workspace, { signal });
1279
1846
  const sessions = this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : []);
1280
1847
  const workspace = listed?.workspace ?? resolved.workspace;
1281
1848
  if (sessions.length === 0) {
@@ -1297,9 +1864,11 @@ export class FeishuHarnessBridge {
1297
1864
  sessionListCard(workspace, sessions, safePage, sessions.length, watchedSet),
1298
1865
  {
1299
1866
  key,
1867
+ updateMessageId,
1300
1868
  // Keep the canonical selector result for later page callbacks. The
1301
1869
  // list response's workspace is display data and is not authoritative.
1302
1870
  sessionWorkspace: resolved.workspace,
1871
+ sessionPage: safePage,
1303
1872
  },
1304
1873
  );
1305
1874
  } catch (error) {
@@ -1308,37 +1877,83 @@ export class FeishuHarnessBridge {
1308
1877
  }
1309
1878
  }
1310
1879
 
1311
- async #showWorkspaces({ chatId, key }) {
1880
+ async #showWorkspaces({ chatId, key }, { updateMessageId = null } = {}) {
1312
1881
  try {
1313
- const { current, paths } = await workspacePathSnapshot(this.#harness);
1882
+ const { current, paths } = await workspacePathSnapshot(
1883
+ this.#harness,
1884
+ { signal: this.#cardDataSignal() },
1885
+ );
1314
1886
  this.#rememberMenu(key, { kind: 'workspaces', paths });
1315
- await this.#sendCard(chatId, workspaceListCard(paths, current), { key });
1887
+ await this.#sendCard(
1888
+ chatId,
1889
+ workspaceListCard(paths, current),
1890
+ { key, updateMessageId },
1891
+ );
1316
1892
  } catch (error) {
1317
1893
  this.#logger.warn?.('[dsh-feishu] workspace list failed:', error.message);
1318
1894
  await this.#send(chatId, t('暂时无法获取工作区列表,请稍后重试。'));
1319
1895
  }
1320
1896
  }
1321
1897
 
1322
- async #bindSession(key, chatId, sessionId) {
1898
+ async #bindSession(key, chatId, sessionId, { updateMessageId = null } = {}) {
1323
1899
  try {
1324
1900
  const bound = await this.#harness.bindWorkspaceSession(key, sessionId);
1325
1901
  const title = String(bound?.title ?? '').replace(/\s+/gu, ' ').trim() || t('暂无标题');
1326
1902
  await this.#send(chatId, t('已绑定会话「{title}」\nID:{id}', { title, id: bound?.sessionId ?? sessionId }));
1903
+ await this.#sendMenuCard(key, chatId, { updateMessageId });
1327
1904
  } catch (error) {
1328
1905
  await this.#send(chatId, t('绑定失败:{message}', { message: safeErrorText(error) }));
1329
1906
  }
1330
1907
  }
1331
1908
 
1332
- async #switchWorkspace(key, chatId, workspace) {
1909
+ async #switchWorkspace(key, chatId, workspace, { updateMessageId = null } = {}) {
1333
1910
  try {
1334
1911
  const current = await this.#harness.switchWorkspace(workspace);
1335
1912
  await this.#send(chatId, t('工作区已切换为:{workspace}', { workspace: current }));
1913
+ await this.#sendMenuCard(key, chatId, { updateMessageId });
1336
1914
  } catch (error) {
1337
1915
  await this.#send(chatId, t('切换失败:{message}', { message: safeErrorText(error) }));
1338
1916
  }
1339
1917
  }
1340
1918
 
1919
+ #rememberCardRoute(messageId, chatId, options) {
1920
+ if (!options.key || !messageId) return;
1921
+ this.#cardKeys.delete(messageId);
1922
+ this.#cardKeys.set(messageId, {
1923
+ key: options.key,
1924
+ chatId,
1925
+ sessionWorkspace: typeof options.sessionWorkspace === 'string' && options.sessionWorkspace
1926
+ ? options.sessionWorkspace
1927
+ : null,
1928
+ sessionPage: Number.isSafeInteger(options.sessionPage) && options.sessionPage >= 0
1929
+ ? options.sessionPage
1930
+ : 0,
1931
+ });
1932
+ if (this.#cardKeys.size > 200) {
1933
+ const oldest = this.#cardKeys.keys().next().value;
1934
+ if (oldest !== undefined) this.#cardKeys.delete(oldest);
1935
+ }
1936
+ }
1937
+
1341
1938
  async #sendCard(chatId, cardJson, options = {}) {
1939
+ const updateMessageId = nonEmptyString(options.updateMessageId);
1940
+
1941
+ if (updateMessageId) {
1942
+ try {
1943
+ const response = await this.#client.im.v1.message.patch({
1944
+ path: { message_id: updateMessageId },
1945
+ data: { content: cardJson },
1946
+ });
1947
+ if (response?.code && response.code !== 0) {
1948
+ throw new Error(`Feishu card update failed: ${response.msg || response.code}`);
1949
+ }
1950
+ this.#rememberCardRoute(updateMessageId, chatId, options);
1951
+ return updateMessageId;
1952
+ } catch (error) {
1953
+ this.#logger.warn?.('[dsh-feishu] card update failed:', error?.code ?? error?.message ?? error, 'sending new');
1954
+ }
1955
+ }
1956
+
1342
1957
  const response = await this.#client.im.v1.message.create({
1343
1958
  params: { receive_id_type: 'chat_id' },
1344
1959
  data: { receive_id: chatId, msg_type: 'interactive', content: cardJson },
@@ -1347,20 +1962,420 @@ export class FeishuHarnessBridge {
1347
1962
  throw new Error(`Feishu card send failed: ${response.msg || response.code}`);
1348
1963
  }
1349
1964
  const messageId = nonEmptyString(response?.data?.message_id);
1350
- if (options.key && messageId) {
1351
- this.#cardKeys.set(messageId, {
1352
- key: options.key,
1353
- chatId,
1354
- sessionWorkspace: typeof options.sessionWorkspace === 'string' && options.sessionWorkspace
1355
- ? options.sessionWorkspace
1356
- : null,
1965
+ this.#rememberCardRoute(messageId, chatId, options);
1966
+ return messageId;
1967
+ }
1968
+
1969
+ async #sendMenuCard(key, chatId, { updateMessageId = null } = {}) {
1970
+ let currentSessionId = null;
1971
+ let directSessionTitle = null;
1972
+ try {
1973
+ const sessionId = this.#state.sessionFor(key);
1974
+ if (typeof sessionId === 'string' && sessionId) {
1975
+ currentSessionId = sessionId;
1976
+ const session = this.#harness.workspaceSession?.(sessionId);
1977
+ directSessionTitle = nonEmptyString(session?.title)
1978
+ ?? nonEmptyString(session?.name)
1979
+ ?? nonEmptyString(session?.displayName);
1980
+ }
1981
+ } catch { /* render without a selected session */ }
1982
+
1983
+ const dataSignal = this.#cardDataSignal();
1984
+ // Independent sections start together. Each one degrades on its own so a
1985
+ // slow preset/model RPC cannot force redundant session-list scans.
1986
+ const workspaceTask = workspacePathSnapshot(this.#harness, { signal: dataSignal })
1987
+ .catch(() => {
1988
+ const current = typeof this.#harness.currentWorkspace === 'function'
1989
+ ? this.#harness.currentWorkspace()
1990
+ : null;
1991
+ return { current, paths: current ? [current] : [] };
1357
1992
  });
1358
- if (this.#cardKeys.size > 200) {
1359
- const oldest = this.#cardKeys.keys().next().value;
1360
- if (oldest !== undefined) this.#cardKeys.delete(oldest);
1993
+ const sessionTask = (async () => {
1994
+ const current = typeof this.#harness.currentWorkspace === 'function'
1995
+ ? this.#harness.currentWorkspace()
1996
+ : null;
1997
+ if (!current || typeof this.#harness.listWorkspaceSessions !== 'function') return [];
1998
+ try {
1999
+ const listed = await this.#harness.listWorkspaceSessions(current, { signal: dataSignal });
2000
+ return this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : []);
2001
+ } catch {
2002
+ return [];
2003
+ }
2004
+ })();
2005
+ const presetTask = (async () => {
2006
+ try {
2007
+ const settings = await this.#harness.agentPresetSettings({ signal: dataSignal });
2008
+ return { ...settings.agentPresetCatalog, _currentId: settings.agentPreset };
2009
+ } catch {
2010
+ return null;
2011
+ }
2012
+ })();
2013
+ const modelTask = (async () => {
2014
+ try {
2015
+ if (currentSessionId) {
2016
+ const session = this.#harness.workspaceSession?.(currentSessionId);
2017
+ if (typeof session?.models === 'function') {
2018
+ return await session.models({ signal: dataSignal });
2019
+ }
2020
+ }
2021
+ return await this.#harness.listModels({ signal: dataSignal });
2022
+ } catch {
2023
+ return null;
2024
+ }
2025
+ })();
2026
+
2027
+ const [snapshot, listedSessions, presetCatalog, modelCatalog] = await Promise.all([
2028
+ workspaceTask,
2029
+ sessionTask,
2030
+ presetTask,
2031
+ modelTask,
2032
+ ]);
2033
+ const workspaces = Array.isArray(snapshot.paths) ? snapshot.paths : [];
2034
+ const currentWorkspace = snapshot.current ?? null;
2035
+ const currentMatch = listedSessions.find((session) => session.sessionId === currentSessionId);
2036
+ const currentSessionTitle = currentSessionId
2037
+ ? nonEmptyString(currentMatch?.title)
2038
+ ?? nonEmptyString(currentMatch?.name)
2039
+ ?? directSessionTitle
2040
+ ?? currentSessionId
2041
+ : null;
2042
+ let sessions = listedSessions
2043
+ .map((session) => ({
2044
+ id: session.sessionId,
2045
+ title: session.title ?? session.name ?? session.sessionId,
2046
+ }))
2047
+ .slice(0, 20);
2048
+ // 确保当前绑定会话始终出现在下拉最前(它可能不在最近列表里),
2049
+ // 否则 initial_index 找不到默认展示项,下拉会显示占位文本。
2050
+ if (currentSessionId) {
2051
+ sessions = sessions.filter((s) => s.id !== currentSessionId);
2052
+ sessions.unshift({ id: currentSessionId, title: currentSessionTitle ?? currentSessionId });
2053
+ sessions = sessions.slice(0, 20);
2054
+ }
2055
+ const archiveVisible = this.#state?.includesArchivedSessions?.() ?? false;
2056
+ this.#rememberMenu(key, { kind: 'menu', chatId });
2057
+ await this.#sendCard(
2058
+ chatId,
2059
+ menuCard({
2060
+ workspaces, currentWorkspace,
2061
+ currentSession: currentSessionId ? { id: currentSessionId, title: currentSessionTitle } : null,
2062
+ sessions, archiveVisible, presetCatalog, modelCatalog,
2063
+ }),
2064
+ { key, updateMessageId },
2065
+ );
2066
+ }
2067
+
2068
+ /**
2069
+ * Resolve a human-readable title for a bound session when available.
2070
+ * Attempts the workspace-session object first, then falls back to the
2071
+ * current workspace's session list. Returns null on any failure.
2072
+ */
2073
+ async #resolveSessionTitle(key, sessionId) {
2074
+ try {
2075
+ if (typeof this.#harness.workspaceSession === 'function') {
2076
+ const session = this.#harness.workspaceSession(sessionId);
2077
+ if (session && typeof session === 'object') {
2078
+ const direct = nonEmptyString(session.title)
2079
+ ?? nonEmptyString(session.name)
2080
+ ?? nonEmptyString(session.displayName);
2081
+ if (direct) return direct;
2082
+ }
2083
+ }
2084
+ // Fallback: scan the current workspace session list for this id
2085
+ const current = typeof this.#harness.currentWorkspace === 'function'
2086
+ ? this.#harness.currentWorkspace()
2087
+ : null;
2088
+ if (current && typeof this.#harness.listWorkspaceSessions === 'function') {
2089
+ const listed = await this.#harness.listWorkspaceSessions(current);
2090
+ const match = (Array.isArray(listed?.sessions) ? listed.sessions : [])
2091
+ .find((s) => s.sessionId === sessionId);
2092
+ if (match) {
2093
+ const title = nonEmptyString(match.title) ?? nonEmptyString(match.name);
2094
+ if (title) return title;
2095
+ }
1361
2096
  }
2097
+ } catch { /* best-effort */ }
2098
+ return null;
2099
+ }
2100
+
2101
+ // ── Sub-card handlers: preset, model, status, help, compact ──────────────
2102
+
2103
+ /**
2104
+ * Fetch the preset catalog and show the preset selection card.
2105
+ */
2106
+ async #showPresetCard(key, chatId, { updateMessageId = null } = {}) {
2107
+ try {
2108
+ const settings = await this.#harness.agentPresetSettings({ signal: this.#cardDataSignal() });
2109
+ const catalog = settings.agentPresetCatalog;
2110
+ // Inject the current preset id so the card can render the selection
2111
+ catalog._currentId = settings.agentPreset;
2112
+ await this.#sendCard(chatId, presetCard(catalog), { key, updateMessageId });
2113
+ } catch (error) {
2114
+ this.#logger.warn?.('[dsh-feishu] preset card failed:', error.message);
2115
+ await this.#send(chatId, t('暂时无法获取预设列表,请稍后重试。'));
2116
+ }
2117
+ }
2118
+
2119
+ /**
2120
+ * Fetch the model catalog and show the model selection card.
2121
+ */
2122
+ async #showModelCard(key, chatId, { updateMessageId = null } = {}) {
2123
+ try {
2124
+ const signal = this.#cardDataSignal();
2125
+ await this.#harness.ensureRunning({ signal });
2126
+ // Try to get the session-bound catalog first, fall back to harness-level
2127
+ const sessionId = this.#state?.sessionFor?.(key);
2128
+ let catalog;
2129
+ if (typeof sessionId === 'string' && sessionId) {
2130
+ const session = this.#harness.workspaceSession(sessionId);
2131
+ if (session?.models) {
2132
+ catalog = await session.models({ signal });
2133
+ }
2134
+ }
2135
+ if (!catalog) {
2136
+ catalog = await this.#harness.listModels({ signal });
2137
+ }
2138
+ await this.#sendCard(chatId, modelCard(catalog), { key, updateMessageId });
2139
+ } catch (error) {
2140
+ this.#logger.warn?.('[dsh-feishu] model card failed:', error.message);
2141
+ await this.#send(chatId, t('暂时无法获取模型列表,请稍后重试。'));
2142
+ }
2143
+ }
2144
+
2145
+ /**
2146
+ * Gather system status and show the status card.
2147
+ */
2148
+ async #showStatusText(key, chatId) {
2149
+ try {
2150
+ await this.#harness.ensureRunning({ signal: this.#signal });
2151
+ const lines = [t('连接正常')];
2152
+ const ws = typeof this.#harness.currentWorkspace === 'function'
2153
+ ? this.#harness.currentWorkspace()
2154
+ : null;
2155
+ if (ws) lines.push(t('工作区:{workspace}', { workspace: ws }));
2156
+ const settings = typeof this.#harness.agentPresetSettings === 'function'
2157
+ ? await this.#harness.agentPresetSettings({ signal: this.#signal }).catch(() => null)
2158
+ : null;
2159
+ if (settings) {
2160
+ const item = settings.agentPresetCatalog?.items?.find((i) => i.id === settings.agentPreset);
2161
+ lines.push(t('预设:{preset}', {
2162
+ preset: item
2163
+ ? `${item.label}(${item.id})`
2164
+ : (settings.agentPreset || t('跟随默认')),
2165
+ }));
2166
+ }
2167
+ await this.#send(chatId, lines.join('\n'));
2168
+ } catch (error) {
2169
+ this.#logger.warn?.('[dsh-feishu] status text failed:', error.message);
2170
+ await this.#send(chatId, t('暂时无法获取系统状态,请稍后重试。'));
2171
+ }
2172
+ }
2173
+
2174
+ async #showStatusCard(key, chatId, { updateMessageId = null } = {}) {
2175
+ try {
2176
+ const signal = this.#cardDataSignal();
2177
+ await this.#harness.ensureRunning({ signal });
2178
+ const info = { connected: true, workspace: null, preset: null, model: null, sessionCount: 0 };
2179
+
2180
+ // Current workspace
2181
+ try {
2182
+ const ws = typeof this.#harness.currentWorkspace === 'function'
2183
+ ? this.#harness.currentWorkspace()
2184
+ : null;
2185
+ info.workspace = ws || t('未知');
2186
+ } catch { /* ignore */ }
2187
+
2188
+ // Preset
2189
+ try {
2190
+ const settings = await this.#harness.agentPresetSettings({ signal });
2191
+ const item = settings.agentPresetCatalog.items.find((i) => i.id === settings.agentPreset);
2192
+ info.preset = item
2193
+ ? `${item.label}(${item.id})`
2194
+ : (settings.agentPreset || t('跟随默认'));
2195
+ } catch { /* ignore */ }
2196
+
2197
+ // Model (from bound session or harness)
2198
+ try {
2199
+ const sessionId = this.#state?.sessionFor?.(key);
2200
+ if (typeof sessionId === 'string' && sessionId) {
2201
+ const session = this.#harness.workspaceSession(sessionId);
2202
+ if (session?.models) {
2203
+ const cat = await session.models({ signal });
2204
+ if (cat.current) info.model = `${cat.current.provider}/${cat.current.model}`;
2205
+ }
2206
+ }
2207
+ } catch { /* ignore */ }
2208
+
2209
+ // Session count
2210
+ try {
2211
+ const ws = typeof this.#harness.currentWorkspace === 'function'
2212
+ ? this.#harness.currentWorkspace()
2213
+ : null;
2214
+ if (ws) {
2215
+ const listed = await this.#harness.listWorkspaceSessions(ws, { signal });
2216
+ if (Array.isArray(listed?.sessions)) info.sessionCount = listed.sessions.length;
2217
+ }
2218
+ } catch { /* ignore */ }
2219
+
2220
+ await this.#sendCard(chatId, statusCard(info), { key, updateMessageId });
2221
+ } catch (error) {
2222
+ this.#logger.warn?.('[dsh-feishu] status card failed:', error.message);
2223
+ await this.#send(chatId, t('暂时无法获取系统状态,请稍后重试。'));
2224
+ }
2225
+ }
2226
+
2227
+ /**
2228
+ * Show the help card with all command descriptions.
2229
+ */
2230
+ async #showHelpCard(key, chatId, { updateMessageId = null } = {}) {
2231
+ await this.#sendCard(
2232
+ chatId,
2233
+ helpCard(WORKSPACE_HELP_LINES.map((line) => t(line))),
2234
+ { key, updateMessageId },
2235
+ );
2236
+ }
2237
+
2238
+ /**
2239
+ * Run the /compact command and show the result.
2240
+ */
2241
+ async #handleCompact(key, chatId) {
2242
+ try {
2243
+ const result = await runCompactCommand(
2244
+ '/compact', this.#harness, this.#state, key, { signal: this.#signal },
2245
+ );
2246
+ await this.#send(chatId, result?.message || t('上下文压缩失败。'));
2247
+ } catch (error) {
2248
+ this.#logger.warn?.('[dsh-feishu] compact failed:', error.message, error.code);
2249
+ await this.#send(chatId, t('上下文压缩失败,请稍后重试。'));
2250
+ }
2251
+ }
2252
+
2253
+ /**
2254
+ * Stop the running task in the bound session (mirrors `/stop`).
2255
+ */
2256
+ async #handleStop(key, chatId) {
2257
+ try {
2258
+ const result = await runControlCommand(
2259
+ '/stop', this.#harness, this.#state, key, {
2260
+ signal: this.#signal,
2261
+ control: { owner: this, key },
2262
+ },
2263
+ );
2264
+ if (result?.stopped) {
2265
+ await Promise.allSettled([
2266
+ this.#cancelPendingInteraction(key),
2267
+ this.#approvals.closeRoute(key),
2268
+ ]);
2269
+ }
2270
+ await this.#send(chatId, result?.message || t('/stop 执行完成。'));
2271
+ } catch (error) {
2272
+ this.#logger.warn?.('[dsh-feishu] stop failed:', error.message);
2273
+ await this.#send(chatId, t('停止任务失败,请稍后重试。'));
2274
+ }
2275
+ }
2276
+
2277
+ /**
2278
+ * Show the steer card (quick-select dropdown + free-text input).
2279
+ */
2280
+ async #showSteerCard(key, chatId, { updateMessageId = null } = {}) {
2281
+ const hasSession = Boolean(this.#state.sessionFor?.(key));
2282
+ this.#rememberMenu(key, { kind: 'steer' });
2283
+ await this.#sendCard(chatId, steerCard({ hasSession }), { key, updateMessageId });
2284
+ }
2285
+
2286
+ /**
2287
+ * Send a steer instruction to the bound session (mirrors `/steer <text>`).
2288
+ */
2289
+ async #sendSteer(entry, text) {
2290
+ const { key, chatId } = entry;
2291
+ const result = await runControlCommand(
2292
+ `/steer ${text}`, this.#harness, this.#state, key, {
2293
+ signal: this.#signal,
2294
+ pendingInteraction: this.#hasPendingInteraction(key),
2295
+ control: { owner: this, key },
2296
+ },
2297
+ );
2298
+ await this.#send(chatId, result?.message || t('已提交补充指令。'));
2299
+ }
2300
+
2301
+ /**
2302
+ * Reset the preset to follow the Host default.
2303
+ */
2304
+ async #handlePresetDefault(key, chatId, { updateMessageId = null } = {}) {
2305
+ try {
2306
+ const result = await runPresetCommand(
2307
+ '/preset --default', this.#harness, this.#state, key, { signal: this.#signal },
2308
+ );
2309
+ for (const reply of result?.messages ?? [result?.message]) {
2310
+ if (reply) await this.#send(chatId, reply);
2311
+ }
2312
+ } catch (error) {
2313
+ this.#logger.warn?.('[dsh-feishu] preset default failed:', error.message);
2314
+ await this.#send(chatId, t('预设重置失败,请稍后重试。'));
2315
+ return;
2316
+ }
2317
+ try {
2318
+ await this.#sendMenuCard(key, chatId, { updateMessageId });
2319
+ } catch (error) {
2320
+ this.#logger.warn?.('[dsh-feishu] menu refresh failed after preset reset:', error.message);
2321
+ }
2322
+ }
2323
+
2324
+ /**
2325
+ * Handle preset selection from the preset dropdown.
2326
+ */
2327
+ async #handlePresetSelect(key, chatId, presetId, { updateMessageId = null } = {}) {
2328
+ try {
2329
+ const selector = /^\d+$/u.test(presetId) ? `id:${presetId}` : presetId;
2330
+ const result = await runPresetCommand(
2331
+ `/preset ${selector}`, this.#harness, this.#state, key, { signal: this.#signal },
2332
+ );
2333
+ for (const reply of result?.messages ?? [result?.message]) {
2334
+ if (reply) await this.#send(chatId, reply);
2335
+ }
2336
+ } catch (error) {
2337
+ this.#logger.warn?.('[dsh-feishu] preset select failed:', error.message);
2338
+ await this.#send(chatId, t('预设切换失败,请稍后重试。'));
2339
+ return;
2340
+ }
2341
+ try {
2342
+ await this.#sendMenuCard(key, chatId, { updateMessageId });
2343
+ } catch (error) {
2344
+ this.#logger.warn?.('[dsh-feishu] menu refresh failed after preset select:', error.message);
2345
+ }
2346
+ }
2347
+
2348
+ /**
2349
+ * Handle model selection from the model dropdown.
2350
+ *
2351
+ * Reuses `runModelCommand` (the same path as the `/model <id>` text
2352
+ * command) so model IDs containing `/` (e.g.
2353
+ * `openrouter/anthropic/claude-sonnet-4`) keep working, and all the
2354
+ * catalog validation, busy checks, pending-interaction checks and the
2355
+ * session binding lock stay in one place.
2356
+ */
2357
+ async #handleModelSelect(key, chatId, modelId, { updateMessageId = null } = {}) {
2358
+ try {
2359
+ const result = await runModelCommand(
2360
+ `/model ${modelId}`, this.#harness, this.#state, key, {
2361
+ signal: this.#signal,
2362
+ pendingInteraction: this.#hasPendingInteraction(key),
2363
+ control: { owner: this, key },
2364
+ },
2365
+ );
2366
+ for (const reply of result?.messages ?? [result?.message]) {
2367
+ if (reply) await this.#send(chatId, reply);
2368
+ }
2369
+ } catch (error) {
2370
+ this.#logger.warn?.('[dsh-feishu] model select failed:', error.message);
2371
+ await this.#send(chatId, t('模型切换失败,请稍后重试。'));
2372
+ return;
2373
+ }
2374
+ try {
2375
+ await this.#sendMenuCard(key, chatId, { updateMessageId });
2376
+ } catch (error) {
2377
+ this.#logger.warn?.('[dsh-feishu] menu refresh failed after model select:', error.message);
1362
2378
  }
1363
- return messageId;
1364
2379
  }
1365
2380
 
1366
2381
  // ── Watches: read-only session tracking + completion pushes ─────────────
@@ -1375,7 +2390,7 @@ export class FeishuHarnessBridge {
1375
2390
  signal,
1376
2391
  onSessionEvent: (payload) => this.#onHarnessEvent(payload),
1377
2392
  onReconnect: () => {
1378
- void this.#queueEventTask(() => this.#compensateMissedEvents());
2393
+ void this.#compensateMissedEvents();
1379
2394
  },
1380
2395
  });
1381
2396
  Promise.resolve(this.#eventWatcher).catch((error) => {
@@ -1389,22 +2404,91 @@ export class FeishuHarnessBridge {
1389
2404
  }
1390
2405
  }
1391
2406
 
1392
- #queueEventTask(task) {
1393
- const next = this.#eventTail.then(task, task).catch((error) => {
2407
+ #queueEventTask(sessionId, task) {
2408
+ const previous = this.#eventTails.get(sessionId) ?? Promise.resolve();
2409
+ let next;
2410
+ next = previous.then(task, task).catch((error) => {
1394
2411
  if (!this.#signal?.aborted) {
1395
2412
  this.#logger.warn?.('[dsh-feishu] completion event failed:', error.message);
1396
2413
  }
2414
+ }).finally(() => {
2415
+ if (this.#eventTails.get(sessionId) === next) this.#eventTails.delete(sessionId);
1397
2416
  });
1398
- this.#eventTail = next;
2417
+ this.#eventTails.set(sessionId, next);
1399
2418
  return next;
1400
2419
  }
1401
2420
 
2421
+ #pruneObservedCompletionEvents(now = Date.now()) {
2422
+ let total = 0;
2423
+ for (const [sessionId, observed] of this.#observedCompletionEvents) {
2424
+ for (const [seq, record] of observed) {
2425
+ if (!Number.isSafeInteger(record?.arrivalAt)
2426
+ || record.arrivalAt < 0
2427
+ || record.arrivalAt + COMPLETION_OBSERVATION_TTL_MS <= now) {
2428
+ observed.delete(seq);
2429
+ }
2430
+ }
2431
+ while (observed.size > MAX_OBSERVED_COMPLETIONS_PER_SESSION) {
2432
+ const oldest = observed.keys().next().value;
2433
+ if (oldest === undefined) break;
2434
+ observed.delete(oldest);
2435
+ }
2436
+ if (observed.size === 0) this.#observedCompletionEvents.delete(sessionId);
2437
+ else total += observed.size;
2438
+ }
2439
+ while (this.#observedCompletionEvents.size > MAX_OBSERVED_COMPLETION_SESSIONS) {
2440
+ const oldestSessionId = this.#observedCompletionEvents.keys().next().value;
2441
+ if (oldestSessionId === undefined) break;
2442
+ total -= this.#observedCompletionEvents.get(oldestSessionId)?.size ?? 0;
2443
+ this.#observedCompletionEvents.delete(oldestSessionId);
2444
+ }
2445
+ while (total > MAX_OBSERVED_COMPLETIONS) {
2446
+ const oldestSessionId = this.#observedCompletionEvents.keys().next().value;
2447
+ if (oldestSessionId === undefined) break;
2448
+ const observed = this.#observedCompletionEvents.get(oldestSessionId);
2449
+ const oldestSeq = observed?.keys().next().value;
2450
+ if (oldestSeq === undefined) {
2451
+ this.#observedCompletionEvents.delete(oldestSessionId);
2452
+ continue;
2453
+ }
2454
+ observed.delete(oldestSeq);
2455
+ total -= 1;
2456
+ if (observed.size === 0) this.#observedCompletionEvents.delete(oldestSessionId);
2457
+ }
2458
+ }
2459
+
2460
+ #recordObservedCompletion(sessionId, event, now = Date.now()) {
2461
+ this.#pruneObservedCompletionEvents(now);
2462
+ let observed = this.#observedCompletionEvents.get(sessionId);
2463
+ if (!observed) observed = new Map();
2464
+ const rawReason = event?.data?.reason;
2465
+ const reason = typeof rawReason === 'string'
2466
+ ? rawReason
2467
+ : typeof rawReason?.kind === 'string'
2468
+ ? { kind: rawReason.kind }
2469
+ : null;
2470
+ observed.delete(event.seq);
2471
+ observed.set(event.seq, {
2472
+ arrivalAt: now,
2473
+ event: {
2474
+ type: 'turn/end',
2475
+ seq: event.seq,
2476
+ ...(Number.isSafeInteger(event.time) && event.time >= 0 ? { time: event.time } : {}),
2477
+ data: { reason },
2478
+ },
2479
+ });
2480
+ // Refresh the session as a unit so both session and entry eviction are LRU.
2481
+ this.#observedCompletionEvents.delete(sessionId);
2482
+ this.#observedCompletionEvents.set(sessionId, observed);
2483
+ this.#pruneObservedCompletionEvents(now);
2484
+ }
2485
+
1402
2486
  /**
1403
2487
  * Resolve a /watch target READ-ONLY: a session id is validated against
1404
2488
  * the registered workspaces' listings, an index against the current
1405
2489
  * workspace. Nothing is bound and no workspace is switched.
1406
2490
  */
1407
- async #resolveWatchTarget(target) {
2491
+ async #resolveWatchTarget(target, { workspaceHint = null, signal = this.#signal } = {}) {
1408
2492
  if (typeof target !== 'string' || target === '') {
1409
2493
  return { error: t('用法:/watch <Session ID 或当前工作区序号>') };
1410
2494
  }
@@ -1413,7 +2497,7 @@ export class FeishuHarnessBridge {
1413
2497
  ? this.#harness.currentWorkspace()
1414
2498
  : null;
1415
2499
  const listSessions = async (workspace) => {
1416
- const listed = await this.#harness.listWorkspaceSessions(workspace);
2500
+ const listed = await this.#harness.listWorkspaceSessions(workspace, { signal });
1417
2501
  return Array.isArray(listed?.sessions) ? listed.sessions : [];
1418
2502
  };
1419
2503
  if (numeric !== null) {
@@ -1423,94 +2507,217 @@ export class FeishuHarnessBridge {
1423
2507
  if (!session?.sessionId) {
1424
2508
  return { error: t('当前工作区只有 {count} 个会话。', { count: sessions.length }) };
1425
2509
  }
1426
- return { sessionId: session.sessionId, title: session.title ?? t('暂无标题') };
2510
+ return {
2511
+ sessionId: session.sessionId,
2512
+ title: session.title ?? t('暂无标题'),
2513
+ workspace: currentPath,
2514
+ ...(validLastSeq(session.lastSeq) ? { lastSeq: session.lastSeq } : {}),
2515
+ };
2516
+ }
2517
+ let paths;
2518
+ if (nonEmptyString(workspaceHint)) {
2519
+ paths = [workspaceHint];
2520
+ } else {
2521
+ const extraPaths = typeof this.#harness?.listWorkspaces === 'function'
2522
+ ? (await this.#harness.listWorkspaces({ signal })).filter((path) => path !== currentPath)
2523
+ : [];
2524
+ paths = [currentPath, ...extraPaths].filter(Boolean);
1427
2525
  }
1428
- const extraPaths = typeof this.#harness?.listWorkspaces === 'function'
1429
- ? (await this.#harness.listWorkspaces()).filter((path) => path !== currentPath)
1430
- : [];
1431
- const paths = [currentPath, ...extraPaths].filter(Boolean);
1432
2526
  for (const workspace of paths) {
1433
2527
  const sessions = await listSessions(workspace);
1434
2528
  const session = sessions.find((candidate) => candidate.sessionId === target);
1435
- if (session) return { sessionId: target, title: session.title ?? t('暂无标题') };
2529
+ if (session) {
2530
+ return {
2531
+ sessionId: target,
2532
+ title: session.title ?? t('暂无标题'),
2533
+ workspace,
2534
+ ...(validLastSeq(session.lastSeq) ? { lastSeq: session.lastSeq } : {}),
2535
+ };
2536
+ }
1436
2537
  }
1437
2538
  return { error: t('没有找到这个会话,请用 /sessionlist 查看可用会话。') };
1438
2539
  }
1439
2540
 
1440
- async #latestSessionSeq(sessionId) {
1441
- if (typeof this.#harness?.rpc !== 'function') return null;
1442
- const history = await this.#harness.rpc(
1443
- 'session.history',
1444
- { sessionId, maxMessages: 20 },
1445
- 30_000,
1446
- { signal: this.#signal },
2541
+ async #freshWatchTargets(workspace) {
2542
+ const selectedWorkspace = nonEmptyString(workspace)
2543
+ ?? (typeof this.#harness?.currentWorkspace === 'function'
2544
+ ? nonEmptyString(this.#harness.currentWorkspace())
2545
+ : null);
2546
+ if (!selectedWorkspace || typeof this.#harness?.listWorkspaceSessions !== 'function') {
2547
+ return new Map();
2548
+ }
2549
+ const listed = await this.#harness.listWorkspaceSessions(
2550
+ selectedWorkspace,
2551
+ { signal: this.#cardDataSignal() },
1447
2552
  );
1448
- return orderedHistoryEvents(history).at(-1)?.seq ?? -1;
2553
+ return new Map(this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : [])
2554
+ .filter((session) => nonEmptyString(session?.sessionId))
2555
+ .map((session) => [session.sessionId, {
2556
+ sessionId: session.sessionId,
2557
+ title: session.title ?? session.name ?? t('暂无标题'),
2558
+ workspace: selectedWorkspace,
2559
+ ...(validLastSeq(session.lastSeq) ? { lastSeq: session.lastSeq } : {}),
2560
+ }]));
2561
+ }
2562
+
2563
+ #scheduleCompensation(sessionId) {
2564
+ const pending = this.#pendingCompensations.get(sessionId);
2565
+ if (pending) {
2566
+ pending.requested = true;
2567
+ return pending.promise;
2568
+ }
2569
+ const state = { requested: true, promise: null };
2570
+ this.#pendingCompensations.set(sessionId, state);
2571
+ state.promise = Promise.resolve().then(async () => {
2572
+ try {
2573
+ // A watch can be persisted after an in-progress compensation already
2574
+ // snapshotted its keys. Remember that request and run one trailing pass.
2575
+ do {
2576
+ state.requested = false;
2577
+ await this.#queueEventTask(sessionId, () => this.#compensateSession(sessionId));
2578
+ } while (state.requested && !this.#signal?.aborted);
2579
+ } finally {
2580
+ if (this.#pendingCompensations.get(sessionId) === state) {
2581
+ this.#pendingCompensations.delete(sessionId);
2582
+ }
2583
+ }
2584
+ });
2585
+ return state.promise;
1449
2586
  }
1450
2587
 
1451
- async #runWatch(key, chatId, target) {
2588
+ async #runWatch(key, chatId, target, {
2589
+ notify = true,
2590
+ validatedTarget = null,
2591
+ workspaceHint = null,
2592
+ } = {}) {
2593
+ const watchRequestedAt = Date.now();
2594
+ const reply = async (message) => {
2595
+ if (!notify) return;
2596
+ await this.#send(chatId, message).catch((error) => {
2597
+ this.#logger.warn?.('[dsh-feishu] watch notification failed:', error.message);
2598
+ });
2599
+ };
1452
2600
  this.#ensureEventWatcher();
1453
2601
  if (typeof this.#state?.setWatch !== 'function') {
1454
- await this.#send(chatId, t('当前状态存储不支持关注。'));
1455
- return;
2602
+ await reply(t('当前状态存储不支持关注。'));
2603
+ return { ok: false, changed: false, reason: 'unsupported' };
1456
2604
  }
1457
2605
  let resolved;
1458
2606
  try {
1459
- resolved = await this.#resolveWatchTarget(target);
2607
+ resolved = validatedTarget?.sessionId === target
2608
+ ? validatedTarget
2609
+ : await this.#resolveWatchTarget(target, {
2610
+ workspaceHint,
2611
+ signal: workspaceHint ? this.#cardDataSignal() : this.#signal,
2612
+ });
1460
2613
  } catch (error) {
1461
- await this.#send(chatId, t('无法解析会话:{message}', { message: safeErrorText(error) }));
1462
- return;
2614
+ await reply(t('无法解析会话:{message}', { message: safeErrorText(error) }));
2615
+ return { ok: false, changed: false, reason: 'resolve' };
1463
2616
  }
1464
2617
  if (resolved.error) {
1465
- await this.#send(chatId, resolved.error);
1466
- return;
2618
+ await reply(resolved.error);
2619
+ return { ok: false, changed: false, reason: 'not-found' };
1467
2620
  }
1468
2621
  const existing = this.#state.watchEntries?.(key) ?? [];
1469
2622
  const existingEntry = existing.find((entry) => entry.sessionId === resolved.sessionId);
1470
2623
  if (!existingEntry && existing.length >= MAX_WATCHES_PER_KEY) {
1471
- await this.#send(chatId, t('每个聊天最多关注 {count} 个会话。', { count: MAX_WATCHES_PER_KEY }));
1472
- return;
1473
- }
2624
+ await reply(t('每个聊天最多关注 {count} 个会话。', { count: MAX_WATCHES_PER_KEY }));
2625
+ return { ok: false, changed: false, reason: 'limit' };
2626
+ }
2627
+ const lastSeq = validLastSeq(existingEntry?.lastSeq)
2628
+ ? existingEntry.lastSeq
2629
+ : validLastSeq(resolved.lastSeq)
2630
+ ? resolved.lastSeq
2631
+ : null;
2632
+ // session.list's projection asOfSeq can be stale for a cold session. Every
2633
+ // new watch therefore keeps a durable wall-clock boundary; lastSeq is only
2634
+ // a lower bound for the history scan. Existing settled and legacy entries
2635
+ // keep their prior semantics when /watch is repeated.
2636
+ const existingBoundary = watchBoundary(existingEntry);
2637
+ const watchStartedAt = existingBoundary ?? (existingEntry ? null : watchRequestedAt);
1474
2638
  try {
1475
- const lastSeq = typeof existingEntry?.lastSeq === 'number'
1476
- ? existingEntry.lastSeq
1477
- : await this.#latestSessionSeq(resolved.sessionId);
1478
2639
  await this.#state.setWatch(key, {
1479
2640
  sessionId: resolved.sessionId,
1480
2641
  title: resolved.title,
1481
2642
  chatId,
1482
2643
  lastSeq,
2644
+ ...(watchStartedAt !== null ? { watchStartedAt } : {}),
1483
2645
  });
1484
- await this.#send(chatId, t('已关注会话「{title}」,任务完成会推送结果。', { title: String(resolved.title).replace(/\s+/gu, ' ') }));
1485
- await this.#queueEventTask(() => this.#compensateSession(resolved.sessionId));
1486
2646
  } catch (error) {
1487
- await this.#send(chatId, t('关注失败:{message}', { message: safeErrorText(error) }));
2647
+ await reply(t('关注失败:{message}', { message: safeErrorText(error) }));
2648
+ return { ok: false, changed: false, reason: 'persist' };
1488
2649
  }
2650
+ // Always compensate a newly created or still-unsettled watch. This closes
2651
+ // both target-list and durable-persistence windows.
2652
+ if (!existingEntry || watchStartedAt !== null || !validLastSeq(lastSeq)) {
2653
+ void this.#scheduleCompensation(resolved.sessionId);
2654
+ }
2655
+ await reply(t('已关注会话「{title}」,任务完成会推送结果。', { title: String(resolved.title).replace(/\s+/gu, ' ') }));
2656
+ return { ok: true, changed: !existingEntry, entry: this.#state.watchEntry?.(key, resolved.sessionId) };
1489
2657
  }
1490
2658
 
1491
- async #runUnwatch(key, chatId, target) {
1492
- if (typeof this.#state?.removeWatch !== 'function') return;
2659
+ async #runUnwatch(key, chatId, target, { notify = true } = {}) {
2660
+ const reply = async (message) => {
2661
+ if (!notify) return;
2662
+ await this.#send(chatId, message).catch((error) => {
2663
+ this.#logger.warn?.('[dsh-feishu] unwatch notification failed:', error.message);
2664
+ });
2665
+ };
2666
+ if (typeof this.#state?.removeWatch !== 'function') {
2667
+ return { ok: false, changed: false, reason: 'unsupported' };
2668
+ }
1493
2669
  const entries = this.#state.watchEntries?.(key) ?? [];
1494
2670
  const entry = typeof target === 'string' && /^\d{1,4}$/.test(target)
1495
2671
  ? entries[Number(target) - 1]
1496
2672
  : entries.find((candidate) => candidate.sessionId === target);
1497
2673
  if (!entry) {
1498
- await this.#send(chatId, t('关注列表里没有这个会话,回复 /watchlist 查看。'));
1499
- return;
2674
+ await reply(t('关注列表里没有这个会话,回复 /watchlist 查看。'));
2675
+ return { ok: true, changed: false, reason: 'absent' };
1500
2676
  }
1501
2677
  try {
1502
2678
  await this.#state.removeWatch(key, entry.sessionId);
1503
2679
  this.#failedWatchSeqs.delete(`${key}\0${entry.sessionId}`);
1504
- await this.#send(chatId, t('已取消关注「{title}」。', { title: String(entry.title ?? '').replace(/\s+/gu, ' ') }));
1505
2680
  } catch (error) {
1506
- await this.#send(chatId, t('取消失败:{message}', { message: safeErrorText(error) }));
2681
+ await reply(t('取消失败:{message}', { message: safeErrorText(error) }));
2682
+ return { ok: false, changed: false, reason: 'persist' };
1507
2683
  }
2684
+ await reply(t('已取消关注「{title}」。', { title: String(entry.title ?? '').replace(/\s+/gu, ' ') }));
2685
+ return { ok: true, changed: true, entry };
1508
2686
  }
1509
2687
 
1510
- async #showWatchList(key, chatId) {
2688
+ async #showWatchList(key, chatId, { updateMessageId = null } = {}) {
1511
2689
  const entries = this.#state.watchEntries?.(key) ?? [];
2690
+ // 收集可选会话(用于「添加关注」多选下拉);失败则传空数组 → 只渲染移除/列表。
2691
+ let availableSessions = [];
2692
+ let currentWorkspace = null;
2693
+ try {
2694
+ currentWorkspace = typeof this.#harness?.currentWorkspace === 'function'
2695
+ ? this.#harness.currentWorkspace()
2696
+ : null;
2697
+ if (currentWorkspace && typeof this.#harness?.listWorkspaceSessions === 'function') {
2698
+ const listed = await this.#harness.listWorkspaceSessions(
2699
+ currentWorkspace,
2700
+ { signal: this.#cardDataSignal() },
2701
+ );
2702
+ availableSessions = this.#visibleSessions(
2703
+ Array.isArray(listed?.sessions) ? listed.sessions : [],
2704
+ )
2705
+ .map((session) => ({
2706
+ sessionId: session.sessionId,
2707
+ title: session.title ?? session.name ?? session.sessionId,
2708
+ }));
2709
+ }
2710
+ } catch { /* add-select section degrades to remove-only */ }
1512
2711
  this.#rememberMenu(key, { kind: 'watches', entries });
1513
- await this.#sendCard(chatId, watchListCard(entries), { key });
2712
+ await this.#sendCard(
2713
+ chatId,
2714
+ watchListCard(entries, availableSessions),
2715
+ {
2716
+ key,
2717
+ updateMessageId,
2718
+ sessionWorkspace: currentWorkspace,
2719
+ },
2720
+ );
1514
2721
  }
1515
2722
 
1516
2723
  /** Queue live turn completions behind any reconnect compensation. */
@@ -1520,32 +2727,41 @@ export class FeishuHarnessBridge {
1520
2727
  || !event
1521
2728
  || typeof event !== 'object'
1522
2729
  || event.type !== 'turn/end'
1523
- || !Number.isFinite(event.seq)) return;
1524
- void this.#queueEventTask(async () => {
1525
- const hasFailedDelivery = (this.#state.keysWatching?.(sessionId) ?? [])
2730
+ || !validEventSeq(event.seq)) return;
2731
+ // Record before consulting state: /watch may still be resolving its target
2732
+ // or waiting for setWatch persistence and therefore have no visible entry.
2733
+ this.#recordObservedCompletion(sessionId, event);
2734
+ void this.#queueEventTask(sessionId, async () => {
2735
+ const keys = this.#state.keysWatching?.(sessionId) ?? [];
2736
+ const needsBaseline = keys.some((key) => (
2737
+ watchNeedsBaseline(this.#state.watchEntry?.(key, sessionId))
2738
+ ));
2739
+ const hasFailedDelivery = keys
1526
2740
  .some((key) => this.#failedWatchSeqs.has(`${key}\0${sessionId}`));
1527
- if (hasFailedDelivery) await this.#compensateSession(sessionId);
2741
+ if (needsBaseline || hasFailedDelivery) await this.#compensateSession(sessionId);
1528
2742
  await this.#deliverCompletion(sessionId, event);
1529
2743
  });
1530
2744
  }
1531
2745
 
1532
- async #deliverCompletion(sessionId, event) {
2746
+ async #deliverCompletion(sessionId, event, { keys: targetKeys = null } = {}) {
1533
2747
  if (this.#signal?.aborted || typeof this.#state?.keysWatching !== 'function') return;
1534
2748
  const reason = event?.data?.reason?.kind ?? event?.data?.reason ?? null;
1535
- for (const key of this.#state.keysWatching(sessionId)) {
2749
+ const keys = targetKeys ?? this.#state.keysWatching(sessionId);
2750
+ for (const key of keys) {
1536
2751
  if (this.#signal?.aborted) return;
1537
2752
  const entry = this.#state.watchEntry?.(key, sessionId);
1538
2753
  const deliveryKey = `${key}\0${sessionId}`;
1539
2754
  let failedSeq = this.#failedWatchSeqs.get(deliveryKey);
1540
- if (typeof failedSeq === 'number'
1541
- && typeof entry?.lastSeq === 'number'
2755
+ if (validEventSeq(failedSeq)
2756
+ && validLastSeq(entry?.lastSeq)
1542
2757
  && entry.lastSeq >= failedSeq) {
1543
2758
  this.#failedWatchSeqs.delete(deliveryKey);
1544
2759
  failedSeq = undefined;
1545
2760
  }
1546
2761
  if (!entry?.chatId
1547
- || (typeof entry.lastSeq === 'number' && entry.lastSeq >= event.seq)
1548
- || (typeof failedSeq === 'number' && event.seq > failedSeq)) continue;
2762
+ || watchNeedsBaseline(entry)
2763
+ || (validLastSeq(entry.lastSeq) && entry.lastSeq >= event.seq)
2764
+ || (validEventSeq(failedSeq) && event.seq > failedSeq)) continue;
1549
2765
  try {
1550
2766
  await this.#sendCard(
1551
2767
  entry.chatId,
@@ -1555,13 +2771,14 @@ export class FeishuHarnessBridge {
1555
2771
  const current = this.#state.watchEntry?.(key, sessionId);
1556
2772
  if (!current
1557
2773
  || current.chatId !== entry.chatId
1558
- || (typeof current.lastSeq === 'number' && current.lastSeq >= event.seq)) continue;
2774
+ || watchNeedsBaseline(current)
2775
+ || (validLastSeq(current.lastSeq) && current.lastSeq >= event.seq)) continue;
1559
2776
  await this.#state.setWatch(key, { ...current, lastSeq: event.seq });
1560
2777
  if (failedSeq === event.seq) this.#failedWatchSeqs.delete(deliveryKey);
1561
2778
  } catch (error) {
1562
2779
  this.#failedWatchSeqs.set(
1563
2780
  deliveryKey,
1564
- typeof failedSeq === 'number' ? Math.min(failedSeq, event.seq) : event.seq,
2781
+ validEventSeq(failedSeq) ? Math.min(failedSeq, event.seq) : event.seq,
1565
2782
  );
1566
2783
  this.#logger.warn?.('[dsh-feishu] completion push failed:', error.message);
1567
2784
  }
@@ -1577,23 +2794,55 @@ export class FeishuHarnessBridge {
1577
2794
  30_000,
1578
2795
  { signal: this.#signal },
1579
2796
  );
1580
- const events = orderedHistoryEvents(history);
2797
+ const historicalEvents = orderedHistoryEvents(history);
2798
+ this.#pruneObservedCompletionEvents();
2799
+ const observed = this.#observedCompletionEvents.get(sessionId) ?? new Map();
2800
+ const eventsBySeq = new Map(historicalEvents.map((event) => [event.seq, event]));
2801
+ // The event mux can be ahead of the history projection. Retain a bounded,
2802
+ // minimal completion payload so a pre-persistence live frame is not lost.
2803
+ for (const [seq, record] of observed) {
2804
+ if (!eventsBySeq.has(seq)) eventsBySeq.set(seq, record.event);
2805
+ }
2806
+ const events = [...eventsBySeq.values()].sort((left, right) => left.seq - right.seq);
1581
2807
  const latestSeq = events.at(-1)?.seq ?? -1;
1582
2808
  const keys = typeof this.#state?.keysWatching === 'function'
1583
2809
  ? this.#state.keysWatching(sessionId)
1584
2810
  : [];
1585
2811
 
1586
- // Watches created by older versions have no baseline. Establish one
1587
- // without replaying completions that predate the watch.
2812
+ // Establish independent baselines for every chat watching this Session.
2813
+ // New watches carry a durable wall-clock boundary; old persisted null
2814
+ // watches retain the legacy "baseline latest without replay" behavior.
1588
2815
  for (const key of keys) {
1589
2816
  const entry = this.#state.watchEntry?.(key, sessionId);
1590
- if (entry && typeof entry.lastSeq !== 'number') {
1591
- await this.#state.setWatch(key, { ...entry, lastSeq: latestSeq });
1592
- }
2817
+ if (!watchNeedsBaseline(entry)) continue;
2818
+ const watchStartedAt = watchBoundary(entry);
2819
+ const lowerBound = validLastSeq(entry.lastSeq) ? entry.lastSeq : -1;
2820
+ const firstKnownNew = watchStartedAt === null
2821
+ ? null
2822
+ : events.find((event) => {
2823
+ if (event.seq <= lowerBound) return false;
2824
+ const eventTime = Number.isSafeInteger(event.time) && event.time >= 0
2825
+ ? event.time
2826
+ : observed.get(event.seq)?.arrivalAt;
2827
+ return Number.isSafeInteger(eventTime) && eventTime >= watchStartedAt;
2828
+ });
2829
+ // Unknown events before the first event proven post-boundary are
2830
+ // conservatively part of the baseline. Sequence order proves all later
2831
+ // events are post-boundary. With no proof, replay nothing.
2832
+ const baselineSeq = firstKnownNew
2833
+ ? Math.max(lowerBound, firstKnownNew.seq - 1)
2834
+ : Math.max(lowerBound, latestSeq);
2835
+ const current = this.#state.watchEntry?.(key, sessionId);
2836
+ if (!current
2837
+ || current.chatId !== entry.chatId
2838
+ || current.lastSeq !== entry.lastSeq
2839
+ || watchBoundary(current) !== watchStartedAt) continue;
2840
+ const { watchStartedAt: _watchStartedAt, ...settled } = current;
2841
+ await this.#state.setWatch(key, { ...settled, lastSeq: baselineSeq });
1593
2842
  }
1594
2843
 
1595
2844
  for (const event of events) {
1596
- if (event.type === 'turn/end') await this.#deliverCompletion(sessionId, event);
2845
+ if (event.type === 'turn/end') await this.#deliverCompletion(sessionId, event, { keys });
1597
2846
  }
1598
2847
  } catch (error) {
1599
2848
  if (!this.#signal?.aborted) {
@@ -1607,10 +2856,8 @@ export class FeishuHarnessBridge {
1607
2856
  const sessionIds = typeof this.#state?.watchedSessionIds === 'function'
1608
2857
  ? this.#state.watchedSessionIds()
1609
2858
  : [];
1610
- for (const sessionId of sessionIds) {
1611
- if (this.#signal?.aborted) return;
1612
- await this.#compensateSession(sessionId);
1613
- }
2859
+ if (this.#signal?.aborted) return;
2860
+ await Promise.allSettled(sessionIds.map((sessionId) => this.#scheduleCompensation(sessionId)));
1614
2861
  }
1615
2862
 
1616
2863
  #interactionAskOptions(event, key, files) {