openclaw-lark-multi-agent 1.8.19 → 1.8.21

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.
@@ -1,3 +1,4 @@
1
+ import { INPUT_DISPLAY_LIMITS, LIVE_CARD_BYTES, displayClip, hintDetail, hintLabel, inertInputText, inputPreview, visibleInputPreview, selectInputHints } from "./input-status-display.js";
1
2
  import { createHash, randomUUID } from 'node:crypto';
2
3
  import { AsyncLocalStorage } from "node:async_hooks";
3
4
  import * as lark from "@larksuiteoapi/node-sdk";
@@ -7,11 +8,11 @@ import { getI18n, normalizeLocale } from "./i18n.js";
7
8
  import { InactiveRunObservation, AdmissionUnconfirmed, CollectorStalledError, FollowupAdmissionRefused } from "./openclaw-client.js";
8
9
  import { SessionWaitPaused, isWaitTimeout, normalizeSessionRuntimeStatus, formatSessionFooter, FOREGROUND_WAIT_MS } from "./session-status.js";
9
10
  import { SupersededObservation } from "./openclaw-client.js";
10
- import { LiveStatusController, LiveStatusEffectRejectedError, LiveStatusEffectUnknownError } from "./live-status.js";
11
+ import { LiveStatusController, LiveStatusEffectRejectedError, LiveStatusEffectUnknownError, LiveStatusEffectSuppressedError } from "./live-status.js";
11
12
  import { QueuedPresentations } from "./queued-presentation.js";
12
13
  import { CompactProgressController } from "./compact-progress.js";
13
14
  import { GoController } from "./go-controller.js";
14
- import { errorIdentity } from "./terminal-errors.js";
15
+ import { RunTerminalError, closedStopReason, errorNoticeIdentity, errorIdentity } from "./terminal-errors.js";
15
16
  import { formatProviderError, safeErrorSummary } from "./provider-errors.js";
16
17
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
17
18
  import { basename, extname, join, resolve } from "path";
@@ -31,7 +32,21 @@ export class SendDeadlineError extends Error {
31
32
  }
32
33
  }
33
34
  const nonemptyReceipt = (value) => typeof value === 'string' && !!value.trim();
34
- const canonicalPhysicalSessionKey = (key) => /^agent:[^:]+:/.test(key) ? key : `agent:main:${key}`;
35
+ /** Resolve the PHYSICAL session key for a logical LMA key.
36
+ *
37
+ * A bot may be bound to a specific OpenClaw profile. The profile selects the
38
+ * agent that owns the session, so the physical key carries it as an
39
+ * `agent:<profile>:` prefix. A key that already carries an explicit
40
+ * `agent:<id>:` prefix is left untouched.
41
+ *
42
+ * With the default profile this is byte-identical to the historical
43
+ * `agent:main:<key>`, so existing durable records keep matching. */
44
+ const canonicalPhysicalSessionKey = (key, profile) => {
45
+ if (/^agent:[^:]+:/.test(key))
46
+ return key;
47
+ const agent = String(profile || "main").trim() || "main";
48
+ return `agent:${agent}:${key}`;
49
+ };
35
50
  const require = createRequire(import.meta.url);
36
51
  const LMA_VERSION = require("../package.json").version;
37
52
  const MAX_BOT_STREAK = 10;
