openclaw-lark-multi-agent 1.8.19 → 1.8.20

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.
@@ -305,6 +305,11 @@ export declare class FeishuBot {
305
305
  private deliverySourceId;
306
306
  private sendSystemDelivery;
307
307
  private enqueueAndDispatchDelivery;
308
+ private scopeRunError;
309
+ /** Shared original-error authorization for journaled terminals AND ordinary
310
+ * admitted provider errors. Current outbox/config metadata is not authority
311
+ * to replace the retained admission route or physical target. */
312
+ private errorRouteRevocation;
308
313
  private inspectErrorNotice;
309
314
  private deferErrorNotice;
310
315
  private scheduleErrorNoticeCheck;
@@ -337,6 +342,8 @@ export declare class FeishuBot {
337
342
  private fetchThinkingLabel;
338
343
  private asDiscussionParticipant;
339
344
  private runDiscussionTurn;
345
+ private liveInputElements;
346
+ private liveStatusRequest;
340
347
  private buildLiveStatusCard;
341
348
  /** Minimal escaping so activity text does not break card markdown. */
342
349
  private escapeCardText;
@@ -502,5 +509,8 @@ export declare class FeishuBot {
502
509
  /**
503
510
  * Extract text content from a rich text (post) message.
504
511
  */
512
+ /** Visible rich-post fragments only. Text nodes remain literal; only actual
513
+ * media nodes become type markers, never a regex over the user's prose. */
514
+ private visiblePostInput;
505
515
  private extractPostText;
506
516
  }
@@ -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";
@@ -444,6 +445,10 @@ export class FeishuBot {
444
445
  if (!rowId)
445
446
  return;
446
447
  this.store.clearPendingTrigger(this.config.name, chatId, rowId);
448
+ // Privacy invalidation starts before reaction SDK I/O: unrelated lookup
449
+ // latency must not retain a recalled preview on a frozen/terminal card.
450
+ void this.queuedPresentations.refreshRecalledInput(chatId, rowId, messageId)
451
+ .catch(err => console.warn(`[${this.config.name}] Recalled-input UI redaction unavailable:`, this.errorSummary(err)));
447
452
  const pendingAcks = this.pendingAckMessages.get(chatId) || [];
448
453
  const remainingAcks = [];
449
454
  for (const ack of pendingAcks) {
@@ -561,7 +566,7 @@ export class FeishuBot {
561
566
  }
562
567
  if (meta?.sourceType === "run_error" || provenance) {
563
568
  if (provenance) {
564
- this.store.observeRunError(this.config.name, chatId, provenance);
569
+ this.store.observeRunError(this.config.name, chatId, this.scopeRunError(chatId, provenance));
565
570
  await this.reconcileErrorNotices(chatId);
566
571
  }
567
572
  else
@@ -915,7 +920,7 @@ export class FeishuBot {
915
920
  const drainTasks = [];
916
921
  for (const chat of chats) {
917
922
  // Skip p2p chats that belong to other bots
918
- if (chat.chatType === "p2p" && chat.ownerBot && chat.ownerBot !== this.config.name) {
923
+ if (chat.chatType === "p2p" && !this.store.claimP2pOwner(chat.chatId, this.config.name, this.config.appId)) {
919
924
  continue;
920
925
  }
921
926
  // Re-subscribe to existing sessions
@@ -973,7 +978,9 @@ export class FeishuBot {
973
978
  if ((message.mentions || []).some((mention) => mention?.id && Object.hasOwn(mention.id, 'open_id') &&
974
979
  (typeof mention.id.open_id !== 'string' || !mention.id.open_id.trim())))
975
980
  return;
976
- if (this.store.hasBotProcessed(this.config.name, message.message_id))
981
+ // Private duplicates must reach the stable app/chat fence so an old
982
+ // name-scoped tombstone cannot disappear across a later bot rename.
983
+ if (message.chat_type !== "p2p" && this.store.hasBotProcessed(this.config.name, message.message_id))
977
984
  return;
978
985
  // A duplicate must honor the original journal owner, even if its envelope
979
986
  // differs or identity has resolved since the first receipt.
@@ -1031,7 +1038,9 @@ export class FeishuBot {
1031
1038
  // OpenClaw twice.
1032
1039
  if (this.identityClosed)
1033
1040
  return;
1034
- if (!identityItem && !this.store.tryMarkBotProcessed(this.config.name, messageId))
1041
+ if (!identityItem && !(chatType === "p2p"
1042
+ ? this.store.tryMarkP2pProcessed(this.config.appId, chatId, this.config.name, messageId)
1043
+ : this.store.tryMarkBotProcessed(this.config.name, messageId)))
1035
1044
  return;
1036
1045
  const identityCurrent = () => {
1037
1046
  if (this.identityClosed)
@@ -1051,11 +1060,8 @@ export class FeishuBot {
1051
1060
  if (!identityCurrent())
1052
1061
  return;
1053
1062
  // --- 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
- }
1063
+ if (chatType === "p2p" && !this.store.claimP2pOwner(chatId, this.config.name, this.config.appId)) {
1064
+ return; // A display-name match cannot override a conflicting app pin.
1059
1065
  }
1060
1066
  let content;
1061
1067
  try {
@@ -1065,6 +1071,14 @@ export class FeishuBot {
1065
1071
  console.warn(`[${this.config.name}] Failed to parse message content, skipping`);
1066
1072
  return;
1067
1073
  }
1074
+ // Original visible input for UI only; never use hydrated media paths,
1075
+ // synthetic routing prompts or model/history text as its body.
1076
+ const inputCaption = [content.text, content.caption].find(value => typeof value === 'string') || '';
1077
+ 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; }) : '') :
1078
+ messageType === 'file' ? `[${this.isEn(chatId) ? 'File' : '文件'}: ${(typeof content.file_name === 'string' ? content.file_name : 'file').split(/[\\/]/).at(-1)}] ${inputCaption}` :
1079
+ messageType === 'image' ? `[${this.isEn(chatId) ? 'Image' : '图片'}] ${inputCaption}` : messageType === 'audio' ? `[${this.isEn(chatId) ? 'Audio' : '语音'}] ${inputCaption}` : messageType === 'sticker' ? (this.isEn(chatId) ? '[Sticker]' : '[贴纸]') :
1080
+ messageType === 'post' ? this.visiblePostInput(content, chatId) :
1081
+ [content.title, content.name, content.text, content.url].find(value => typeof value === 'string') || '';
1068
1082
  // --- Extract text content based on message type ---
1069
1083
  let cleanText = "";
1070
1084
  let rawText = "";
@@ -1622,7 +1636,7 @@ export class FeishuBot {
1622
1636
  // No health/setup/model/history await precedes this honest local receipt.
1623
1637
  // UI custody cannot reserve dispatch or claim Gateway/model acceptance.
1624
1638
  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));
1639
+ this.presentation(() => this.queuedPresentations.receive(chatId, insertedId, messageId, isNativeOpenClawCommand, presentationRoot, { source: 'visible', text: inputDisplayText }));
1626
1640
  // Reserve BEFORE the first health/reaction await. Otherwise an ending
1627
1641
  // owner can ordinary-drain this row while its handler is still waiting,
1628
1642
  // and the later steer would submit the same message a second time.
@@ -2739,7 +2753,7 @@ export class FeishuBot {
2739
2753
  owner.continuePending = mergedTriggers.some(message => !identityTargets.some(entry => entry.message === message));
2740
2754
  break;
2741
2755
  }
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(() => { });
2756
+ 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
2757
  if (batchProof) {
2744
2758
  for (const id of mergedTriggerIds) {
2745
2759
  const item = this.store.getInsertion(this.config.name, chatId, id);
@@ -3029,7 +3043,7 @@ export class FeishuBot {
3029
3043
  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
3044
  visibleOutcomeConfirmed = outcome.state === 'delivered';
3031
3045
  if (!visibleOutcomeConfirmed) {
3032
- await liveStatus?.pauseForUnconfirmedResult("飞书投递回执尚未确认;保留原投递保管,不重复发送").catch(() => { });
3046
+ await liveStatus?.pauseForUnconfirmedResult("飞书投递回执尚未确认;保留原投递保管,不重复发送", undefined, "delivery_pending").catch(() => { });
3033
3047
  }
3034
3048
  else {
3035
3049
  await liveStatus?.complete();
@@ -3151,17 +3165,17 @@ export class FeishuBot {
3151
3165
  this.store.saveInsertion(this.config.name, chatId, id, row.insertionId, 'target_changed', { ...row, attempted: false, reason: err.message });
3152
3166
  }
3153
3167
  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(() => { });
3168
+ await liveStatus?.pauseForUnconfirmedResult(err.message, undefined, "blocked").catch(() => { });
3155
3169
  owner.continuePending = true; // independently proved newer cohorts may still be sent
3156
3170
  break;
3157
3171
  }
3158
3172
  if (err instanceof AdmissionUnconfirmed) {
3159
3173
  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(() => { });
3174
+ await liveStatus?.pauseForUnconfirmedResult(err.message, undefined, "submitting").catch(() => { });
3161
3175
  break;
3162
3176
  }
3163
3177
  let pause = err instanceof SessionWaitPaused ? err : undefined;
3164
- if (!pause && isWaitTimeout(err instanceof Error ? err.message : String(err))) {
3178
+ if (!pause && !(err instanceof RunTerminalError) && isWaitTimeout(err instanceof Error ? err.message : String(err))) {
3165
3179
  const state = await this.readSessionStatus(chatId);
3166
3180
  if (state.running)
3167
3181
  pause = new SessionWaitPaused(state, true);
@@ -3170,7 +3184,7 @@ export class FeishuBot {
3170
3184
  const target = this.activeDeliveryTargets.get(chatId);
3171
3185
  if (target?.triggerId === triggerId && target.runId)
3172
3186
  this.openclawClient.releasePausedWait?.(target.runId);
3173
- await liveStatus?.pauseForUnconfirmedResult(pause.message, `${this.config.name} 等待暂停 · ${pause.snapshot.status}`).catch(() => { });
3187
+ await liveStatus?.pauseForUnconfirmedResult(pause.message, `${this.config.name} 等待暂停 · ${pause.snapshot.status}`, "paused").catch(() => { });
3174
3188
  await this.sendWaitPaused(chatId, triggerId, lastHuman.messageId, pause).catch(() => { });
3175
3189
  // A wait notice is not the final answer and must never claim its key.
3176
3190
  // Do not mark DONE, retry this input, or abort the running session.
@@ -3226,7 +3240,7 @@ export class FeishuBot {
3226
3240
  if (row && !row.admissionSubmissionId && !row.admittedRunId)
3227
3241
  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
3242
  }
3229
- await liveStatus?.pauseForUnconfirmedResult('Guarded admission unavailable; input custody retained without replay').catch(() => { });
3243
+ await liveStatus?.pauseForUnconfirmedResult('Guarded admission unavailable; input custody retained without replay', undefined, 'blocked').catch(() => { });
3230
3244
  break;
3231
3245
  }
3232
3246
  console.error(`[${this.config.name}] processQueue error:`, safeErrorSummary(err));
@@ -3236,13 +3250,23 @@ export class FeishuBot {
3236
3250
  if (lastHuman.messageId) {
3237
3251
  // Error message goes through the normal delivery path; the live status
3238
3252
  // 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(() => { });
3253
+ if (err instanceof RunTerminalError) {
3254
+ const p = this.scopeRunError(chatId, err.provenance);
3255
+ this.store.observeRunError(this.config.name, chatId, p);
3256
+ await this.reconcileErrorNotices(chatId);
3257
+ errorOutcomeConfirmed = this.store.findRunErrorOwner(this.config.name, chatId, p)?.status === 'delivered';
3258
+ }
3259
+ else
3260
+ await this.enqueueAndDispatchDelivery(chatId, "provider_error", `trigger:${triggerId}:provider-error`, errorText, [], lastHuman.messageId, `trigger:${triggerId}:provider-error`, undefined, foregroundRunId ? (() => {
3261
+ const p = this.scopeRunError(chatId, { sessionKey: `agent:main:${this.getSessionKey(chatId).replace(/^agent:[^:]+:/, '')}`, sessionId: owner.sessionId, runId: foregroundRunId, source: 'chat', state: 'error', detail: err instanceof Error ? err.message : undefined });
3262
+ return { sessionKey: p.sessionKey, sessionId: p.sessionId, runId: p.runId, error: p };
3263
+ })() : undefined)
3264
+ .then(outcome => {
3265
+ errorOutcomeConfirmed = outcome.state === 'delivered';
3266
+ if (errorOutcomeConfirmed && current() && triggerId)
3267
+ this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
3268
+ })
3269
+ .catch(() => { });
3246
3270
  await liveStatus?.fail().catch(() => { });
3247
3271
  }
3248
3272
  else {
@@ -3485,6 +3509,7 @@ export class FeishuBot {
3485
3509
  thinkingLevel: meta.thinkingLevel,
3486
3510
  toolCalls: meta.toolCalls,
3487
3511
  noReply: Boolean(meta.noReply),
3512
+ 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
3513
  state: "done",
3489
3514
  };
3490
3515
  await this.patchLiveStatusCard(messageId, view, chatId);
@@ -3779,7 +3804,12 @@ export class FeishuBot {
3779
3804
  const previous = this.deliveryTargetsByRun.get(key);
3780
3805
  if (previous?.timer)
3781
3806
  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:[^:]+:/, '')}` });
3807
+ const persisted = this.store.getDeliveryRunTarget(this.config.name, chatId, runId);
3808
+ const owner = this.queueOwners.get(chatId);
3809
+ this.store.bindDeliveryRun(this.config.name, chatId, runId, { ...persisted, triggerId: target.triggerId, messageId: target.messageId, runId,
3810
+ sessionId: persisted?.sessionId || (owner?.runId === runId ? owner.sessionId : undefined),
3811
+ botIdentity: persisted?.botIdentity || this.config.appId, stopGeneration: persisted?.stopGeneration ?? this.stopGeneration(chatId),
3812
+ sessionKey: persisted?.sessionKey || `agent:main:${this.getSessionKey(chatId).replace(/^agent:[^:]+:/, '')}` });
3783
3813
  this.deliveryTargetsByRun.set(key, {
3784
3814
  triggerId: target.triggerId,
3785
3815
  messageId: target.messageId,
@@ -3878,9 +3908,9 @@ export class FeishuBot {
3878
3908
  const contentHash = this.stableHash(normalizedPayload);
3879
3909
  const finalDeliveryKey = deliveryKey || sourceId;
3880
3910
  const deliveryMetaJson = JSON.stringify({
3881
- botIdentity: this.config.appId,
3911
+ botIdentity: provenance?.error?.botIdentity || this.config.appId,
3882
3912
  ...(completionTriggerIds?.length ? { completionTriggerIds } : {}),
3883
- deliveryStopEpoch: this.stopGeneration(chatId),
3913
+ deliveryStopEpoch: provenance?.error?.stopEpoch ?? this.stopGeneration(chatId),
3884
3914
  ...(finalReplyModel?.trim() ? { model: finalReplyModel.trim() } : {}),
3885
3915
  ...(provenance ? { provenance } : {}), ...(errorNoticeId ? { errorNoticeId } : {}),
3886
3916
  });
@@ -4035,13 +4065,52 @@ export class FeishuBot {
4035
4065
  }
4036
4066
  return this.store.getDeliveryDispatchOutcome(this.config.name, chatId, finalDeliveryKey);
4037
4067
  }
4068
+ scopeRunError(chatId, p) {
4069
+ const key = /^agent:[^:]+:/.test(p.sessionKey) ? p.sessionKey : `agent:main:${p.sessionKey}`;
4070
+ const target = this.store.getDeliveryRunTarget(this.config.name, chatId, p.runId);
4071
+ const observation = this.store.getRunObservation(key, p.runId);
4072
+ // Pre-upgrade targets may lack app/physical fields. A known exact legacy
4073
+ // outbox owner is original route evidence, never permission for the current
4074
+ // replacement app to take over its notice.
4075
+ const legacy = !target?.botIdentity ? this.store.findRunErrorOwner(this.config.name, chatId, { ...p, sessionKey: key }) : undefined;
4076
+ let meta;
4077
+ try {
4078
+ meta = JSON.parse(legacy?.deliveryMetaJson || '{}');
4079
+ }
4080
+ catch {
4081
+ meta = {};
4082
+ }
4083
+ return { ...p, sessionKey: key, sessionId: p.sessionId || target?.sessionId || observation?.sessionId || meta.provenance?.sessionId,
4084
+ botIdentity: p.botIdentity || target?.botIdentity || meta.botIdentity || this.config.appId,
4085
+ stopEpoch: p.stopEpoch ?? target?.stopGeneration ?? meta.deliveryStopEpoch ?? this.stopGeneration(chatId) };
4086
+ }
4087
+ /** Shared original-error authorization for journaled terminals AND ordinary
4088
+ * admitted provider errors. Current outbox/config metadata is not authority
4089
+ * to replace the retained admission route or physical target. */
4090
+ errorRouteRevocation(chatId, p) {
4091
+ const original = this.store.getDeliveryRunTarget(this.config.name, chatId, p.runId);
4092
+ if (original?.messageId && this.store.isMessageRecalled(original.messageId) ||
4093
+ original?.sessionId && p.sessionId && original.sessionId !== p.sessionId ||
4094
+ original?.sessionKey && canonicalPhysicalSessionKey(original.sessionKey) !== canonicalPhysicalSessionKey(p.sessionKey))
4095
+ return 'original_error_target_revoked';
4096
+ const epoch = this.stopGeneration(chatId);
4097
+ if (p.botIdentity && p.botIdentity !== this.config.appId || original?.botIdentity && original.botIdentity !== this.config.appId ||
4098
+ p.stopEpoch !== undefined && p.stopEpoch !== epoch || original?.stopGeneration !== undefined && original.stopGeneration !== epoch)
4099
+ return 'original_error_route_revoked';
4100
+ }
4038
4101
  async inspectErrorNotice(record) {
4102
+ const revoked = this.errorRouteRevocation(record.chatId, record.provenance);
4103
+ if (revoked)
4104
+ return { state: 'stopped', reason: revoked };
4039
4105
  if (this.store.hasRunFinalDelivery(this.config.name, record.chatId, record.provenance, true)) {
4040
4106
  return { state: "suppressed", reason: "exact_run_final_platform_delivery_confirmed" };
4041
4107
  }
4042
4108
  if (this.store.hasRunFinalDelivery(this.config.name, record.chatId, record.provenance, false)) {
4043
4109
  return { state: "pending", reason: "exact_run_final_delivery_pending" };
4044
4110
  }
4111
+ const existing = this.store.findRunErrorOwner(this.config.name, record.chatId, record.provenance);
4112
+ if (!record.provenance.runId.startsWith('announce:') && existing && existing.deliveryKey !== `run-error:${errorNoticeIdentity(record.provenance)}`)
4113
+ return { state: 'suppressed', reason: 'existing_exact_run_error_owner' };
4045
4114
  let verdict;
4046
4115
  try {
4047
4116
  verdict = await this.openclawClient.inspectRunError(record.provenance);
@@ -4051,8 +4120,13 @@ export class FeishuBot {
4051
4120
  }
4052
4121
  // Presentation only: leave exact-run finality, stop suppression and durable
4053
4122
  // 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) };
4123
+ if (verdict.state === "terminal" && ['ordinary_gateway_error', 'closed_gateway_terminal', 'exact_run_terminal_error'].includes(verdict.reason)) {
4124
+ const reason = closedStopReason(record.provenance.stopReason) || verdict.stopReason;
4125
+ const en = this.isEn(record.chatId);
4126
+ verdict = { ...verdict, text: reason === 'timeout' ? this.formatUserVisibleError({ code: 'execution_timeout' }, record.chatId)
4127
+ : reason === 'superseded' ? (en ? '⚠️ This run ended after being superseded by a newer session writer; no user stop is inferred.' : '⚠️ 本次运行已被新的会话写入者取代而终止;不据此认定用户停止。')
4128
+ : reason === 'restart' ? (en ? '⚠️ This run ended for a Gateway restart; no user stop is inferred.' : '⚠️ 本次运行因 Gateway 重启而终止;不据此认定用户停止。')
4129
+ : this.formatUserVisibleError(record.provenance.detail, record.chatId) };
4056
4130
  }
4057
4131
  // A stop or final may have arrived during the read-only lookup.
4058
4132
  const latest = this.store.getErrorNotice(this.config.name, record.chatId, record.noticeId);
@@ -4071,6 +4145,8 @@ export class FeishuBot {
4071
4145
  if (this.store.hasRunFinalDelivery(this.config.name, record.chatId, record.provenance, false)) {
4072
4146
  return { state: "pending", reason: "exact_run_final_delivery_pending" };
4073
4147
  }
4148
+ if (verdict.state === 'terminal' && verdict.stopReason && !record.provenance.stopReason)
4149
+ record.provenance = { ...record.provenance, stopReason: verdict.stopReason };
4074
4150
  return verdict;
4075
4151
  }
4076
4152
  deferErrorNotice(record, verdict) {
@@ -4153,7 +4229,7 @@ export class FeishuBot {
4153
4229
  this.store.saveErrorNotice(record);
4154
4230
  if (verdict.state === "terminal" && verdict.terminalKey && verdict.text) {
4155
4231
  // 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);
4232
+ 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
4233
  // The send-time recheck may have parked or suppressed the observation.
4158
4234
  const refreshed = this.store.getErrorNotice(this.config.name, chatId, record.noticeId);
4159
4235
  if (refreshed?.verdict && ["pending", "suppressed", "stopped"].includes(refreshed.verdict.state))
@@ -4326,8 +4402,13 @@ export class FeishuBot {
4326
4402
  const meta = JSON.parse(item.deliveryMetaJson || '{}'), p = meta.provenance;
4327
4403
  if (!this.insertionNoticeCurrent(chatId, meta.insertionNotice))
4328
4404
  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) ||
4405
+ if (p?.error) {
4406
+ const revoked = this.errorRouteRevocation(chatId, p.error);
4407
+ if (revoked)
4408
+ throw Object.assign(new Error(`Original error delivery revoked: ${revoked}`), { deliveryStopped: true });
4409
+ }
4410
+ if ((p?.final || p?.error || item.sourceType === 'run_error') &&
4411
+ (meta.botIdentity && meta.botIdentity !== this.config.appId || typeof meta.deliveryStopEpoch === 'number' && meta.deliveryStopEpoch !== this.stopGeneration(chatId) ||
4331
4412
  p?.sessionKey && p.runId && this.store.isStoppedObservation(p.sessionKey, p.runId))) {
4332
4413
  throw Object.assign(new Error('Delivery revoked by explicit stop before platform attempt'), { deliveryStopped: true });
4333
4414
  }
@@ -4699,7 +4780,7 @@ export class FeishuBot {
4699
4780
  }
4700
4781
  if (pause) {
4701
4782
  discussionWaitPaused = true;
4702
- await liveStatus?.pauseForUnconfirmedResult(pause.message, `${this.config.name} 等待暂停 · ${pause.snapshot.status}`).catch(() => { });
4783
+ await liveStatus?.pauseForUnconfirmedResult(pause.message, `${this.config.name} 等待暂停 · ${pause.snapshot.status}`, "paused").catch(() => { });
4703
4784
  await this.sendWaitPaused(chatId, 0, undefined, pause).catch(() => { });
4704
4785
  }
4705
4786
  else
@@ -4735,7 +4816,7 @@ export class FeishuBot {
4735
4816
  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
4817
  discussionDeliveryConfirmed = outcome.state === 'delivered';
4737
4818
  if (!discussionDeliveryConfirmed)
4738
- await liveStatus?.pauseForUnconfirmedResult("飞书投递回执尚未确认;原投递仍受保管").catch(() => { });
4819
+ await liveStatus?.pauseForUnconfirmedResult("飞书投递回执尚未确认;原投递仍受保管", undefined, "delivery_pending").catch(() => { });
4739
4820
  }
4740
4821
  catch (err) {
4741
4822
  await liveStatus?.fail().catch(() => { });
@@ -4755,13 +4836,46 @@ export class FeishuBot {
4755
4836
  }
4756
4837
  return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible && discussionDeliveryConfirmed };
4757
4838
  }
4758
- buildLiveStatusCard(view, chatId) {
4839
+ liveInputElements(view, chatId, limits) {
4840
+ const context = view.inputStatus;
4841
+ if (!context)
4842
+ return [];
4843
+ const en = this.isEn(chatId);
4844
+ const scoped = !!chatId && context.chatId === chatId && context.botName === this.config.name && context.appId === this.config.appId &&
4845
+ context.stopEpoch === this.stopGeneration(chatId);
4846
+ const preview = (hint) => {
4847
+ const source = scoped && hint.rowId ? this.store.getMessageByRowId(hint.rowId) : undefined;
4848
+ if (!source || source.chatId !== chatId || source.senderType !== 'human' || !hint.messageId || source.messageId !== hint.messageId)
4849
+ return en ? '[Message body unavailable]' : '[消息正文暂不可用]';
4850
+ if (this.store.isMessageRecalled(source.messageId))
4851
+ return en ? '[Message recalled]' : '[消息已撤回]';
4852
+ return hint.previewSource === 'visible' ? visibleInputPreview(hint.preview ?? '', limits.preview, en) : inputPreview(hint.preview ?? source.content, limits.preview, en);
4853
+ };
4854
+ const selected = selectInputHints(context.items, limits.members);
4855
+ 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')}`; };
4856
+ 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)) : ''}`);
4857
+ if (context.items.length > selected.length)
4858
+ 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} 条输入未展开,保留最新输入`);
4859
+ if (context.note)
4860
+ 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))}`);
4861
+ return lines.length ? [{ tag: 'markdown', content: lines.join('\n') }] : [];
4862
+ }
4863
+ liveStatusRequest(view, chatId, request) {
4864
+ for (const limits of INPUT_DISPLAY_LIMITS) {
4865
+ const value = request(this.buildLiveStatusCard(view, chatId, limits));
4866
+ if (Buffer.byteLength(JSON.stringify(value), 'utf8') <= LIVE_CARD_BYTES - 512)
4867
+ return value; // reserve SDK envelope overhead
4868
+ }
4869
+ throw new LiveStatusEffectSuppressedError('Live status request exceeds local outer JSON byte budget; no SDK call');
4870
+ }
4871
+ buildLiveStatusCard(view, chatId, limits = INPUT_DISPLAY_LIMITS[0]) {
4759
4872
  const en = this.isEn(chatId);
4873
+ const inputElements = this.liveInputElements(view, chatId, limits);
4760
4874
  if (view.state === "replied") {
4761
4875
  // Neutral UI-only closure, not a green business/input-completion claim.
4762
4876
  const summary = en ? `💬 Linked run replied · ⏱ ${view.elapsed}` : `💬 关联轮次已回复 · ⏱ 耗时${view.elapsed}`;
4763
4877
  return { schema: "2.0", config: { update_multi: true, width_mode: "fill" },
4764
- body: { elements: [{ tag: "markdown", content: `<font color='grey'>${summary}</font>` }] } };
4878
+ body: { elements: [...inputElements, { tag: "markdown", content: `<font color='grey'>${summary}</font>` }] } };
4765
4879
  }
4766
4880
  // Clean finish (done / NO_REPLY): keep it minimal — no header, no footer, just
4767
4881
  // one compact grey line (status emoji + tool-call count + total elapsed) so
@@ -4770,18 +4884,18 @@ export class FeishuBot {
4770
4884
  const statusEmoji = view.noReply ? "💤" : "✅";
4771
4885
  const summary = this.completionSummary({
4772
4886
  toolCalls: view.toolCalls,
4773
- elapsed: view.elapsed,
4774
- model: view.model,
4775
- thinkingLevel: view.thinkingLevel,
4887
+ elapsed: displayClip(view.elapsed, 32),
4888
+ model: view.model ? displayClip(view.model, 160) : undefined,
4889
+ thinkingLevel: view.thinkingLevel ? displayClip(view.thinkingLevel, 80) : undefined,
4776
4890
  locale: en ? "en" : "zh",
4777
4891
  }, statusEmoji);
4778
4892
  return {
4779
4893
  schema: "2.0",
4780
4894
  config: { update_multi: true, width_mode: "fill" },
4781
- body: { elements: [{ tag: "markdown", content: `<font color='grey'>${summary}</font>` }] },
4895
+ body: { elements: [...inputElements, { tag: "markdown", content: `<font color='grey'>${summary}</font>` }] },
4782
4896
  };
4783
4897
  }
4784
- const elements = [];
4898
+ const elements = [...inputElements];
4785
4899
  // Running OR failed: show the recent activity window (each line prefixed with
4786
4900
  // the relative time mm:ss). On failure we deliberately KEEP the last lines so
4787
4901
  // the steps leading up to the error/kill/timeout are visible for debugging.
@@ -4794,24 +4908,26 @@ export class FeishuBot {
4794
4908
  : "•";
4795
4909
  const fmtAt = (sec) => `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")}`;
4796
4910
  const content = view.lines
4911
+ .filter(l => !l.inputHint && !(view.inputStatus?.botName && l.kind === 'lifecycle'))
4797
4912
  .map((l) => {
4798
4913
  const stamp = l.kind === "summary" ? "" : `\`${fmtAt(l.at)}\` `;
4799
- return `${stamp}${iconFor(l.kind)} ${this.escapeCardText(l.text)}`;
4914
+ return `${stamp}${iconFor(l.kind)} ${this.escapeCardText(displayClip(l.text, limits.activity))}`;
4800
4915
  })
4801
4916
  .join("\n");
4802
- elements.push({ tag: "markdown", content });
4917
+ if (content)
4918
+ elements.push({ tag: "markdown", content });
4803
4919
  }
4804
- else {
4920
+ else if (!inputElements.length) {
4805
4921
  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
4922
  }
4807
4923
  elements.push({ tag: "hr" });
4808
4924
  // Keep the active model visible while the run is in progress or failed.
4809
4925
  // The final reply also keeps model attribution so it remains self-contained.
4810
- const footerBits = [en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`];
4926
+ const footerBits = [en ? `⏱ ${displayClip(view.elapsed, 32)}` : `⏱ 已用 ${displayClip(view.elapsed, 32)}`];
4811
4927
  if (view.model?.trim()) {
4812
4928
  const modelBit = view.thinkingLevel?.trim()
4813
- ? `🧠 ${this.escapeCardText(view.model.trim())} · ${this.escapeCardText(view.thinkingLevel.trim())}`
4814
- : `🧠 ${this.escapeCardText(view.model.trim())}`;
4929
+ ? `🧠 ${this.escapeCardText(displayClip(view.model.trim(), 160))} · ${this.escapeCardText(displayClip(view.thinkingLevel.trim(), 80))}`
4930
+ : `🧠 ${this.escapeCardText(displayClip(view.model.trim(), 160))}`;
4815
4931
  footerBits.push(modelBit);
4816
4932
  }
4817
4933
  elements.push({ tag: "markdown", content: `<font color='grey'>${footerBits.join(" · ")}</font>` });
@@ -4822,7 +4938,7 @@ export class FeishuBot {
4822
4938
  // rejects patches on cards that did not declare it before and after.
4823
4939
  config: { update_multi: true, width_mode: "fill" },
4824
4940
  header: {
4825
- title: { tag: "plain_text", content: view.title },
4941
+ title: { tag: "plain_text", content: displayClip(view.title, 160) },
4826
4942
  template,
4827
4943
  },
4828
4944
  body: { elements },
@@ -4859,14 +4975,8 @@ export class FeishuBot {
4859
4975
  }
4860
4976
  async sendLiveStatusCard(chatId, view) {
4861
4977
  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
- });
4978
+ const request = this.liveStatusRequest(view, chatId, card => ({ params: { receive_id_type: "chat_id" }, data: { receive_id: chatId, content: JSON.stringify(card), msg_type: "interactive" } }));
4979
+ const res = await this.client.im.message.create(request);
4870
4980
  if (typeof res.code === "number" && res.code !== 0)
4871
4981
  throw new LiveStatusEffectRejectedError(`Feishu rejected card: ${res.code}`);
4872
4982
  if (res.code !== 0)
@@ -4881,13 +4991,8 @@ export class FeishuBot {
4881
4991
  }
4882
4992
  }
4883
4993
  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
- });
4994
+ const request = this.liveStatusRequest(view, chatId, card => ({ path: { message_id: messageId }, data: { content: JSON.stringify(card), msg_type: "interactive" } }));
4995
+ const res = await this.client.im.message.reply(request);
4891
4996
  if (typeof res.code === "number" && res.code !== 0)
4892
4997
  throw new LiveStatusEffectRejectedError(`Feishu rejected card: ${res.code}`);
4893
4998
  if (res.code !== 0)
@@ -4895,10 +5000,8 @@ export class FeishuBot {
4895
5000
  return res?.data?.message_id || res?.message_id;
4896
5001
  }
4897
5002
  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
- });
5003
+ const request = this.liveStatusRequest(view, chatId, card => ({ path: { message_id: messageId }, data: { content: JSON.stringify(card) } }));
5004
+ const res = await this.client.im.message.patch(request);
4902
5005
  if (typeof res.code === "number" && res.code !== 0)
4903
5006
  throw new LiveStatusEffectRejectedError(`Feishu rejected card: ${res.code}`);
4904
5007
  if (res.code !== 0)
@@ -6158,28 +6261,17 @@ export class FeishuBot {
6158
6261
  * Called once per chat on first message.
6159
6262
  */
6160
6263
  async fetchAndCacheChatInfo(chatId, chatType) {
6264
+ if (chatType === "p2p") {
6265
+ // Private ownership is not hourly metadata. Check/pin the stable SDK app
6266
+ // identity even on a fresh cache; never replace an unknown legacy owner.
6267
+ this.store.claimP2pOwner(chatId, this.config.name, this.config.appId);
6268
+ return;
6269
+ }
6161
6270
  const existing = this.store.getChatInfo(chatId);
6162
6271
  // Refresh at most once per hour
6163
6272
  if (existing && Date.now() - existing.updatedAt < 3600_000)
6164
6273
  return;
6165
6274
  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
6275
  // Fetch group info
6184
6276
  const chatResp = await this.client.im.chat.get({
6185
6277
  path: { chat_id: chatId },
@@ -6428,6 +6520,28 @@ export class FeishuBot {
6428
6520
  /**
6429
6521
  * Extract text content from a rich text (post) message.
6430
6522
  */
6523
+ /** Visible rich-post fragments only. Text nodes remain literal; only actual
6524
+ * media nodes become type markers, never a regex over the user's prose. */
6525
+ visiblePostInput(content, chatId) {
6526
+ const parts = [];
6527
+ if (typeof content.title === 'string')
6528
+ parts.push(content.title);
6529
+ for (const paragraph of Array.isArray(content.content) ? content.content : []) {
6530
+ if (!Array.isArray(paragraph))
6531
+ continue;
6532
+ for (const node of paragraph) {
6533
+ if (node?.tag === 'text' && typeof node.text === 'string')
6534
+ parts.push(node.text);
6535
+ else if (node?.tag === 'a' && typeof node.text === 'string')
6536
+ parts.push(`${node.text}${typeof node.href === 'string' ? `(${node.href})` : ''}`);
6537
+ else if (node?.tag === 'at' && typeof node.user_name === 'string')
6538
+ parts.push(`@${node.user_name}`);
6539
+ else if (node?.tag === 'img')
6540
+ parts.push(this.isEn(chatId) ? '[Image]' : '[图片]');
6541
+ }
6542
+ }
6543
+ return parts.join(' ');
6544
+ }
6431
6545
  extractPostText(content) {
6432
6546
  const parts = [];
6433
6547
  const title = content.title;