@@ -328,8 +343,6 @@ export class FeishuBot {
328
343
  deliveryRetryTimers = new Map();
329
344
  /** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
330
345
  lastRealDeliveryAt = new Map();
331
- /** Per-chat: whether we've already sent the high-context alert this cycle. */
332
- contextAlerted = new Map();
333
346
  /** Active pre-admission target for the current chat operation. */
334
347
  activeDeliveryTargets = new Map();
335
348
  /** Stable targets for concurrent runs in one chat. A steer admission can
@@ -386,10 +399,11 @@ export class FeishuBot {
386
399
  // Go / Go-Checker supervision (design: lma-go-checker-design-20260917).
387
400
  this.goController = new GoController({
388
401
  botName: config.name,
389
- fallbackModel: config.model,
402
+ fallbackModel: config.model || "",
403
+ profile: this.profile,
390
404
  store,
391
405
  client: openclawClient,
392
- executorSessionKeyFor: (chatId) => this.getSessionKey(chatId),
406
+ executorSessionKeyFor: (chatId) => this.physicalSessionKey(chatId),
393
407
  notify: (chatId, text) => this.sendOrdered(chatId, () => this.sendMessage(chatId, text)),
394
408
  reply: (messageId, text) => this.replyMessage(messageId, text),
395
409
  deliverExecutorResult: (chatId, result, goId) => this.deliverGoExecutorResult(chatId, result, goId),
@@ -404,7 +418,7 @@ export class FeishuBot {
404
418
  if (typeof exactKey !== "string" || typeof sessionId !== "string" || typeof runId !== "string")
405
419
  return;
406
420
  for (const job of this.store.listActiveGoJobs(this.config.name)) {
407
- if (exactKey !== job.executorSessionKey && exactKey !== `agent:main:${job.executorSessionKey}`)
421
+ if (exactKey !== job.executorSessionKey && exactKey !== canonicalPhysicalSessionKey(job.executorSessionKey, this.profile))
408
422
  continue;
409
423
  if (sessionId !== job.executorSessionId)
410
424
  continue;
@@ -444,6 +458,10 @@ export class FeishuBot {
444
458
  if (!rowId)
445
459
  return;
446
460
  this.store.clearPendingTrigger(this.config.name, chatId, rowId);
461
+ // Privacy invalidation starts before reaction SDK I/O: unrelated lookup
462
+ // latency must not retain a recalled preview on a frozen/terminal card.
463
+ void this.queuedPresentations.refreshRecalledInput(chatId, rowId, messageId)
464
+ .catch(err => console.warn(`[${this.config.name}] Recalled-input UI redaction unavailable:`, this.errorSummary(err)));
447
465
  const pendingAcks = this.pendingAckMessages.get(chatId) || [];
448
466
  const remainingAcks = [];
449
467
  for (const ack of pendingAcks) {
@@ -464,9 +482,27 @@ export class FeishuBot {
464
482
  * Get the session key for a specific chat.
465
483
  * Format: lma-<botname>-<chatId>
466
484
  */
485
+ /** OpenClaw profile that owns this bot's sessions.
486
+ *
487
+ * Defaults to "main" so a configuration written before profiles existed
488
+ * keeps its historical behaviour exactly. */
489
+ get profile() {
490
+ return String(this.config.profile || "main").trim() || "main";
491
+ }
467
492
  getSessionKey(chatId) {
468
493
  return `lma-${this.config.name.toLowerCase()}-${chatId}`;
469
494
  }
495
+ /** Physical session key for this bot: the logical key addressed under the
496
+ * bot's OpenClaw profile.
497
+ *
498
+ * The LOGICAL key (getSessionKey) stays profile-free so durable records keep
499
+ * their historical shape and never need a migration. Every key that is
500
+ * actually handed to the Gateway goes through this method, so a bot bound to
501
+ * a non-default profile owns its sessions under that profile (its own
502
+ * workspace, memory and identity) while the stored records stay compatible. */
503
+ physicalSessionKey(chatId) {
504
+ return canonicalPhysicalSessionKey(this.getSessionKey(chatId), this.profile);
505
+ }
470
506
  chatLocale(chatId) {
471
507
  return normalizeLocale(chatId ? this.store.getChatLocale(chatId) || this.locale : this.locale);
472
508
  }
@@ -490,7 +526,11 @@ export class FeishuBot {
490
526
  * Lazy: only creates on first message in that chat.
491
527
  */
492
528
  async ensureSession(chatId, setupFence) {
493
- const sessionKey = this.getSessionKey(chatId);
529
+ // The cache is keyed by the LOGICAL key so it stays profile-free and
530
+ // consistent with the rest of the durable bookkeeping; the Gateway is
531
+ // always addressed with the PHYSICAL key (profile included).
532
+ const logicalKey = this.getSessionKey(chatId);
533
+ const sessionKey = this.physicalSessionKey(chatId);
494
534
  await this.assertIdentityEffect();
495
535
  const held = this.identityEffects?.getStore();
496
536
  const expectedSessionId = held?.item.target.sessionId || setupFence?.sessionId;
@@ -501,9 +541,12 @@ export class FeishuBot {
501
541
  if (info?.session?.sessionId !== setupFence.sessionId || info.session.archived)
502
542
  throw new SupersededObservation('retained setup physical target changed');
503
543
  }
504
- if (this.initializedSessions.has(sessionKey)) {
544
+ if (this.initializedSessions.has(logicalKey)) {
505
545
  // Already initialized this process lifetime, just ensure model
506
- const corrected = (expectedSessionId ? await this.openclawClient.ensureModel(sessionKey, this.config.model, { sessionId: expectedSessionId, guard: setupGuard }) : await this.openclawClient.ensureModel(sessionKey, this.config.model));
546
+ // No configured model means the bot follows its profile's own model.
547
+ const corrected = this.config.model
548
+ ? (expectedSessionId ? await this.openclawClient.ensureModel(sessionKey, this.config.model, { sessionId: expectedSessionId, guard: setupGuard }) : await this.openclawClient.ensureModel(sessionKey, this.config.model))
549
+ : false;
507
550
  setupGuard();
508
551
  if (corrected) {
509
552
  console.log(`[${this.config.name}] Model auto-corrected to ${this.config.model}`);
@@ -519,7 +562,9 @@ export class FeishuBot {
519
562
  if (existing?.session) {
520
563
  // Session exists — preserve it, only ensure model is correct
521
564
  console.log(`[${this.config.name}] Session exists: ${sessionKey} (tokens: ${existing.session.totalTokens || 0})`);
522
- const corrected = (expectedSessionId ? await this.openclawClient.ensureModel(sessionKey, this.config.model, { sessionId: expectedSessionId, guard: setupGuard }) : await this.openclawClient.ensureModel(sessionKey, this.config.model));
565
+ const corrected = this.config.model
566
+ ? (expectedSessionId ? await this.openclawClient.ensureModel(sessionKey, this.config.model, { sessionId: expectedSessionId, guard: setupGuard }) : await this.openclawClient.ensureModel(sessionKey, this.config.model))
567
+ : false;
523
568
  setupGuard();
524
569
  if (corrected) {
525
570
  console.log(`[${this.config.name}] Model auto-corrected to ${this.config.model}`);
@@ -529,11 +574,13 @@ export class FeishuBot {
529
574
  // Session doesn't exist — create new
530
575
  await this.openclawClient.createSession({
531
576
  key: sessionKey,
532
- model: this.config.model,
577
+ model: this.config.model || "",
533
578
  label: `LMA: ${this.config.name} [${chatId.slice(-8)}]`,
534
579
  });
535
- // Always patch model after create to ensure it takes effect
536
- await this.openclawClient.patchSession({ key: sessionKey, model: this.config.model });
580
+ // Only patch when a model is configured; otherwise the profile's own
581
+ // model must be left untouched.
582
+ if (this.config.model)
583
+ await this.openclawClient.patchSession({ key: sessionKey, model: this.config.model });
537
584
  await this.injectBridgePolicy(sessionKey);
538
585
  console.log(`[${this.config.name}] Session created: ${sessionKey} (model: ${this.config.model})`);
539
586
  }
@@ -545,7 +592,7 @@ export class FeishuBot {
545
592
  throw err;
546
593
  }
547
594
  setupGuard();
548
- this.initializedSessions.add(sessionKey);
595
+ this.initializedSessions.add(logicalKey);
549
596
  // Subscribe to session events (proactive messages + tool calls)
550
597
  this.openclawClient.setVerboseTranscriptDelivery(sessionKey, this.store.getBotVerbose(this.config.name, chatId));
551
598
  await this.openclawClient.subscribeSession(sessionKey, async (text, meta) => {
@@ -561,7 +608,7 @@ export class FeishuBot {
561
608
  }
562
609
  if (meta?.sourceType === "run_error" || provenance) {
563
610
  if (provenance) {
564
- this.store.observeRunError(this.config.name, chatId, provenance);
611
+ this.store.observeRunError(this.config.name, chatId, this.scopeRunError(chatId, provenance));
565
612
  await this.reconcileErrorNotices(chatId);
566
613
  }
567
614
  else
@@ -599,7 +646,7 @@ export class FeishuBot {
599
646
  console.error(`[${this.config.name}] Failed to deliver proactive msg:`, err.message);
600
647
  }
601
648
  }, async (meta) => {
602
- if (!meta.runId || this.store.isStoppedObservation(`agent:main:${sessionKey.replace(/^agent:[^:]+:/, '')}`, meta.runId))
649
+ if (!meta.runId || this.store.isStoppedObservation(canonicalPhysicalSessionKey(sessionKey.replace(/^agent:[^:]+:/, ''), this.profile), meta.runId))
603
650
  return;
604
651
  if (this.store.hasUnconfirmedRunDelivery(this.config.name, chatId, meta.runId))
605
652
  return;
@@ -655,7 +702,7 @@ export class FeishuBot {
655
702
  await this.wsClient.start({ eventDispatcher: this.eventDispatcher });
656
703
  if (this.identityClosed)
657
704
  return;
658
- console.log(`[${this.config.name}] Bot started (model: ${this.config.model}, open_id: ${this.botOpenId || "unknown"})`);
705
+ console.log(`[${this.config.name}] Bot started (model: ${this.config.model || `profile ${this.profile}`}, open_id: ${this.botOpenId || "unknown"})`);
659
706
  // Drain any unsynced messages left from previous run
660
707
  this.drainOnStartup();
661
708
  void this.drainIdentityIngress().catch(err => console.warn(`[${this.config.name}] Identity ingress recovery failed:`, err.message));
@@ -688,14 +735,14 @@ export class FeishuBot {
688
735
  });
689
736
  }
690
737
  captureIdentityTarget(messageId, chatId) {
691
- const key = this.getSessionKey(chatId);
738
+ const key = this.physicalSessionKey(chatId);
692
739
  const work = (async () => {
693
740
  const info = await this.identitySessionInfo(key);
694
741
  if (this.identityClosed)
695
742
  return;
696
743
  const session = info?.session;
697
744
  const target = typeof session?.sessionId === 'string' && session.sessionId.trim() && session.sessionId.length <= 256 && session.archived !== true
698
- && (!session.key || typeof session.key === 'string' && canonicalPhysicalSessionKey(session.key) === canonicalPhysicalSessionKey(key))
745
+ && (!session.key || typeof session.key === 'string' && canonicalPhysicalSessionKey(session.key, this.profile) === canonicalPhysicalSessionKey(key, this.profile))
699
746
  ? { state: 'bound', sessionKey: key, sessionId: session.sessionId } : { state: 'unbound', sessionKey: key };
700
747
  if (target.state === 'unbound')
701
748
  console.warn(`[${this.config.name}] Identity input ${messageId} has no proven original physical session; retained without retargeting`);
@@ -875,7 +922,12 @@ export class FeishuBot {
875
922
  if (this.identityTargetRepairs.has(item.messageId))
876
923
  return;
877
924
  item.target = this.store.getIdentityTarget(this.config.name, this.config.appId, item.messageId);
878
- if (item.target.state !== 'bound' || !item.target.sessionId || item.target.sessionKey !== this.getSessionKey(item.chatId))
925
+ // An identity target may hold the physical key (current records) or the
926
+ // logical key (records written before profiles existed). Both denote the
927
+ // SAME session, so both are accepted here; comparing against only one
928
+ // form silently skips identity recovery and the message is never sent.
929
+ if (item.target.state !== 'bound' || !item.target.sessionId ||
930
+ (item.target.sessionKey !== this.physicalSessionKey(item.chatId) && item.target.sessionKey !== this.getSessionKey(item.chatId)))
879
931
  continue;
880
932
  const info = await this.identitySessionInfo(item.target.sessionKey);
881
933
  if (this.identityClosed)
@@ -915,7 +967,7 @@ export class FeishuBot {
915
967
  const drainTasks = [];
916
968
  for (const chat of chats) {
917
969
  // Skip p2p chats that belong to other bots
918
- if (chat.chatType === "p2p" && chat.ownerBot && chat.ownerBot !== this.config.name) {
970
+ if (chat.chatType === "p2p" && !this.store.claimP2pOwner(chat.chatId, this.config.name, this.config.appId)) {
919
971
  continue;
920
972
  }
921
973
  // Re-subscribe to existing sessions
@@ -973,7 +1025,9 @@ export class FeishuBot {
973
1025
  if ((message.mentions || []).some((mention) => mention?.id && Object.hasOwn(mention.id, 'open_id') &&
974
1026
  (typeof mention.id.open_id !== 'string' || !mention.id.open_id.trim())))
975
1027
  return;
976
- if (this.store.hasBotProcessed(this.config.name, message.message_id))
1028
+ // Private duplicates must reach the stable app/chat fence so an old
1029
+ // name-scoped tombstone cannot disappear across a later bot rename.
1030
+ if (message.chat_type !== "p2p" && this.store.hasBotProcessed(this.config.name, message.message_id))
977
1031
  return;
978
1032
  // A duplicate must honor the original journal owner, even if its envelope
979
1033
  // differs or identity has resolved since the first receipt.
@@ -1031,7 +1085,9 @@ export class FeishuBot {
1031
1085
  // OpenClaw twice.
1032
1086
  if (this.identityClosed)
1033
1087
  return;
1034
- if (!identityItem && !this.store.tryMarkBotProcessed(this.config.name, messageId))
1088
+ if (!identityItem && !(chatType === "p2p"
1089
+ ? this.store.tryMarkP2pProcessed(this.config.appId, chatId, this.config.name, messageId)
1090
+ : this.store.tryMarkBotProcessed(this.config.name, messageId)))
1035
1091
  return;
1036
1092
  const identityCurrent = () => {
1037
1093
  if (this.identityClosed)
@@ -1051,11 +1107,8 @@ export class FeishuBot {
1051
1107
  if (!identityCurrent())
1052
1108
  return;
1053
1109
  // --- P2P isolation: only the owning bot processes p2p messages ---
1054
- if (chatType === "p2p") {
1055
- const chatInfo = this.store.getChatInfo(chatId);
1056
- if (chatInfo?.ownerBot && chatInfo.ownerBot !== this.config.name) {
1057
- return; // This p2p chat belongs to another bot
1058
- }
1110
+ if (chatType === "p2p" && !this.store.claimP2pOwner(chatId, this.config.name, this.config.appId)) {
1111
+ return; // A display-name match cannot override a conflicting app pin.
1059
1112
  }
1060
1113
  let content;
1061
1114
  try {
@@ -1065,6 +1118,14 @@ export class FeishuBot {
1065
1118
  console.warn(`[${this.config.name}] Failed to parse message content, skipping`);
1066
1119
  return;
1067
1120
  }
1121
+ // Original visible input for UI only; never use hydrated media paths,
1122
+ // synthetic routing prompts or model/history text as its body.
1123
+ const inputCaption = [content.text, content.caption].find(value => typeof value === 'string') || '';
1124
+ const inputDisplayText = messageType === 'text' ? (typeof content.text === 'string' ? content.text.replace(/@_user_\d+/g, (key) => { const mention = (message.mentions || []).find((item) => item.key === key); return typeof mention?.name === 'string' ? `@${mention.name}` : key; }) : '') :
1125
+ messageType === 'file' ? `[${this.isEn(chatId) ? 'File' : '文件'}: ${(typeof content.file_name === 'string' ? content.file_name : 'file').split(/[\\/]/).at(-1)}] ${inputCaption}` :
1126
+ messageType === 'image' ? `[${this.isEn(chatId) ? 'Image' : '图片'}] ${inputCaption}` : messageType === 'audio' ? `[${this.isEn(chatId) ? 'Audio' : '语音'}] ${inputCaption}` : messageType === 'sticker' ? (this.isEn(chatId) ? '[Sticker]' : '[贴纸]') :
1127
+ messageType === 'post' ? this.visiblePostInput(content, chatId) :
1128
+ [content.title, content.name, content.text, content.url].find(value => typeof value === 'string') || '';
1068
1129
  // --- Extract text content based on message type ---
1069
1130
  let cleanText = "";
1070
1131
  let rawText = "";
@@ -1448,7 +1509,7 @@ export class FeishuBot {
1448
1509
  const isOn = this.store.getBotVerbose(this.config.name, chatId);
1449
1510
  const nextVerbose = !isOn;
1450
1511
  this.store.setBotVerbose(this.config.name, chatId, nextVerbose);
1451
- this.openclawClient.setVerboseTranscriptDelivery(this.getSessionKey(chatId), nextVerbose);
1512
+ this.openclawClient.setVerboseTranscriptDelivery(this.physicalSessionKey(chatId), nextVerbose);
1452
1513
  if (isOn) {
1453
1514
  await this.replyMessage(messageId, `🔇 ${this.config.name} Verbose 已关闭\n只影响当前 Bot 在当前会话的 Tool call 和中间文本显示`);
1454
1515
  }
@@ -1622,7 +1683,7 @@ export class FeishuBot {
1622
1683
  // No health/setup/model/history await precedes this honest local receipt.
1623
1684
  // UI custody cannot reserve dispatch or claim Gateway/model acceptance.
1624
1685
  const presentationRoot = this.queueOwners.get(chatId)?.runId || this.store.getDispatchCustody(this.config.name, chatId)?.runId;
1625
- this.presentation(() => this.queuedPresentations.receive(chatId, insertedId, messageId, isNativeOpenClawCommand, presentationRoot));
1686
+ this.presentation(() => this.queuedPresentations.receive(chatId, insertedId, messageId, isNativeOpenClawCommand, presentationRoot, { source: 'visible', text: inputDisplayText }));
1626
1687
  // Reserve BEFORE the first health/reaction await. Otherwise an ending
1627
1688
  // owner can ordinary-drain this row while its handler is still waiting,
1628
1689
  // and the later steer would submit the same message a second time.
@@ -1716,9 +1777,23 @@ export class FeishuBot {
1716
1777
  if (!activity.followupAdmissionEligible || activity.identityConflict || activity.activeRunId && !noEffectRunId || activity.archived || status.archived ||
1717
1778
  !status.sessionId || status.sessionId !== activity.sessionId || !activity.expectedLeafEntryId)
1718
1779
  return;
1719
- return { sessionKey: canonicalPhysicalSessionKey(this.getSessionKey(chatId)), sessionId: status.sessionId, expectedLeafEntryId: activity.expectedLeafEntryId,
1780
+ return { sessionKey: canonicalPhysicalSessionKey(this.getSessionKey(chatId), this.profile), sessionId: status.sessionId, expectedLeafEntryId: activity.expectedLeafEntryId,
1720
1781
  ...(noEffectRunId ? { nonInjectableRunId: noEffectRunId } : {}) };
1721
1782
  }
1783
+ /** How long an unconfirmed input may stay silent before the user is told.
1784
+ *
1785
+ * A fixed 60s window misfired on every slow session: measured reply latency
1786
+ * in a busy group was p50 3.8min / p90 20.2min / max 135min, so 85% of real
1787
+ * replies exceeded the old threshold and every one of them produced a false
1788
+ * "not confirmed" notice. Scale the window with the session's own observed
1789
+ * latency so a healthy slow run is never reported as stuck. */
1790
+ unconfirmedGraceMs(chatId) {
1791
+ const observed = this.store.observedReplyLatencyMs(this.config.name, chatId);
1792
+ const floor = 60_000;
1793
+ if (!observed || !Number.isFinite(observed) || observed <= 0)
1794
+ return floor;
1795
+ return Math.min(Math.max(floor, Math.ceil(observed * 1.5)), 30 * 60_000);
1796
+ }
1722
1797
  /** A positive refusal must be durable AND explained to the sender. */
1723
1798
  retainChangedFollowup(chatId, item, reason) {
1724
1799
  this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, 'target_changed', { ...item, reason });
@@ -1735,7 +1810,7 @@ export class FeishuBot {
1735
1810
  for (const item of this.store.listInsertions(this.config.name, chatId)) {
1736
1811
  if (item.state !== 'pending' || item.attempted || item.followupAdmissionGuard || item.ordinaryEligible === false ||
1737
1812
  item.ownerId !== saved.id || item.batchId !== saved.batchId || item.stopEpoch !== saved.stopEpoch || item.sessionId !== proof.sessionId ||
1738
- !item.sessionKey || canonicalPhysicalSessionKey(item.sessionKey) !== proof.sessionKey)
1813
+ !item.sessionKey || canonicalPhysicalSessionKey(item.sessionKey, this.profile) !== proof.sessionKey)
1739
1814
  continue;
1740
1815
  const source = this.store.getMessageByMessageId(item.messageId);
1741
1816
  if (source?.senderType !== 'human' || source.triggerKind !== 'normal')
@@ -1759,7 +1834,7 @@ export class FeishuBot {
1759
1834
  !this.queueOwners.get(chatId)?.triggerIds?.has(rowId);
1760
1835
  if (!unsubmitted())
1761
1836
  return;
1762
- await this.openclawClient.waitForSessionStop?.(this.getSessionKey(chatId));
1837
+ await this.openclawClient.waitForSessionStop?.(this.physicalSessionKey(chatId));
1763
1838
  if (!unsubmitted())
1764
1839
  return;
1765
1840
  // Session creation/model setup is not agent admission. Initialize a truly
@@ -1785,15 +1860,15 @@ export class FeishuBot {
1785
1860
  }
1786
1861
  if (!unsubmitted() || this.queueRuns.has(chatId) || this.store.getDispatchCustody(this.config.name, chatId))
1787
1862
  return;
1788
- const status = await this.openclawClient.getSessionRuntimeStatus(this.getSessionKey(chatId));
1863
+ const status = await this.openclawClient.getSessionRuntimeStatus(this.physicalSessionKey(chatId));
1789
1864
  if (!unsubmitted() || this.queueRuns.has(chatId) || this.store.getDispatchCustody(this.config.name, chatId))
1790
1865
  return;
1791
1866
  const current = this.store.getInsertion(this.config.name, chatId, rowId);
1792
1867
  if (current && (!current.activityUnbound || current.attempted))
1793
1868
  return;
1794
- if (current && (current.stopEpoch !== epoch || !current.sessionKey || canonicalPhysicalSessionKey(current.sessionKey) !== canonicalPhysicalSessionKey(this.getSessionKey(chatId))))
1869
+ if (current && (current.stopEpoch !== epoch || !current.sessionKey || canonicalPhysicalSessionKey(current.sessionKey, this.profile) !== canonicalPhysicalSessionKey(this.getSessionKey(chatId), this.profile)))
1795
1870
  return;
1796
- const activity = await this.openclawClient.getSessionDispatchStatus(this.getSessionKey(chatId));
1871
+ const activity = await this.openclawClient.getSessionDispatchStatus(this.physicalSessionKey(chatId));
1797
1872
  if (!unsubmitted() || this.queueRuns.has(chatId) || this.store.getDispatchCustody(this.config.name, chatId))
1798
1873
  return;
1799
1874
  // Another recovery/ingress lookup may have identified the root and begun
@@ -1809,6 +1884,13 @@ export class FeishuBot {
1809
1884
  !!current?.sessionId && !!status.sessionId && current.sessionId !== status.sessionId;
1810
1885
  const source = this.store.getMessageByMessageId(messageId);
1811
1886
  const ordinaryHuman = source?.id === rowId && source.chatId === chatId && source.senderType === 'human' && source.triggerKind === 'normal';
1887
+ if (current?.followupBlocked && !archived && !generationConflict) {
1888
+ this.store.saveInsertion(this.config.name, chatId, rowId, current.insertionId || JSON.stringify([this.config.appId, chatId, messageId]), 'pending', {
1889
+ ...current, followupBlocked: false, followupAdmissionGuard: undefined, followupObservedAt: undefined,
1890
+ reason: 'followup block cleared; awaiting admission re-observation',
1891
+ }, Date.now() + 5000);
1892
+ return;
1893
+ }
1812
1894
  const proof = ordinaryHuman && compatible && !current?.followupBlocked && !archived ? this.observedFollowupGuard(chatId, status, activity) : undefined;
1813
1895
  const previous = current?.followupAdmissionGuard;
1814
1896
  if (previous && (archived || generationConflict)) {
@@ -1857,7 +1939,7 @@ export class FeishuBot {
1857
1939
  ...((archived || generationConflict) ? { followupBlocked: true } : {}),
1858
1940
  reason: identified ? 'current Gateway run observed; insertion custody only' : blockerReason,
1859
1941
  }, Date.now() + (identified ? 0 : 5000));
1860
- if (!identified && !proof && current?.unboundSince && Date.now() - current.unboundSince >= 60_000 &&
1942
+ if (!identified && !proof && current?.unboundSince && Date.now() - current.unboundSince >= this.unconfirmedGraceMs(chatId) &&
1861
1943
  (archived || generationConflict || activity.busyConfirmed || current.restrictedObservation))
1862
1944
  this.explainUnconfirmedFollowup(chatId, { ...current, rowId, messageId });
1863
1945
  }
@@ -1872,7 +1954,7 @@ export class FeishuBot {
1872
1954
  const source = this.store.getMessageByMessageId(item.messageId);
1873
1955
  if (item.state !== 'pending' || item.attempted || item.ordinaryEligible === false || !item.sessionId ||
1874
1956
  !source || source.id !== item.rowId || source.chatId !== chatId || source.senderType !== 'human' || source.triggerKind !== 'normal' ||
1875
- !item.sessionKey || canonicalPhysicalSessionKey(item.sessionKey) !== canonicalPhysicalSessionKey(this.getSessionKey(chatId)))
1957
+ !item.sessionKey || canonicalPhysicalSessionKey(item.sessionKey, this.profile) !== canonicalPhysicalSessionKey(this.getSessionKey(chatId), this.profile))
1876
1958
  return false;
1877
1959
  const admissionEpoch = this.queueAdmissionEpoch.get(chatId) || 0;
1878
1960
  const snapshot = this.store.getInsertion(this.config.name, chatId, item.rowId);
@@ -1922,14 +2004,14 @@ export class FeishuBot {
1922
2004
  return (this.queueAdmissionEpoch.get(chatId) || 0) === admissionEpoch && this.stopGeneration(chatId) === epoch && saved.stopEpoch === epoch && JSON.stringify(now) === JSON.stringify(saved) &&
1923
2005
  this.queueOwners.get(chatId) === local && (!local || local.batch === batch && local.runId === runId && (local.admitted === true || local.yielded === true) && local.collecting === true);
1924
2006
  };
1925
- const activity = await this.openclawClient.getSessionDispatchStatus?.(this.getSessionKey(chatId)).catch(() => null);
2007
+ const activity = await this.openclawClient.getSessionDispatchStatus?.(this.physicalSessionKey(chatId)).catch(() => null);
1926
2008
  if (!unchanged() || !activity || activity.archived || activity.sessionId !== saved.sessionId)
1927
2009
  return false;
1928
2010
  if (activity.activeRunId === saved.runId)
1929
2011
  return false; // real foreground keeps SDK priority
1930
2012
  if (!activity.idleConfirmed && !activity.followupAdmissionEligible)
1931
2013
  return false;
1932
- const status = await this.openclawClient.getSessionRuntimeStatus(this.getSessionKey(chatId)).catch(() => null);
2014
+ const status = await this.openclawClient.getSessionRuntimeStatus(this.physicalSessionKey(chatId)).catch(() => null);
1933
2015
  if (!unchanged() || !status || status.archived || status.sessionId !== saved.sessionId)
1934
2016
  return false;
1935
2017
  if (activity.activeRunId && activity.activeRunId !== saved.runId) {
@@ -1940,7 +2022,7 @@ export class FeishuBot {
1940
2022
  for (const item of this.store.listInsertions(this.config.name, chatId)) {
1941
2023
  if (item.state !== 'pending' || item.attempted || item.noEffectRunId || item.ordinaryEligible === false || item.ownerId !== saved.id || item.batchId !== saved.batchId ||
1942
2024
  item.stopEpoch !== epoch || item.sessionId !== saved.sessionId || !item.sessionKey ||
1943
- canonicalPhysicalSessionKey(item.sessionKey) !== canonicalPhysicalSessionKey(this.getSessionKey(chatId)))
2025
+ canonicalPhysicalSessionKey(item.sessionKey, this.profile) !== canonicalPhysicalSessionKey(this.getSessionKey(chatId), this.profile))
1944
2026
  continue;
1945
2027
  const source = this.store.getMessageByMessageId(item.messageId);
1946
2028
  if (source?.senderType !== 'human' || source.triggerKind !== 'normal')
@@ -1967,7 +2049,7 @@ export class FeishuBot {
1967
2049
  let followup;
1968
2050
  if (!idle || saved.followupAdmissionGuard) {
1969
2051
  const proof = this.observedFollowupGuard(chatId, status, activity);
1970
- if (!proof || !saved.sessionKey || canonicalPhysicalSessionKey(saved.sessionKey) !== proof.sessionKey)
2052
+ if (!proof || !saved.sessionKey || canonicalPhysicalSessionKey(saved.sessionKey, this.profile) !== proof.sessionKey)
1971
2053
  return false;
1972
2054
  const previous = saved.followupAdmissionGuard;
1973
2055
  if (previous && (previous.sessionKey !== proof.sessionKey || previous.sessionId !== proof.sessionId))
@@ -2000,7 +2082,12 @@ export class FeishuBot {
2000
2082
  }
2001
2083
  }
2002
2084
  stopGeneration(chatId) {
2003
- return this.stopEpoch.get(chatId) ?? this.store.getStopGeneration(this.config.name, chatId);
2085
+ // A peer bot's group-scoped /stop bumps this bot's durable epoch. The
2086
+ // in-memory value can then be stale and would mask the newer durable one,
2087
+ // so take the greater of the two instead of trusting the cache blindly.
2088
+ const cached = this.stopEpoch.get(chatId);
2089
+ const stored = this.store.getStopGeneration(this.config.name, chatId);
2090
+ return cached === undefined ? stored : Math.max(cached, stored);
2004
2091
  }
2005
2092
  reactionChecks = new Set();
2006
2093
  async receiptReaction(chatId, rowId, messageId, desired, runId = '') {
@@ -2235,7 +2322,7 @@ export class FeishuBot {
2235
2322
  }
2236
2323
  else {
2237
2324
  state = 'pending';
2238
- if (Date.now() - item.noEffectSince >= 60_000)
2325
+ if (Date.now() - item.noEffectSince >= this.unconfirmedGraceMs(chatId))
2239
2326
  this.explainUnconfirmedFollowup(chatId, item);
2240
2327
  }
2241
2328
  }
@@ -2253,7 +2340,7 @@ export class FeishuBot {
2253
2340
  ordinaryEligible: receipt.ordinaryEligible, attempted: valid ? (receipt.attempted ?? true) : true, sessionId: item.sessionId, reason: receipt.reason, unknownChecks,
2254
2341
  unknownSince: state === 'unknown' ? (item.state === 'unknown' && Number.isFinite(item.unknownSince) ? item.unknownSince : Date.now()) : undefined };
2255
2342
  this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, state, record, Date.now() + (state === 'unknown' ? 15_000 : 5000));
2256
- if (state === 'pending' && !record.attempted && Date.now() - (record.pendingSince || Date.now()) >= 60_000)
2343
+ if (state === 'pending' && !record.attempted && Date.now() - (record.pendingSince || Date.now()) >= this.unconfirmedGraceMs(chatId))
2257
2344
  this.explainUnconfirmedFollowup(chatId, record);
2258
2345
  if (state === 'ordinary') {
2259
2346
  this.store.deletePendingInsertionNotices(this.config.name, chatId, item.insertionId);
@@ -2309,7 +2396,7 @@ export class FeishuBot {
2309
2396
  return process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS !== "0";
2310
2397
  }
2311
2398
  async getUnhealthySessionStatus(chatId) {
2312
- const sessionKey = this.getSessionKey(chatId);
2399
+ const sessionKey = this.physicalSessionKey(chatId);
2313
2400
  try {
2314
2401
  const resp = await this.openclawClient.getSessionInfo(sessionKey);
2315
2402
  const status = String(resp?.session?.status || "").toLowerCase();
@@ -2491,11 +2578,20 @@ export class FeishuBot {
2491
2578
  */
2492
2579
  forceExpireQueueOwner(chatId, reason, preserveDispatch = false) {
2493
2580
  const owner = this.queueRuns.get(chatId);
2494
- if (!owner)
2581
+ if (!owner) {
2582
+ // No local owner left, but a persisted dispatch_custody row may still
2583
+ // exist for this chat (it is written before the owner is retired). The
2584
+ // old early return stranded that row forever: it never became visible
2585
+ // again, yet it also was never deleted. Clear it explicitly.
2586
+ if (!preserveDispatch && this.store.clearDispatchCustodyForChat(this.config.name, chatId))
2587
+ console.warn(`[${this.config.name}] cleared orphaned dispatch custody for ${chatId.slice(-8)}: ${reason}`);
2495
2588
  return false;
2589
+ }
2496
2590
  const context = this.queueOwners.get(chatId);
2497
2591
  if (context?.batchId && !preserveDispatch)
2498
2592
  this.store.clearDispatchCustody(this.config.name, chatId, context.id, context.batchId);
2593
+ else if (!preserveDispatch && !context)
2594
+ this.store.clearDispatchCustodyForChat(this.config.name, chatId);
2499
2595
  if (context)
2500
2596
  context.revoked = true;
2501
2597
  this.busyChats.set(chatId, 0);
@@ -2717,7 +2813,11 @@ export class FeishuBot {
2717
2813
  const expectedIdentitySessionId = identityTargets[0]?.target.sessionId;
2718
2814
  try {
2719
2815
  if (identityTargets.some(({ target }) => target.state !== 'bound' || !target.sessionId || target.sessionId !== expectedIdentitySessionId ||
2720
- target.sessionKey !== this.getSessionKey(chatId) || (target.expiresAt ?? 0) <= Date.now()))
2816
+ // An identity target may hold the physical key (current records) or the
2817
+ // logical key (records written before profiles existed). Both denote
2818
+ // the SAME session, so both are accepted; comparing against only one
2819
+ // form would silently drop the message.
2820
+ (target.sessionKey !== this.physicalSessionKey(chatId) && target.sessionKey !== this.getSessionKey(chatId)) || (target.expiresAt ?? 0) <= Date.now()))
2721
2821
  throw new SupersededObservation('retained original target unavailable');
2722
2822
  sessionKey = await (expectedIdentitySessionId ? this.ensureSession(chatId, { sessionId: expectedIdentitySessionId, guard }) : this.ensureSession(chatId));
2723
2823
  guard();
@@ -2739,7 +2839,7 @@ export class FeishuBot {
2739
2839
  owner.continuePending = mergedTriggers.some(message => !identityTargets.some(entry => entry.message === message));
2740
2840
  break;
2741
2841
  }
2742
- await liveStatus?.pauseForUnconfirmedResult(this.isEn(chatId) ? "Session setup failed before admission" : "会话准备失败,尚未提交 Gateway", this.isEn(chatId) ? `${this.config.name} setup unavailable` : `${this.config.name} 会话准备未完成`).catch(() => { });
2842
+ await liveStatus?.pauseForUnconfirmedResult(this.isEn(chatId) ? "Session setup failed before admission" : "会话准备失败,尚未提交 Gateway", this.isEn(chatId) ? `${this.config.name} setup unavailable` : `${this.config.name} 会话准备未完成`, "blocked").catch(() => { });
2743
2843
  if (batchProof) {
2744
2844
  for (const id of mergedTriggerIds) {
2745
2845
  const item = this.store.getInsertion(this.config.name, chatId, id);
@@ -2879,7 +2979,10 @@ export class FeishuBot {
2879
2979
  if (target?.triggerId === triggerId) {
2880
2980
  target.runId = runId;
2881
2981
  this.bindRunDeliveryTarget(chatId, runId, target);
2882
- this.releaseRunDeliveryTargetAfter(chatId, runId, 30 * 60_000);
2982
+ // Result custody must outlive the real run. Observed GPT latency in a heavy
2983
+ // group reached ~59m40s, so a 30-minute release dropped a computed answer
2984
+ // that had no receiver left. Two hours covers the observed tail.
2985
+ this.releaseRunDeliveryTargetAfter(chatId, runId, 2 * 60 * 60_000);
2883
2986
  }
2884
2987
  // Refine state for logs/idempotent sync rows when OpenClaw returns
2885
2988
  // a runId. markSubmittedBatch is idempotent due to local flags and
@@ -2947,7 +3050,7 @@ export class FeishuBot {
2947
3050
  guard,
2948
3051
  onSendAttempt: submissionId => {
2949
3052
  guard();
2950
- this.store.bindDeliveryRun(this.config.name, chatId, submissionId, { triggerId, messageId: lastHuman.messageId || '', runId: submissionId, stopGeneration: this.stopGeneration(chatId), sessionKey: `agent:main:${sessionKey.replace(/^agent:[^:]+:/, '')}` });
3053
+ this.store.bindDeliveryRun(this.config.name, chatId, submissionId, { triggerId, messageId: lastHuman.messageId || '', runId: submissionId, stopGeneration: this.stopGeneration(chatId), sessionKey: canonicalPhysicalSessionKey(sessionKey.replace(/^agent:[^:]+:/, ''), this.profile) });
2951
3054
  },
2952
3055
  onSubmitted: async (runId) => { guard(); const previousRoot = owner.runId; owner.runId = runId; foregroundRunId = runId; void this.queuedPresentations.advance(liveStatus, previousRoot, runId, owner.id, owner.batchId).catch(() => { }); this.bindRunDeliveryTarget(chatId, runId, { triggerId, messageId: lastHuman.messageId || "", liveStatus }); },
2953
3056
  });
@@ -3029,14 +3132,13 @@ export class FeishuBot {
3029
3132
  const outcome = await this.enqueueAndDispatchDelivery(chatId, "assistant_visible", this.deliverySourceId("visible", `${triggerId}|${(shouldReply ? visibleReply : "").trim()}|${JSON.stringify(parsedReply.attachments)}`), shouldReply ? visibleReply : "", parsedReply.attachments, lastHuman.messageId, `trigger:${triggerId}`, this.config.model, foregroundRunId ? { runId: foregroundRunId, sessionKey, ...(foregroundSessionId ? { sessionId: foregroundSessionId } : {}), final: true } : undefined, undefined, mergedTriggerIds);
3030
3133
  visibleOutcomeConfirmed = outcome.state === 'delivered';
3031
3134
  if (!visibleOutcomeConfirmed) {
3032
- await liveStatus?.pauseForUnconfirmedResult("飞书投递回执尚未确认;保留原投递保管,不重复发送").catch(() => { });
3135
+ await liveStatus?.pauseForUnconfirmedResult("飞书投递回执尚未确认;保留原投递保管,不重复发送", undefined, "delivery_pending").catch(() => { });
3033
3136
  }
3034
3137
  else {
3035
3138
  await liveStatus?.complete();
3036
3139
  // Mark every merged trigger only after receipt confirmation.
3037
3140
  for (const id of mergedTriggerIds)
3038
3141
  this.store.markDeliveredReply(this.config.name, chatId, id, lastHuman.messageId);
3039
- void this.maybeAlertHighContext(chatId);
3040
3142
  }
3041
3143
  // Go supervision: record the delivered final reply as evidence and
3042
3144
  // arm a debounced checker round (busy gates apply inside).
@@ -3101,6 +3203,10 @@ export class FeishuBot {
3101
3203
  this.goController?.notifyRunOutcome(chatId, "no_reply", `trigger:${triggerId}`, "(本轮无用户可见回复 NO_REPLY)");
3102
3204
  }
3103
3205
  console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
3206
+ // Context usage is re-checked after EVERY completed message (delivered
3207
+ // reply, NO_REPLY turn or runtime failure) and re-notifies each time while
3208
+ // usage stays above the threshold.
3209
+ await this.maybeAlertHighContext(chatId);
3104
3210
  guard();
3105
3211
  // Replace ack reactions with DONE only for trigger messages actually
3106
3212
  // processed in this run. Queued messages that arrived mid-run keep their
@@ -3151,17 +3257,17 @@ export class FeishuBot {
3151
3257
  this.store.saveInsertion(this.config.name, chatId, id, row.insertionId, 'target_changed', { ...row, attempted: false, reason: err.message });
3152
3258
  }
3153
3259
  await this.enqueueAndDispatchDelivery(chatId, 'admission_status', `admission:${err.submissionId}:refused`, err.message, [], lastHuman.messageId, `admission:${err.submissionId}:refused`).catch(() => { });
3154
- await liveStatus?.pauseForUnconfirmedResult(err.message).catch(() => { });
3260
+ await liveStatus?.pauseForUnconfirmedResult(err.message, undefined, "blocked").catch(() => { });
3155
3261
  owner.continuePending = true; // independently proved newer cohorts may still be sent
3156
3262
  break;
3157
3263
  }
3158
3264
  if (err instanceof AdmissionUnconfirmed) {
3159
3265
  await this.enqueueAndDispatchDelivery(chatId, 'admission_status', `admission:${err.submissionId}:unknown`, err.message, [], lastHuman.messageId, `admission:${err.submissionId}:unknown`).catch(() => { });
3160
- await liveStatus?.pauseForUnconfirmedResult(err.message).catch(() => { });
3266
+ await liveStatus?.pauseForUnconfirmedResult(err.message, undefined, "submitting").catch(() => { });
3161
3267
  break;
3162
3268
  }
3163
3269
  let pause = err instanceof SessionWaitPaused ? err : undefined;
3164
- if (!pause && isWaitTimeout(err instanceof Error ? err.message : String(err))) {
3270
+ if (!pause && !(err instanceof RunTerminalError) && isWaitTimeout(err instanceof Error ? err.message : String(err))) {
3165
3271
  const state = await this.readSessionStatus(chatId);
3166
3272
  if (state.running)
3167
3273
  pause = new SessionWaitPaused(state, true);
@@ -3170,7 +3276,7 @@ export class FeishuBot {
3170
3276
  const target = this.activeDeliveryTargets.get(chatId);
3171
3277
  if (target?.triggerId === triggerId && target.runId)
3172
3278
  this.openclawClient.releasePausedWait?.(target.runId);
3173
- await liveStatus?.pauseForUnconfirmedResult(pause.message, `${this.config.name} 等待暂停 · ${pause.snapshot.status}`).catch(() => { });
3279
+ await liveStatus?.pauseForUnconfirmedResult(pause.message, `${this.config.name} 等待暂停 · ${pause.snapshot.status}`, "paused").catch(() => { });
3174
3280
  await this.sendWaitPaused(chatId, triggerId, lastHuman.messageId, pause).catch(() => { });
3175
3281
  // A wait notice is not the final answer and must never claim its key.
3176
3282
  // Do not mark DONE, retry this input, or abort the running session.
@@ -3226,7 +3332,7 @@ export class FeishuBot {
3226
3332
  if (row && !row.admissionSubmissionId && !row.admittedRunId)
3227
3333
  this.store.saveInsertion(this.config.name, chatId, id, row.insertionId, 'target_changed', { ...row, attempted: false, reason: 'guarded pre-admission failure; input retained without sync or new-ID retry' });
3228
3334
  }
3229
- await liveStatus?.pauseForUnconfirmedResult('Guarded admission unavailable; input custody retained without replay').catch(() => { });
3335
+ await liveStatus?.pauseForUnconfirmedResult('Guarded admission unavailable; input custody retained without replay', undefined, 'blocked').catch(() => { });
3230
3336
  break;
3231
3337
  }
3232
3338
  console.error(`[${this.config.name}] processQueue error:`, safeErrorSummary(err));
@@ -3236,13 +3342,30 @@ export class FeishuBot {
3236
3342
  if (lastHuman.messageId) {
3237
3343
  // Error message goes through the normal delivery path; the live status
3238
3344
  // is a separate message that we mark interrupted afterwards.
3239
- await this.enqueueAndDispatchDelivery(chatId, "provider_error", `trigger:${triggerId}:provider-error`, errorText, [], lastHuman.messageId, `trigger:${triggerId}:provider-error`)
3240
- .then(outcome => {
3241
- errorOutcomeConfirmed = outcome.state === 'delivered';
3242
- if (errorOutcomeConfirmed && current() && triggerId)
3243
- this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
3244
- })
3245
- .catch(() => { });
3345
+ if (err instanceof RunTerminalError) {
3346
+ const p = this.scopeRunError(chatId, err.provenance);
3347
+ this.store.observeRunError(this.config.name, chatId, p);
3348
+ await this.reconcileErrorNotices(chatId);
3349
+ errorOutcomeConfirmed = this.store.findRunErrorOwner(this.config.name, chatId, p)?.status === 'delivered';
3350
+ }
3351
+ else if (this.store.hasDeliveredReply(this.config.name, chatId, triggerId)) {
3352
+ // This trigger already received its visible reply. A later terminal
3353
+ // error for the same run must NOT contradict a delivered answer:
3354
+ // the user would see "did not complete" under their own answer.
3355
+ errorOutcomeConfirmed = true;
3356
+ console.warn(`[${this.config.name}] provider_error suppressed for ${chatId.slice(-8)} trigger=${triggerId}: reply already delivered`);
3357
+ }
3358
+ else
3359
+ await this.enqueueAndDispatchDelivery(chatId, "provider_error", `trigger:${triggerId}:provider-error`, errorText, [], lastHuman.messageId, `trigger:${triggerId}:provider-error`, undefined, foregroundRunId ? (() => {
3360
+ const p = this.scopeRunError(chatId, { sessionKey: canonicalPhysicalSessionKey(this.getSessionKey(chatId), this.profile), sessionId: owner.sessionId, runId: foregroundRunId, source: 'chat', state: 'error', detail: err instanceof Error ? err.message : undefined });
3361
+ return { sessionKey: p.sessionKey, sessionId: p.sessionId, runId: p.runId, error: p };
3362
+ })() : undefined)
3363
+ .then(outcome => {
3364
+ errorOutcomeConfirmed = outcome.state === 'delivered';
3365
+ if (errorOutcomeConfirmed && current() && triggerId)
3366
+ this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
3367
+ })
3368
+ .catch(() => { });
3246
3369
  await liveStatus?.fail().catch(() => { });
3247
3370
  }
3248
3371
  else {
@@ -3485,13 +3608,14 @@ export class FeishuBot {
3485
3608
  thinkingLevel: meta.thinkingLevel,
3486
3609
  toolCalls: meta.toolCalls,
3487
3610
  noReply: Boolean(meta.noReply),
3611
+ inputStatus: meta.inputStatus ? { ...meta.inputStatus, items: meta.inputStatus.items.map(item => item.kind === 'delivery_pending' ? { ...item, kind: 'completed' } : item) } : { items: [{ kind: meta.noReply ? "no_reply" : "completed", at: 0 }] },
3488
3612
  state: "done",
3489
3613
  };
3490
3614
  await this.patchLiveStatusCard(messageId, view, chatId);
3491
3615
  }
3492
3616
  async readSessionStatus(chatId) {
3493
3617
  try {
3494
- return await this.openclawClient.getSessionRuntimeStatus(this.getSessionKey(chatId));
3618
+ return await this.openclawClient.getSessionRuntimeStatus(this.physicalSessionKey(chatId));
3495
3619
  }
3496
3620
  catch {
3497
3621
  return normalizeSessionRuntimeStatus(undefined);
@@ -3504,7 +3628,7 @@ export class FeishuBot {
3504
3628
  formatUserVisibleError(err, chatId) {
3505
3629
  if (err instanceof SessionWaitPaused)
3506
3630
  return err.message;
3507
- return formatProviderError(err, this.config.name, this.config.model, this.chatLocale(chatId));
3631
+ return formatProviderError(err, this.config.name, this.config.model || "", this.chatLocale(chatId));
3508
3632
  }
3509
3633
  isBridgeControlReply(text) {
3510
3634
  const t = text.trim();
@@ -3589,36 +3713,34 @@ export class FeishuBot {
3589
3713
  }
3590
3714
  }
3591
3715
  /**
3592
- * After a reply is delivered, alert the user once if context usage crossed the
3593
- * threshold (default 80%), so they can compact before a session bloats into the
3594
- * timeout/lock/overflow failure modes. We only alert on a fresh crossing: once
3595
- * alerted we stay quiet until usage drops back below the threshold (e.g. after
3596
- * a compact), then re-arm.
3716
+ * After a message completes, alert the user when context usage is at or above
3717
+ * the threshold (default 80%), so they can compact before the session bloats
3718
+ * into the timeout/lock/overflow failure modes.
3719
+ *
3720
+ * The check runs after EVERY completed message and re-notifies on every message
3721
+ * while usage stays above the threshold. There is deliberately no per-chat
3722
+ * "already warned" latch: with one, a single failed send (or a stale latch) would
3723
+ * silently suppress every later warning, and the user would stop being told to
3724
+ * compact while their context keeps growing.
3597
3725
  */
3598
3726
  async maybeAlertHighContext(chatId) {
3599
3727
  const threshold = contextAlertPct();
3600
3728
  if (threshold <= 0)
3601
3729
  return;
3602
3730
  try {
3603
- const sessionKey = this.getSessionKey(chatId);
3731
+ const sessionKey = this.physicalSessionKey(chatId);
3604
3732
  const pct = await this.getContextUsagePercent(sessionKey);
3605
- if (pct <= 0)
3733
+ if (pct <= 0 || pct < threshold)
3606
3734
  return;
3607
- const alerted = this.contextAlerted.get(chatId) || false;
3608
- if (pct >= threshold) {
3609
- if (alerted)
3610
- return; // already warned this cycle
3611
- this.contextAlerted.set(chatId, true);
3612
- const en = this.isEn(chatId);
3613
- const text = en
3614
- ? `\u26a0\ufe0f Context is at ${pct}% (\u2265${threshold}%). Consider /compact soon to avoid timeouts/overflow.`
3615
- : `\u26a0\ufe0f \u4e0a\u4e0b\u6587\u5df2\u8fbe ${pct}%\uff08\u2265${threshold}%\uff09\uff0c\u5efa\u8bae\u5c3d\u5feb /compact \u538b\u7f29\uff0c\u907f\u514d\u8d85\u65f6/\u7206\u4e0a\u4e0b\u6587\u3002`;
3616
- await this.sendMessage(chatId, text).catch(() => { });
3617
- }
3618
- else if (pct < threshold - 5) {
3619
- // Re-arm once usage clearly dropped (hysteresis to avoid flapping).
3620
- this.contextAlerted.set(chatId, false);
3621
- }
3735
+ const en = this.isEn(chatId);
3736
+ const text = en
3737
+ ? `\u26a0\ufe0f Context is at ${pct}% (\u2265${threshold}%). Consider /compact soon to avoid timeouts/overflow.`
3738
+ : `\u26a0\ufe0f \u4e0a\u4e0b\u6587\u5df2\u8fbe ${pct}%\uff08\u2265${threshold}%\uff09\uff0c\u5efa\u8bae\u5c3d\u5feb /compact \u538b\u7f29\uff0c\u907f\u514d\u8d85\u65f6/\u7206\u4e0a\u4e0b\u6587\u3002`;
3739
+ // Best-effort: a failed alert must not block delivery, but it is logged so
3740
+ // a persistently undelivered warning stays visible instead of silent.
3741
+ await this.sendMessage(chatId, text).catch((err) => {
3742
+ console.warn(`[${this.config.name}] high-context alert send failed for ${chatId.slice(-8)}: ${this.errorSummary(err)}`);
3743
+ });
3622
3744
  }
3623
3745
  catch {
3624
3746
  // best-effort; never block delivery
@@ -3779,7 +3901,12 @@ export class FeishuBot {
3779
3901
  const previous = this.deliveryTargetsByRun.get(key);
3780
3902
  if (previous?.timer)
3781
3903
  clearTimeout(previous.timer);
3782
- this.store.bindDeliveryRun(this.config.name, chatId, runId, { ...this.store.getDeliveryRunTarget(this.config.name, chatId, runId), triggerId: target.triggerId, messageId: target.messageId, runId, stopGeneration: this.stopGeneration(chatId), sessionKey: `agent:main:${this.getSessionKey(chatId).replace(/^agent:[^:]+:/, '')}` });
3904
+ const persisted = this.store.getDeliveryRunTarget(this.config.name, chatId, runId);
3905
+ const owner = this.queueOwners.get(chatId);
3906
+ this.store.bindDeliveryRun(this.config.name, chatId, runId, { ...persisted, triggerId: target.triggerId, messageId: target.messageId, runId,
3907
+ sessionId: persisted?.sessionId || (owner?.runId === runId ? owner.sessionId : undefined),
3908
+ botIdentity: persisted?.botIdentity || this.config.appId, stopGeneration: persisted?.stopGeneration ?? this.stopGeneration(chatId),
3909
+ sessionKey: persisted?.sessionKey || canonicalPhysicalSessionKey(this.getSessionKey(chatId), this.profile) });
3783
3910
  this.deliveryTargetsByRun.set(key, {
3784
3911
  triggerId: target.triggerId,
3785
3912
  messageId: target.messageId,
@@ -3847,7 +3974,7 @@ export class FeishuBot {
3847
3974
  this.delayedFailureTimers.set(chatId, timer);
3848
3975
  }
3849
3976
  deliverySessionKey(chatId) {
3850
- return this.getSessionKey(chatId);
3977
+ return this.physicalSessionKey(chatId);
3851
3978
  }
3852
3979
  stableHash(text) {
3853
3980
  let hash = 2166136261;
@@ -3866,7 +3993,7 @@ export class FeishuBot {
3866
3993
  }
3867
3994
  async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey, finalReplyModel, provenance, errorNoticeId, completionTriggerIds) {
3868
3995
  if (provenance?.sessionKey && !/^agent:/.test(provenance.sessionKey))
3869
- provenance = { ...provenance, sessionKey: `agent:main:${provenance.sessionKey}` };
3996
+ provenance = { ...provenance, sessionKey: canonicalPhysicalSessionKey(provenance.sessionKey, this.profile) };
3870
3997
  if (provenance?.sessionKey && provenance.runId && this.store.isStoppedObservation(provenance.sessionKey, provenance.runId))
3871
3998
  return { state: "suppressed", outboxIds: [] };
3872
3999
  if (text.trim().toUpperCase() === 'NO_REPLY')
@@ -3878,9 +4005,9 @@ export class FeishuBot {
3878
4005
  const contentHash = this.stableHash(normalizedPayload);
3879
4006
  const finalDeliveryKey = deliveryKey || sourceId;
3880
4007
  const deliveryMetaJson = JSON.stringify({
3881
- botIdentity: this.config.appId,
4008
+ botIdentity: provenance?.error?.botIdentity || this.config.appId,
3882
4009
  ...(completionTriggerIds?.length ? { completionTriggerIds } : {}),
3883
- deliveryStopEpoch: this.stopGeneration(chatId),
4010
+ deliveryStopEpoch: provenance?.error?.stopEpoch ?? this.stopGeneration(chatId),
3884
4011
  ...(finalReplyModel?.trim() ? { model: finalReplyModel.trim() } : {}),
3885
4012
  ...(provenance ? { provenance } : {}), ...(errorNoticeId ? { errorNoticeId } : {}),
3886
4013
  });
@@ -4035,13 +4162,52 @@ export class FeishuBot {
4035
4162
  }
4036
4163
  return this.store.getDeliveryDispatchOutcome(this.config.name, chatId, finalDeliveryKey);
4037
4164
  }
4165
+ scopeRunError(chatId, p) {
4166
+ const key = canonicalPhysicalSessionKey(p.sessionKey, this.profile);
4167
+ const target = this.store.getDeliveryRunTarget(this.config.name, chatId, p.runId);
4168
+ const observation = this.store.getRunObservation(key, p.runId);
4169
+ // Pre-upgrade targets may lack app/physical fields. A known exact legacy
4170
+ // outbox owner is original route evidence, never permission for the current
4171
+ // replacement app to take over its notice.
4172
+ const legacy = !target?.botIdentity ? this.store.findRunErrorOwner(this.config.name, chatId, { ...p, sessionKey: key }) : undefined;
4173
+ let meta;
4174
+ try {
4175
+ meta = JSON.parse(legacy?.deliveryMetaJson || '{}');
4176
+ }
4177
+ catch {
4178
+ meta = {};
4179
+ }
4180
+ return { ...p, sessionKey: key, sessionId: p.sessionId || target?.sessionId || observation?.sessionId || meta.provenance?.sessionId,
4181
+ botIdentity: p.botIdentity || target?.botIdentity || meta.botIdentity || this.config.appId,
4182
+ stopEpoch: p.stopEpoch ?? target?.stopGeneration ?? meta.deliveryStopEpoch ?? this.stopGeneration(chatId) };
4183
+ }
4184
+ /** Shared original-error authorization for journaled terminals AND ordinary
4185
+ * admitted provider errors. Current outbox/config metadata is not authority
4186
+ * to replace the retained admission route or physical target. */
4187
+ errorRouteRevocation(chatId, p) {
4188
+ const original = this.store.getDeliveryRunTarget(this.config.name, chatId, p.runId);
4189
+ if (original?.messageId && this.store.isMessageRecalled(original.messageId) ||
4190
+ original?.sessionId && p.sessionId && original.sessionId !== p.sessionId ||
4191
+ original?.sessionKey && canonicalPhysicalSessionKey(original.sessionKey, this.profile) !== canonicalPhysicalSessionKey(p.sessionKey, this.profile))
4192
+ return 'original_error_target_revoked';
4193
+ const epoch = this.stopGeneration(chatId);
4194
+ if (p.botIdentity && p.botIdentity !== this.config.appId || original?.botIdentity && original.botIdentity !== this.config.appId ||
4195
+ p.stopEpoch !== undefined && p.stopEpoch !== epoch || original?.stopGeneration !== undefined && original.stopGeneration !== epoch)
4196
+ return 'original_error_route_revoked';
4197
+ }
4038
4198
  async inspectErrorNotice(record) {
4199
+ const revoked = this.errorRouteRevocation(record.chatId, record.provenance);
4200
+ if (revoked)
4201
+ return { state: 'stopped', reason: revoked };
4039
4202
  if (this.store.hasRunFinalDelivery(this.config.name, record.chatId, record.provenance, true)) {
4040
4203
  return { state: "suppressed", reason: "exact_run_final_platform_delivery_confirmed" };
4041
4204
  }
4042
4205
  if (this.store.hasRunFinalDelivery(this.config.name, record.chatId, record.provenance, false)) {
4043
4206
  return { state: "pending", reason: "exact_run_final_delivery_pending" };
4044
4207
  }
4208
+ const existing = this.store.findRunErrorOwner(this.config.name, record.chatId, record.provenance);
4209
+ if (!record.provenance.runId.startsWith('announce:') && existing && existing.deliveryKey !== `run-error:${errorNoticeIdentity(record.provenance)}`)
4210
+ return { state: 'suppressed', reason: 'existing_exact_run_error_owner' };
4045
4211
  let verdict;
4046
4212
  try {
4047
4213
  verdict = await this.openclawClient.inspectRunError(record.provenance);
@@ -4051,8 +4217,13 @@ export class FeishuBot {
4051
4217
  }
4052
4218
  // Presentation only: leave exact-run finality, stop suppression and durable
4053
4219
  // outbox keys owned by the existing verdict; no retry/model fallback here.
4054
- if (verdict.state === "terminal" && verdict.reason === "ordinary_gateway_error") {
4055
- verdict = { ...verdict, text: this.formatUserVisibleError(record.provenance.detail, record.chatId) };
4220
+ if (verdict.state === "terminal" && ['ordinary_gateway_error', 'closed_gateway_terminal', 'exact_run_terminal_error'].includes(verdict.reason)) {
4221
+ const reason = closedStopReason(record.provenance.stopReason) || verdict.stopReason;
4222
+ const en = this.isEn(record.chatId);
4223
+ verdict = { ...verdict, text: reason === 'timeout' ? this.formatUserVisibleError({ code: 'execution_timeout' }, record.chatId)
4224
+ : reason === 'superseded' ? (en ? '⚠️ This run ended after being superseded by a newer session writer; no user stop is inferred.' : '⚠️ 本次运行已被新的会话写入者取代而终止;不据此认定用户停止。')
4225
+ : reason === 'restart' ? (en ? '⚠️ This run ended for a Gateway restart; no user stop is inferred.' : '⚠️ 本次运行因 Gateway 重启而终止;不据此认定用户停止。')
4226
+ : this.formatUserVisibleError(record.provenance.detail, record.chatId) };
4056
4227
  }
4057
4228
  // A stop or final may have arrived during the read-only lookup.
4058
4229
  const latest = this.store.getErrorNotice(this.config.name, record.chatId, record.noticeId);
@@ -4071,6 +4242,8 @@ export class FeishuBot {
4071
4242
  if (this.store.hasRunFinalDelivery(this.config.name, record.chatId, record.provenance, false)) {
4072
4243
  return { state: "pending", reason: "exact_run_final_delivery_pending" };
4073
4244
  }
4245
+ if (verdict.state === 'terminal' && verdict.stopReason && !record.provenance.stopReason)
4246
+ record.provenance = { ...record.provenance, stopReason: verdict.stopReason };
4074
4247
  return verdict;
4075
4248
  }
4076
4249
  deferErrorNotice(record, verdict) {
@@ -4153,7 +4326,7 @@ export class FeishuBot {
4153
4326
  this.store.saveErrorNotice(record);
4154
4327
  if (verdict.state === "terminal" && verdict.terminalKey && verdict.text) {
4155
4328
  // Independent identity: never take the result's trigger/final key.
4156
- await this.enqueueAndDispatchDelivery(chatId, "run_error", verdict.terminalKey, verdict.text, [], undefined, verdict.terminalKey, undefined, { sourceType: "run_error", sessionKey: record.provenance.sessionKey, runId: record.provenance.runId, error: record.provenance, terminalEvidence: verdict }, record.noticeId);
4329
+ await this.enqueueAndDispatchDelivery(chatId, "run_error", verdict.terminalKey, verdict.text, [], undefined, verdict.terminalKey, undefined, { sourceType: "run_error", sessionKey: record.provenance.sessionKey, sessionId: record.provenance.sessionId, runId: record.provenance.runId, error: record.provenance, terminalEvidence: verdict }, record.noticeId);
4157
4330
  // The send-time recheck may have parked or suppressed the observation.
4158
4331
  const refreshed = this.store.getErrorNotice(this.config.name, chatId, record.noticeId);
4159
4332
  if (refreshed?.verdict && ["pending", "suppressed", "stopped"].includes(refreshed.verdict.state))
@@ -4301,7 +4474,7 @@ export class FeishuBot {
4301
4474
  }
4302
4475
  const notice = JSON.parse(item.deliveryMetaJson || '{}').insertionNotice;
4303
4476
  if (notice) {
4304
- const info = await this.openclawClient.getSessionInfo(this.getSessionKey(chatId), 1500).catch(() => null);
4477
+ const info = await this.openclawClient.getSessionInfo(this.physicalSessionKey(chatId), 1500).catch(() => null);
4305
4478
  if (!this.insertionNoticeCurrent(chatId, notice) || info?.session?.archived === true ||
4306
4479
  info?.session?.sessionId && info.session.sessionId !== notice.sessionId) {
4307
4480
  this.store.markDeliveryFailed(item.id);
@@ -4326,8 +4499,13 @@ export class FeishuBot {
4326
4499
  const meta = JSON.parse(item.deliveryMetaJson || '{}'), p = meta.provenance;
4327
4500
  if (!this.insertionNoticeCurrent(chatId, meta.insertionNotice))
4328
4501
  throw Object.assign(new Error('Insertion notice no longer current'), { deliveryStopped: true });
4329
- if ((p?.final || item.sourceType === 'run_error') &&
4330
- (typeof meta.deliveryStopEpoch === 'number' && meta.deliveryStopEpoch !== this.stopGeneration(chatId) ||
4502
+ if (p?.error) {
4503
+ const revoked = this.errorRouteRevocation(chatId, p.error);
4504
+ if (revoked)
4505
+ throw Object.assign(new Error(`Original error delivery revoked: ${revoked}`), { deliveryStopped: true });
4506
+ }
4507
+ if ((p?.final || p?.error || item.sourceType === 'run_error') &&
4508
+ (meta.botIdentity && meta.botIdentity !== this.config.appId || typeof meta.deliveryStopEpoch === 'number' && meta.deliveryStopEpoch !== this.stopGeneration(chatId) ||
4331
4509
  p?.sessionKey && p.runId && this.store.isStoppedObservation(p.sessionKey, p.runId))) {
4332
4510
  throw Object.assign(new Error('Delivery revoked by explicit stop before platform attempt'), { deliveryStopped: true });
4333
4511
  }
@@ -4699,7 +4877,7 @@ export class FeishuBot {
4699
4877
  }
4700
4878
  if (pause) {
4701
4879
  discussionWaitPaused = true;
4702
- await liveStatus?.pauseForUnconfirmedResult(pause.message, `${this.config.name} 等待暂停 · ${pause.snapshot.status}`).catch(() => { });
4880
+ await liveStatus?.pauseForUnconfirmedResult(pause.message, `${this.config.name} 等待暂停 · ${pause.snapshot.status}`, "paused").catch(() => { });
4703
4881
  await this.sendWaitPaused(chatId, 0, undefined, pause).catch(() => { });
4704
4882
  }
4705
4883
  else
@@ -4735,7 +4913,7 @@ export class FeishuBot {
4735
4913
  const outcome = await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments, undefined, undefined, this.config.model);
4736
4914
  discussionDeliveryConfirmed = outcome.state === 'delivered';
4737
4915
  if (!discussionDeliveryConfirmed)
4738
- await liveStatus?.pauseForUnconfirmedResult("飞书投递回执尚未确认;原投递仍受保管").catch(() => { });
4916
+ await liveStatus?.pauseForUnconfirmedResult("飞书投递回执尚未确认;原投递仍受保管", undefined, "delivery_pending").catch(() => { });
4739
4917
  }
4740
4918
  catch (err) {
4741
4919
  await liveStatus?.fail().catch(() => { });
@@ -4755,13 +4933,46 @@ export class FeishuBot {
4755
4933
  }
4756
4934
  return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible && discussionDeliveryConfirmed };
4757
4935
  }
4758
- buildLiveStatusCard(view, chatId) {
4936
+ liveInputElements(view, chatId, limits) {
4937
+ const context = view.inputStatus;
4938
+ if (!context)
4939
+ return [];
4940
+ const en = this.isEn(chatId);
4941
+ const scoped = !!chatId && context.chatId === chatId && context.botName === this.config.name && context.appId === this.config.appId &&
4942
+ context.stopEpoch === this.stopGeneration(chatId);
4943
+ const preview = (hint) => {
4944
+ const source = scoped && hint.rowId ? this.store.getMessageByRowId(hint.rowId) : undefined;
4945
+ if (!source || source.chatId !== chatId || source.senderType !== 'human' || !hint.messageId || source.messageId !== hint.messageId)
4946
+ return en ? '[Message body unavailable]' : '[消息正文暂不可用]';
4947
+ if (this.store.isMessageRecalled(source.messageId))
4948
+ return en ? '[Message recalled]' : '[消息已撤回]';
4949
+ return hint.previewSource === 'visible' ? visibleInputPreview(hint.preview ?? '', limits.preview, en) : inputPreview(hint.preview ?? source.content, limits.preview, en);
4950
+ };
4951
+ const selected = selectInputHints(context.items, limits.members);
4952
+ const stamp = (at) => { const sec = Number.isFinite(at) ? Math.max(0, Math.floor(at)) : 0; return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`; };
4953
+ const lines = selected.map(hint => `${stamp(hint.at)} ${hintLabel(hint.kind, en)} ${inertInputText(preview(hint))}${hintDetail(hint.kind, en) ? '\n ↳ ' + inertInputText(hintDetail(hint.kind, en)) : ''}`);
4954
+ if (context.items.length > selected.length)
4955
+ lines.splice(Math.min(1, lines.length), 0, en ? `… ${context.items.length - selected.length} other inputs omitted; newest inputs retained` : `… 另有 ${context.items.length - selected.length} 条输入未展开,保留最新输入`);
4956
+ if (context.note)
4957
+ lines.push(`${stamp(context.note.at)} ${hintLabel(context.note.kind, en)} ${selected.map(hint => inertInputText(preview(hint))).join(' · ')}\n ↳ ${inertInputText(hintDetail(context.note.kind, en))}`);
4958
+ return lines.length ? [{ tag: 'markdown', content: lines.join('\n') }] : [];
4959
+ }
4960
+ liveStatusRequest(view, chatId, request) {
4961
+ for (const limits of INPUT_DISPLAY_LIMITS) {
4962
+ const value = request(this.buildLiveStatusCard(view, chatId, limits));
4963
+ if (Buffer.byteLength(JSON.stringify(value), 'utf8') <= LIVE_CARD_BYTES - 512)
4964
+ return value; // reserve SDK envelope overhead
4965
+ }
4966
+ throw new LiveStatusEffectSuppressedError('Live status request exceeds local outer JSON byte budget; no SDK call');
4967
+ }
4968
+ buildLiveStatusCard(view, chatId, limits = INPUT_DISPLAY_LIMITS[0]) {
4759
4969
  const en = this.isEn(chatId);
4970
+ const inputElements = this.liveInputElements(view, chatId, limits);
4760
4971
  if (view.state === "replied") {
4761
4972
  // Neutral UI-only closure, not a green business/input-completion claim.
4762
4973
  const summary = en ? `💬 Linked run replied · ⏱ ${view.elapsed}` : `💬 关联轮次已回复 · ⏱ 耗时${view.elapsed}`;
4763
4974
  return { schema: "2.0", config: { update_multi: true, width_mode: "fill" },
4764
- body: { elements: [{ tag: "markdown", content: `<font color='grey'>${summary}</font>` }] } };
4975
+ body: { elements: [...inputElements, { tag: "markdown", content: `<font color='grey'>${summary}</font>` }] } };
4765
4976
  }
4766
4977
  // Clean finish (done / NO_REPLY): keep it minimal — no header, no footer, just
4767
4978
  // one compact grey line (status emoji + tool-call count + total elapsed) so
@@ -4770,18 +4981,18 @@ export class FeishuBot {
4770
4981
  const statusEmoji = view.noReply ? "💤" : "✅";
4771
4982
  const summary = this.completionSummary({
4772
4983
  toolCalls: view.toolCalls,
4773
- elapsed: view.elapsed,
4774
- model: view.model,
4775
- thinkingLevel: view.thinkingLevel,
4984
+ elapsed: displayClip(view.elapsed, 32),
4985
+ model: view.model ? displayClip(view.model, 160) : undefined,
4986
+ thinkingLevel: view.thinkingLevel ? displayClip(view.thinkingLevel, 80) : undefined,
4776
4987
  locale: en ? "en" : "zh",
4777
4988
  }, statusEmoji);
4778
4989
  return {
4779
4990
  schema: "2.0",
4780
4991
  config: { update_multi: true, width_mode: "fill" },
4781
- body: { elements: [{ tag: "markdown", content: `<font color='grey'>${summary}</font>` }] },
4992
+ body: { elements: [...inputElements, { tag: "markdown", content: `<font color='grey'>${summary}</font>` }] },
4782
4993
  };
4783
4994
  }
4784
- const elements = [];
4995
+ const elements = [...inputElements];
4785
4996
  // Running OR failed: show the recent activity window (each line prefixed with
4786
4997
  // the relative time mm:ss). On failure we deliberately KEEP the last lines so
4787
4998
  // the steps leading up to the error/kill/timeout are visible for debugging.
@@ -4794,24 +5005,26 @@ export class FeishuBot {
4794
5005
  : "•";
4795
5006
  const fmtAt = (sec) => `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")}`;
4796
5007
  const content = view.lines
5008
+ .filter(l => !l.inputHint && !(view.inputStatus?.botName && l.kind === 'lifecycle'))
4797
5009
  .map((l) => {
4798
5010
  const stamp = l.kind === "summary" ? "" : `\`${fmtAt(l.at)}\` `;
4799
- return `${stamp}${iconFor(l.kind)} ${this.escapeCardText(l.text)}`;
5011
+ return `${stamp}${iconFor(l.kind)} ${this.escapeCardText(displayClip(l.text, limits.activity))}`;
4800
5012
  })
4801
5013
  .join("\n");
4802
- elements.push({ tag: "markdown", content });
5014
+ if (content)
5015
+ elements.push({ tag: "markdown", content });
4803
5016
  }
4804
- else {
5017
+ else if (!inputElements.length) {
4805
5018
  elements.push({ tag: "markdown", content: view.state === "queued" ? (en ? "_Received locally; waiting for admission_" : "_本地已收件,等待提交确认_") : view.state === "admitted" ? (en ? "_Accepted; waiting for activity_" : "_已接收,等待活动_") : (en ? "_working…_" : "_正在启动…_") });
4806
5019
  }
4807
5020
  elements.push({ tag: "hr" });
4808
5021
  // Keep the active model visible while the run is in progress or failed.
4809
5022
  // The final reply also keeps model attribution so it remains self-contained.
4810
- const footerBits = [en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`];
5023
+ const footerBits = [en ? `⏱ ${displayClip(view.elapsed, 32)}` : `⏱ 已用 ${displayClip(view.elapsed, 32)}`];
4811
5024
  if (view.model?.trim()) {
4812
5025
  const modelBit = view.thinkingLevel?.trim()
4813
- ? `🧠 ${this.escapeCardText(view.model.trim())} · ${this.escapeCardText(view.thinkingLevel.trim())}`
4814
- : `🧠 ${this.escapeCardText(view.model.trim())}`;
5026
+ ? `🧠 ${this.escapeCardText(displayClip(view.model.trim(), 160))} · ${this.escapeCardText(displayClip(view.thinkingLevel.trim(), 80))}`
5027
+ : `🧠 ${this.escapeCardText(displayClip(view.model.trim(), 160))}`;
4815
5028
  footerBits.push(modelBit);
4816
5029
  }
4817
5030
  elements.push({ tag: "markdown", content: `<font color='grey'>${footerBits.join(" · ")}</font>` });
@@ -4822,7 +5035,7 @@ export class FeishuBot {
4822
5035
  // rejects patches on cards that did not declare it before and after.
4823
5036
  config: { update_multi: true, width_mode: "fill" },
4824
5037
  header: {
4825
- title: { tag: "plain_text", content: view.title },
5038
+ title: { tag: "plain_text", content: displayClip(view.title, 160) },
4826
5039
  template,
4827
5040
  },
4828
5041
  body: { elements },
@@ -4859,14 +5072,8 @@ export class FeishuBot {
4859
5072
  }
4860
5073
  async sendLiveStatusCard(chatId, view) {
4861
5074
  try {
4862
- const res = await this.client.im.message.create({
4863
- params: { receive_id_type: "chat_id" },
4864
- data: {
4865
- receive_id: chatId,
4866
- content: JSON.stringify(this.buildLiveStatusCard(view, chatId)),
4867
- msg_type: "interactive",
4868
- },
4869
- });
5075
+ const request = this.liveStatusRequest(view, chatId, card => ({ params: { receive_id_type: "chat_id" }, data: { receive_id: chatId, content: JSON.stringify(card), msg_type: "interactive" } }));
5076
+ const res = await this.client.im.message.create(request);
4870
5077
  if (typeof res.code === "number" && res.code !== 0)
4871
5078
  throw new LiveStatusEffectRejectedError(`Feishu rejected card: ${res.code}`);
4872
5079
  if (res.code !== 0)
@@ -4881,13 +5088,8 @@ export class FeishuBot {
4881
5088
  }
4882
5089
  }
4883
5090
  async replyLiveStatusCard(messageId, view, chatId) {
4884
- const res = await this.client.im.message.reply({
4885
- path: { message_id: messageId },
4886
- data: {
4887
- content: JSON.stringify(this.buildLiveStatusCard(view, chatId)),
4888
- msg_type: "interactive",
4889
- },
4890
- });
5091
+ const request = this.liveStatusRequest(view, chatId, card => ({ path: { message_id: messageId }, data: { content: JSON.stringify(card), msg_type: "interactive" } }));
5092
+ const res = await this.client.im.message.reply(request);
4891
5093
  if (typeof res.code === "number" && res.code !== 0)
4892
5094
  throw new LiveStatusEffectRejectedError(`Feishu rejected card: ${res.code}`);
4893
5095
  if (res.code !== 0)
@@ -4895,10 +5097,8 @@ export class FeishuBot {
4895
5097
  return res?.data?.message_id || res?.message_id;
4896
5098
  }
4897
5099
  async patchLiveStatusCard(messageId, view, chatId) {
4898
- const res = await this.client.im.message.patch({
4899
- path: { message_id: messageId },
4900
- data: { content: JSON.stringify(this.buildLiveStatusCard(view, chatId)) },
4901
- });
5100
+ const request = this.liveStatusRequest(view, chatId, card => ({ path: { message_id: messageId }, data: { content: JSON.stringify(card) } }));
5101
+ const res = await this.client.im.message.patch(request);
4902
5102
  if (typeof res.code === "number" && res.code !== 0)
4903
5103
  throw new LiveStatusEffectRejectedError(`Feishu rejected card: ${res.code}`);
4904
5104
  if (res.code !== 0)
@@ -5798,7 +5998,7 @@ export class FeishuBot {
5798
5998
  return;
5799
5999
  }
5800
6000
  if (nextModel === this.config.model) {
5801
- await this.replyMessage(messageId, `🤖 ${this.config.name} 已经在使用:${this.config.model}`);
6001
+ await this.replyMessage(messageId, `🤖 ${this.config.name} 已经在使用:${this.config.model || `profile ${this.profile}`}`);
5802
6002
  return;
5803
6003
  }
5804
6004
  if (!this.configPath) {
@@ -5829,7 +6029,7 @@ export class FeishuBot {
5829
6029
  * Handle /status command: show current session info.
5830
6030
  */
5831
6031
  async handleStatusCommand(chatId, chatType, messageId) {
5832
- const sessionKey = this.getSessionKey(chatId);
6032
+ const sessionKey = this.physicalSessionKey(chatId);
5833
6033
  const chatInfo = this.store.getChatInfo(chatId);
5834
6034
  const msgCount = this.store.getMessageCount(chatId);
5835
6035
  let session = null;
@@ -5976,7 +6176,7 @@ export class FeishuBot {
5976
6176
  }
5977
6177
  }
5978
6178
  async handleCompactCommand(chatId, messageId, args = "") {
5979
- const sessionKey = this.getSessionKey(chatId);
6179
+ const sessionKey = this.physicalSessionKey(chatId);
5980
6180
  const en = this.isEn(chatId);
5981
6181
  // Argument parsing — modes are strictly separated:
5982
6182
  // /compact → transcript tail trim (default, model-free)
@@ -6077,7 +6277,7 @@ export class FeishuBot {
6077
6277
  * then verify via describe.
6078
6278
  */
6079
6279
  async handleResetCommand(chatId, messageId) {
6080
- const sessionKey = this.getSessionKey(chatId);
6280
+ const sessionKey = this.physicalSessionKey(chatId);
6081
6281
  try {
6082
6282
  await this.assertIdentityEffect();
6083
6283
  // Go supervision: a reset replaces the executor session generation; pause
@@ -6100,7 +6300,7 @@ export class FeishuBot {
6100
6300
  await new Promise((r) => setTimeout(r, 2000));
6101
6301
  this.assertIdentityCurrent();
6102
6302
  // A held reset must not initialize an unobserved successor generation.
6103
- this.initializedSessions.delete(sessionKey);
6303
+ this.initializedSessions.delete(this.getSessionKey(chatId));
6104
6304
  if (!held)
6105
6305
  await this.ensureSession(chatId);
6106
6306
  await this.replyMessage(messageId, `✅ Session 已重置\n模型: ${this.config.model}`);
@@ -6114,8 +6314,42 @@ export class FeishuBot {
6114
6314
  * runs for the session, clears the busy lock and every pending trigger, and
6115
6315
  * resets stuck reactions so new messages are processed normally again.
6116
6316
  */
6317
+ /** Release every peer bot's in-memory scheduling state for one chat.
6318
+ * Each bot instance keeps its own queueRuns / queueOwners / busyChats /
6319
+ * pendingAckMessages maps; a durable row cleanup cannot reach those. A
6320
+ * group-scoped /stop must retire them too, otherwise the peer's queue owner
6321
+ * survives and its next message is treated as "busy" forever. */
6322
+ releasePeerBotsForChat(chatId) {
6323
+ let released = 0;
6324
+ for (const peer of Array.from(FeishuBot.allBots.values())) {
6325
+ if (peer === this || peer.store !== this.store)
6326
+ continue;
6327
+ // Durable stop epoch was already bumped for this peer by the store-level
6328
+ // group cleanup; mirror it into the peer's in-memory cache.
6329
+ peer.stopEpoch.set(chatId, peer.store.getStopGeneration(peer.config.name, chatId));
6330
+ peer.forceExpireQueueOwner(chatId, 'group-scoped stop by peer bot');
6331
+ peer.busyChats.set(chatId, 0);
6332
+ peer.admittedSteerAcks.delete(chatId);
6333
+ peer.runFailureObservers.delete(chatId);
6334
+ peer.queueRunTokens.delete(chatId);
6335
+ const acks = peer.pendingAckMessages.get(chatId) || [];
6336
+ peer.pendingAckMessages.set(chatId, []);
6337
+ for (const ack of acks)
6338
+ void peer.receiptReaction(chatId, ack.rowId, ack.messageId, '').catch(() => { });
6339
+ // Fence the peer's stale collector so it cannot keep destructively
6340
+ // consuming the replacement batch's frames from the shared session bucket.
6341
+ try {
6342
+ this.openclawClient.revokeSessionCollectors(peer.getSessionKey(chatId), 'group-scoped stop');
6343
+ }
6344
+ catch { /* isolated */ }
6345
+ void peer.openclawClient.cancelSteerSession?.(peer.getSessionKey(chatId), peer.stopGeneration(chatId)).catch(() => { });
6346
+ void peer.openclawClient.abortChat(peer.getSessionKey(chatId)).catch(() => { });
6347
+ released++;
6348
+ }
6349
+ return released;
6350
+ }
6117
6351
  async handleStopCommand(chatId, messageId) {
6118
- const sessionKey = this.getSessionKey(chatId);
6352
+ const sessionKey = this.physicalSessionKey(chatId);
6119
6353
  // Go supervision: a user stop must also pause related Go, so the checker
6120
6354
  // cannot wake the just-stopped task back up with a follow-up.
6121
6355
  void this.goController?.pauseSilently(chatId, "用户执行了 /stop");
@@ -6140,14 +6374,29 @@ export class FeishuBot {
6140
6374
  this.pendingAckMessages.set(chatId, []);
6141
6375
  for (const ack of acks)
6142
6376
  void this.receiptReaction(chatId, ack.rowId, ack.messageId, '').catch(() => { });
6377
+ // Group scope: a bare /stop in a multi-bot group is executed by ONE bot,
6378
+ // but every bot keeps its own pending_triggers / insertion_custody /
6379
+ // dispatch_custody / reaction rows AND its own in-memory queue owner for
6380
+ // this same chat. Stopping only the executing bot left the others
6381
+ // believing the chat was busy, so their next message was queued and
6382
+ // never dispatched. Release the whole chat for every peer bot.
6383
+ //
6384
+ // This MUST run BEFORE the abort RPC await: a user message that arrives
6385
+ // while the abort is in flight has to survive the stop cutoff, exactly
6386
+ // like the executing bot's own late input above.
6387
+ const group = this.store.stopChatForAllBots(chatId, this.config.name);
6388
+ const peers = this.releasePeerBotsForChat(chatId);
6143
6389
  await this.openclawClient.abortChat(sessionKey).catch(() => { });
6144
6390
  if (!this.queueRuns.has(chatId))
6145
6391
  this.busyChats.set(chatId, 0);
6146
6392
  const locale = this.chatLocale(chatId);
6393
+ const scopeNote = peers > 0
6394
+ ? (locale === "en" ? ` Also released ${peers} peer bot(s) in this chat.` : ` 同时释放了本群其余 ${peers} 个 bot 的队列。`)
6395
+ : "";
6147
6396
  await this.replyMessage(messageId, locale === "en"
6148
- ? `⏹️ Stopped. Aborted the active run, unlocked the queue, and cleared ${cleared} pending message(s). You can send a new message now.`
6149
- : `⏹️ 已停止。已中止当前 run、解锁队列,并清掉 ${cleared} 条待处理消息。现在可以重新发消息了。`);
6150
- console.log(`[${this.config.name}] /stop force-cleared ${chatId.slice(-8)}: aborted run, cleared ${cleared} pending`);
6397
+ ? `⏹️ Stopped. Aborted the active run, unlocked the queue, and cleared ${cleared} pending message(s). You can send a new message now.${scopeNote}`
6398
+ : `⏹️ 已停止。已中止当前 run、解锁队列,并清掉 ${cleared} 条待处理消息。现在可以重新发消息了。${scopeNote}`);
6399
+ console.log(`[${this.config.name}] /stop force-cleared ${chatId.slice(-8)}: aborted run, cleared ${cleared} pending, group: insertions=${group.stoppedInsertions} triggers=${group.clearedTriggers} custody=${group.clearedCustody} peers=${peers}`);
6151
6400
  }
6152
6401
  catch (err) {
6153
6402
  await this.replyMessage(messageId, `❌ stop 失败: ${err.message}`);
@@ -6158,28 +6407,17 @@ export class FeishuBot {
6158
6407
  * Called once per chat on first message.
6159
6408
  */
6160
6409
  async fetchAndCacheChatInfo(chatId, chatType) {
6410
+ if (chatType === "p2p") {
6411
+ // Private ownership is not hourly metadata. Check/pin the stable SDK app
6412
+ // identity even on a fresh cache; never replace an unknown legacy owner.
6413
+ this.store.claimP2pOwner(chatId, this.config.name, this.config.appId);
6414
+ return;
6415
+ }
6161
6416
  const existing = this.store.getChatInfo(chatId);
6162
6417
  // Refresh at most once per hour
6163
6418
  if (existing && Date.now() - existing.updatedAt < 3600_000)
6164
6419
  return;
6165
6420
  try {
6166
- if (chatType === "p2p") {
6167
- // For p2p, we don't have a group name or member list from chat API
6168
- this.store.upsertChatInfo({
6169
- chatId,
6170
- chatType: "p2p",
6171
- chatName: "私聊",
6172
- members: "",
6173
- memberNames: "",
6174
- ownerBot: existing?.ownerBot || this.config.name,
6175
- freeDiscussion: this.store.getChatInfo(chatId)?.freeDiscussion || false,
6176
- verbose: this.store.getChatInfo(chatId)?.verbose || false,
6177
- discuss: this.store.getChatInfo(chatId)?.discuss || false,
6178
- discussMaxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 10,
6179
- updatedAt: Date.now(),
6180
- });
6181
- return;
6182
- }
6183
6421
  // Fetch group info
6184
6422
  const chatResp = await this.client.im.chat.get({
6185
6423
  path: { chat_id: chatId },
@@ -6428,6 +6666,28 @@ export class FeishuBot {
6428
6666
  /**
6429
6667
  * Extract text content from a rich text (post) message.
6430
6668
  */
6669
+ /** Visible rich-post fragments only. Text nodes remain literal; only actual
6670
+ * media nodes become type markers, never a regex over the user's prose. */
6671
+ visiblePostInput(content, chatId) {
6672
+ const parts = [];
6673
+ if (typeof content.title === 'string')
6674
+ parts.push(content.title);
6675
+ for (const paragraph of Array.isArray(content.content) ? content.content : []) {
6676
+ if (!Array.isArray(paragraph))
6677
+ continue;
6678
+ for (const node of paragraph) {
6679
+ if (node?.tag === 'text' && typeof node.text === 'string')
6680
+ parts.push(node.text);
6681
+ else if (node?.tag === 'a' && typeof node.text === 'string')
6682
+ parts.push(`${node.text}${typeof node.href === 'string' ? `(${node.href})` : ''}`);
6683
+ else if (node?.tag === 'at' && typeof node.user_name === 'string')
6684
+ parts.push(`@${node.user_name}`);
6685
+ else if (node?.tag === 'img')
6686
+ parts.push(this.isEn(chatId) ? '[Image]' : '[图片]');
6687
+ }
6688
+ }
6689
+ return parts.join(' ');
6690
+ }
6431
6691
  extractPostText(content) {
6432
6692
  const parts = [];
6433
6693
  const title = content.title;