openclaw-lark-multi-agent 1.4.8 → 1.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -590,3 +590,21 @@ If a secret is ever committed, remove it from the current tree and rewrite git h
590
590
  ## License
591
591
 
592
592
  MIT
593
+
594
+ ### Reply status and context usage (v1.4.9)
595
+
596
+ Final text replies include a compact footer, for example:
597
+
598
+ `🧠 provider/model · running · 85K/200K`
599
+
600
+ State and context usage come from one bounded Gateway status lookup. K values
601
+ are rounded integers (1000 tokens per K); unavailable/stale values are not shown
602
+ as zero. The footer is a send-time snapshot, not a live status widget.
603
+
604
+ LMA's foreground result wait and live-card refresh budget are now 10 minutes.
605
+ When the session is still running, LMA sends a normal wait notice instead of
606
+ claiming execution failed. The old card stops periodic/progress edits; background
607
+ result handling and the original queue remain, with no automatic abort/replay.
608
+ Real results are sent separately; necessary terminal card cleanup is retained.
609
+ This does not shorten the Gateway's execution limit. Unverified steer V2 and
610
+ question adapters are not included in this release.
package/README.zh-CN.md CHANGED
@@ -498,3 +498,19 @@ git ls-files | grep -E 'config|secret|\.env' || true
498
498
  ## License
499
499
 
500
500
  MIT
501
+
502
+ ### 回复状态与上下文用量(v1.4.9)
503
+
504
+ 最终文字回复使用紧凑尾部,例如:
505
+
506
+ `🧠 provider/model · running · 85K/200K`
507
+
508
+ 状态与上下文已用量/上限来自同一次、有等待上限的 Gateway 查询;K 按
509
+ 1000 token 四舍五入为整数。缺失或明确过期的数据不冒充 0。尾部反映发送前
510
+ 查询的状态,不会持续自动刷新历史回复。
511
+
512
+ 前台结果等待与过程卡刷新预算调整为 10 分钟。会话仍在 running 时发送普通
513
+ 等待提示,不把它当作整个任务执行失败;旧卡不再周期刷新或随工具进度编辑。
514
+ 后台仍监听原任务,保留原队列,不因此停止或自动重发请求。真实结果另行回复,
515
+ 保留必要的终态收尾。本改动不缩短 Gateway 的执行时限,也不包含未验收的
516
+ steer V2 与问答适配器。
@@ -40,6 +40,8 @@ export declare class FeishuBot {
40
40
  * command/error/card sends with model attribution. */
41
41
  private replyModelFooters;
42
42
  private sendModelFooters;
43
+ private replyStatusFooters;
44
+ private sendStatusFooters;
43
45
  /** Per-chat durable outbox retry timer. */
44
46
  private deliveryRetryTimers;
45
47
  /** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
@@ -137,6 +139,8 @@ export declare class FeishuBot {
137
139
  /** Best-effort terminal cleanup for status-only rows and legacy v1.4.3
138
140
  * outbox entries that once targeted the live-status card. */
139
141
  private patchLiveStatusDoneSummary;
142
+ private readSessionStatus;
143
+ private sendWaitPaused;
140
144
  private formatUserVisibleError;
141
145
  private isBridgeControlReply;
142
146
  private isLegacyBridgeControlMessage;
@@ -2,6 +2,8 @@ import * as lark from "@larksuiteoapi/node-sdk";
2
2
  import { createRequire } from "module";
3
3
  import { persistBotModel } from "./config.js";
4
4
  import { getI18n, normalizeLocale } from "./i18n.js";
5
+ import { InactiveRunObservation } from "./openclaw-client.js";
6
+ import { SessionWaitPaused, isWaitTimeout, normalizeSessionRuntimeStatus, formatSessionFooter, FOREGROUND_WAIT_MS } from "./session-status.js";
5
7
  import { LiveStatusController } from "./live-status.js";
6
8
  import { CompactProgressController } from "./compact-progress.js";
7
9
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
@@ -98,6 +100,8 @@ export class FeishuBot {
98
100
  * command/error/card sends with model attribution. */
99
101
  replyModelFooters = new Map();
100
102
  sendModelFooters = new Map();
103
+ replyStatusFooters = new Map();
104
+ sendStatusFooters = new Map();
101
105
  /** Per-chat durable outbox retry timer. */
102
106
  deliveryRetryTimers = new Map();
103
107
  /** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
@@ -930,9 +934,9 @@ export class FeishuBot {
930
934
  return;
931
935
  }
932
936
  // --- Queue-based sending: if agent is busy, just accumulate ---
933
- const busySince = this.busyChats.get(chatId) || 0;
934
- const BUSY_TIMEOUT_MS = 1_800_000; // 30 minutes, matches collectReply safety timeout
935
- const isBusy = busySince > 0 && (Date.now() - busySince) < BUSY_TIMEOUT_MS;
937
+ // The actual queue owner, not elapsed wall time, decides whether input
938
+ // should steer/queue. Long healthy runs must not lose their busy ownership.
939
+ const isBusy = this.queueRuns.has(chatId);
936
940
  if (isBusy) {
937
941
  // The agent is mid-run. Instead of only queuing to wait for the current
938
942
  // run to finish, try to STEER this message into the active run so it is
@@ -948,10 +952,12 @@ export class FeishuBot {
948
952
  pending.push({ messageId, emoji: "Typing", rowId: insertedId });
949
953
  this.pendingAckMessages.set(chatId, pending);
950
954
  const norm = cleanText.replace(/\s+/g, " ").trim();
955
+ let consumedBeforeAck = false;
951
956
  const stopWatchingConsumption = this.openclawClient.onSteerConsumed(sessionKeyForSteer, steerWrapped, (consumedText) => {
952
957
  const cn = (consumedText || "").replace(/\s+/g, " ").trim();
953
958
  if (cn !== norm && !cn.includes(norm))
954
959
  return false;
960
+ consumedBeforeAck = true;
955
961
  // Only transcript commitment/consumption transfers durable custody.
956
962
  // Until this point the local pending trigger remains the fallback for
957
963
  // detached dispatch rejection or an admission that never consumed.
@@ -970,7 +976,7 @@ export class FeishuBot {
970
976
  });
971
977
  const steer = await this.openclawClient.steer(sessionKeyForSteer, steerWrapped, cleanText).catch(() => ({ status: "unavailable" }));
972
978
  if (steer.status === "steered") {
973
- if (insertedId > 0) {
979
+ if (insertedId > 0 && !consumedBeforeAck) {
974
980
  this.pendingSteerCleanups.set(insertedId, () => {
975
981
  stopWatchingConsumption();
976
982
  if ("cancelPending" in steer)
@@ -990,13 +996,18 @@ export class FeishuBot {
990
996
  if ("cancelPending" in steer)
991
997
  steer.cancelPending?.();
992
998
  console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)} (steer=${steer.status}), queuing: "${cleanText.substring(0, 50)}..."`);
993
- return; // Message is in DB, will be picked up when agent finishes
994
- }
995
- if (busySince > 0) {
996
- // Busy timeout expired — unlock but don't processQueue here;
997
- // let the next new message trigger it naturally to avoid concurrent runs
998
- console.warn(`[${this.config.name}] Busy timeout expired for ${chatId.slice(-8)} (${Math.round((Date.now() - busySince) / 1000)}s), unlocking (will process on next message)`);
999
- this.busyChats.set(chatId, 0);
999
+ // Reconciliation may have finished the old owner during the steer RPC.
1000
+ // Ensure this new, still-pending input is drained even when that owner
1001
+ // exited through an error path. Already-consumed input is never replayed.
1002
+ const owner = this.queueRuns.get(chatId);
1003
+ const drainPending = () => {
1004
+ if (insertedId > 0 && this.store.getPendingTriggerIds(this.config.name, chatId).has(insertedId))
1005
+ return this.processQueue(chatId);
1006
+ };
1007
+ if (owner)
1008
+ void owner.then(drainPending, drainPending).catch(err => console.warn(`[${this.config.name}] pending drain failed:`, err.message));
1009
+ else
1010
+ await drainPending();
1000
1011
  return;
1001
1012
  }
1002
1013
  // --- Not busy, send now (with any accumulated messages) ---
@@ -1352,7 +1363,7 @@ export class FeishuBot {
1352
1363
  deliver: false,
1353
1364
  // Keep bridge UX responsive; long agent/tool loops should surface a clear failure
1354
1365
  // instead of leaving reactions stuck forever.
1355
- timeoutMs: 1_800_000,
1366
+ timeoutMs: FOREGROUND_WAIT_MS,
1356
1367
  includeContext: !isNativeCommandTrigger,
1357
1368
  includeBridgeAttachmentHint: !isNativeCommandTrigger,
1358
1369
  onSendAttempt: () => {
@@ -1375,6 +1386,11 @@ export class FeishuBot {
1375
1386
  markSubmittedBatch(`submitted:${runId}`);
1376
1387
  },
1377
1388
  onProgress: (event) => liveStatus?.progress(event),
1389
+ onWaitPaused: async (notice) => {
1390
+ await liveStatus?.showWaitingForResult(notice.message);
1391
+ if (Date.now() - (this.lastRealDeliveryAt.get(chatId) || 0) >= 5000)
1392
+ await this.sendWaitPaused(chatId, triggerId, lastHuman.messageId, notice);
1393
+ },
1378
1394
  }));
1379
1395
  }
1380
1396
  catch (mainErr) {
@@ -1585,6 +1601,43 @@ export class FeishuBot {
1585
1601
  this.pendingAckMessages.set(chatId, remainingAcks);
1586
1602
  }
1587
1603
  catch (err) {
1604
+ let pause = err instanceof SessionWaitPaused ? err : undefined;
1605
+ if (!pause && isWaitTimeout(err instanceof Error ? err.message : String(err))) {
1606
+ const state = await this.readSessionStatus(chatId);
1607
+ if (state.running)
1608
+ pause = new SessionWaitPaused(state, true);
1609
+ }
1610
+ if (pause) {
1611
+ const target = this.activeDeliveryTargets.get(chatId);
1612
+ if (target?.triggerId === triggerId && target.runId)
1613
+ this.openclawClient.releasePausedWait?.(target.runId);
1614
+ await liveStatus?.pauseForUnconfirmedResult(pause.message, `${this.config.name} 等待暂停 · ${pause.snapshot.status}`).catch(() => { });
1615
+ await this.sendWaitPaused(chatId, triggerId, lastHuman.messageId, pause).catch(() => { });
1616
+ // A wait notice is not the final answer and must never claim its key.
1617
+ // Do not mark DONE, retry this input, or abort the running session.
1618
+ for (const id of mergedTriggerIds)
1619
+ this.store.clearPendingTrigger(this.config.name, chatId, id);
1620
+ this.store.markMessagesSynced(this.config.name, chatId, mergedTriggerIds, `${this.config.name}:${chatId}:${triggerId}:wait-paused`);
1621
+ const acks = this.pendingAckMessages.get(chatId) || [];
1622
+ for (const ack of acks)
1623
+ if (completedTriggerIdSet.has(ack.rowId))
1624
+ await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
1625
+ this.pendingAckMessages.set(chatId, acks.filter(ack => !completedTriggerIdSet.has(ack.rowId)));
1626
+ break;
1627
+ }
1628
+ if (err instanceof InactiveRunObservation) {
1629
+ await liveStatus?.pauseForUnconfirmedResult(err.message).catch(() => { });
1630
+ if (!liveStatus?.id && lastHuman.messageId)
1631
+ await this.replyMessage(lastHuman.messageId, `ℹ️ ${err.message}`).catch(() => { });
1632
+ const acks = this.pendingAckMessages.get(chatId) || [];
1633
+ for (const ack of acks)
1634
+ if (completedTriggerIdSet.has(ack.rowId))
1635
+ await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
1636
+ this.pendingAckMessages.set(chatId, acks.filter(ack => !completedTriggerIdSet.has(ack.rowId)));
1637
+ // No DONE/delivered mark and no replay of the old input. The new
1638
+ // pending trigger resumes after this queue owner's finally releases.
1639
+ break;
1640
+ }
1588
1641
  if (err instanceof UnhealthySessionError) {
1589
1642
  // stopForUnhealthySession already warned the user; mark the live
1590
1643
  // status as interrupted. The user can send another message to retry
@@ -1767,13 +1820,13 @@ export class FeishuBot {
1767
1820
  }
1768
1821
  return s;
1769
1822
  }
1770
- buildMarkdownCard(text, model) {
1823
+ buildMarkdownCard(text, model, status) {
1771
1824
  const elements = buildFeishuCardElements(text);
1772
1825
  if (model?.trim()) {
1773
1826
  elements.push({ tag: "hr" });
1774
1827
  elements.push({
1775
1828
  tag: "markdown",
1776
- content: `<font color='grey'>🧠 ${this.escapeCardText(model.trim())}</font>`,
1829
+ content: `<font color='grey'>🧠 ${this.escapeCardText(model.trim())}${status ? ` · ${this.escapeCardText(status)}` : ""}</font>`,
1777
1830
  });
1778
1831
  }
1779
1832
  return {
@@ -1816,7 +1869,21 @@ export class FeishuBot {
1816
1869
  };
1817
1870
  await this.patchLiveStatusCard(messageId, view, chatId);
1818
1871
  }
1872
+ async readSessionStatus(chatId) {
1873
+ try {
1874
+ return await this.openclawClient.getSessionRuntimeStatus(this.getSessionKey(chatId));
1875
+ }
1876
+ catch {
1877
+ return normalizeSessionRuntimeStatus(undefined);
1878
+ }
1879
+ }
1880
+ async sendWaitPaused(chatId, triggerId, messageId, notice) {
1881
+ const key = triggerId ? `trigger:${triggerId}:wait-paused` : `wait-paused:${notice.snapshot.checkedAt}`;
1882
+ await this.enqueueAndDispatchDelivery(chatId, "wait_paused", key, notice.message, [], messageId, key, this.config.model);
1883
+ }
1819
1884
  formatUserVisibleError(err) {
1885
+ if (err instanceof SessionWaitPaused)
1886
+ return err.message;
1820
1887
  const raw = err instanceof Error ? err.message : String(err);
1821
1888
  let reason = raw.replace(/\s+/g, " ").trim();
1822
1889
  if (/quota/i.test(reason) || /exceeded/i.test(reason)) {
@@ -2039,13 +2106,19 @@ export class FeishuBot {
2039
2106
  currentMessage: probe,
2040
2107
  currentSenderName: senderName,
2041
2108
  deliver: false,
2042
- timeoutMs: 1_800_000,
2109
+ timeoutMs: FOREGROUND_WAIT_MS,
2043
2110
  includeContext: false,
2044
2111
  includeBridgeAttachmentHint: false,
2045
2112
  onProgress: (event) => liveStatus?.progress(event),
2113
+ onWaitPaused: async (notice) => {
2114
+ await liveStatus?.showWaitingForResult(notice.message);
2115
+ await this.sendWaitPaused(chatId, triggerId, lastHumanMessageId, notice);
2116
+ },
2046
2117
  }));
2047
2118
  }
2048
2119
  catch (err) {
2120
+ if (err instanceof SessionWaitPaused)
2121
+ throw err;
2049
2122
  // The confirmation round itself failed (timeout/unhealthy). Do not spin:
2050
2123
  // deliver the existing (truncated-looking) reply as-is.
2051
2124
  console.warn(`[${this.config.name}] auto-retry probe failed for ${chatId.slice(-8)}:`, this.errorSummary(err));
@@ -2134,11 +2207,20 @@ export class FeishuBot {
2134
2207
  this.cancelDelayedFailure(chatId);
2135
2208
  const timer = setTimeout(() => {
2136
2209
  this.delayedFailureTimers.delete(chatId);
2137
- void this.enqueueAndDispatchDelivery(chatId, "delayed_error", `trigger:${triggerId}:delayed-error`, text, [], replyToMessageId, `trigger:${triggerId}:delayed-error`)
2138
- .then(() => {
2210
+ void (async () => {
2211
+ if (isWaitTimeout(text)) {
2212
+ const status = await this.readSessionStatus(chatId);
2213
+ if (Date.now() - (this.lastRealDeliveryAt.get(chatId) || 0) < 90_000)
2214
+ return;
2215
+ if (status.running) {
2216
+ await this.sendWaitPaused(chatId, triggerId, replyToMessageId, new SessionWaitPaused(status, true));
2217
+ return;
2218
+ }
2219
+ }
2220
+ await this.enqueueAndDispatchDelivery(chatId, "delayed_error", `trigger:${triggerId}:delayed-error`, text, [], replyToMessageId, `trigger:${triggerId}:delayed-error`);
2139
2221
  if (triggerId)
2140
2222
  this.store.markDeliveredReply(this.config.name, chatId, triggerId, replyToMessageId);
2141
- })
2223
+ })()
2142
2224
  .catch((err) => {
2143
2225
  console.warn(`[${this.config.name}] delayed failure delivery failed:`, err.message);
2144
2226
  });
@@ -2342,23 +2424,28 @@ export class FeishuBot {
2342
2424
  const shouldReplyToSource = Boolean(replyTarget) && (item.sourceType.startsWith("assistant_visible")
2343
2425
  || item.sourceType === "verbose_transcript"
2344
2426
  || item.sourceType === "provider_error"
2345
- || item.sourceType === "delayed_error");
2427
+ || item.sourceType === "delayed_error"
2428
+ || item.sourceType === "wait_paused");
2346
2429
  let finalModel;
2347
2430
  try {
2348
2431
  const meta = JSON.parse(item.deliveryMetaJson || "{}");
2349
2432
  finalModel = typeof meta.model === "string" && meta.model.trim() ? meta.model.trim() : undefined;
2350
2433
  }
2351
2434
  catch { /* legacy/malformed metadata: send without model footer */ }
2435
+ const needsStatus = Boolean(finalModel) || item.sourceType.startsWith("assistant_visible") || ["provider_error", "delayed_error", "wait_paused"].includes(item.sourceType);
2436
+ const finalStatus = needsStatus ? formatSessionFooter(await this.readSessionStatus(chatId)) : undefined;
2437
+ if (needsStatus && !finalModel)
2438
+ finalModel = this.config.model;
2352
2439
  if (shouldReplyToSource) {
2353
2440
  try {
2354
- await this.replyFinalMessage(replyTarget, item.content, finalModel);
2441
+ await this.replyFinalMessage(replyTarget, item.content, finalModel, finalStatus);
2355
2442
  }
2356
2443
  catch {
2357
- await this.sendFinalMessage(chatId, item.content, finalModel);
2444
+ await this.sendFinalMessage(chatId, item.content, finalModel, finalStatus);
2358
2445
  }
2359
2446
  }
2360
2447
  else {
2361
- await this.sendFinalMessage(chatId, item.content, finalModel);
2448
+ await this.sendFinalMessage(chatId, item.content, finalModel, finalStatus);
2362
2449
  }
2363
2450
  // Rows created by v1.4.3 may still target a live-status card. Preserve
2364
2451
  // restart compatibility: send their text as a new message, then
@@ -2551,6 +2638,7 @@ export class FeishuBot {
2551
2638
  : undefined;
2552
2639
  liveStatus?.start(meta ? `第 ${meta.round}/${meta.maxRounds} 轮讨论` : "讨论中");
2553
2640
  let reply;
2641
+ let discussionWaitPaused = false;
2554
2642
  try {
2555
2643
  reply = await this.openclawClient.chatSendWithContext({
2556
2644
  sessionKey,
@@ -2558,20 +2646,36 @@ export class FeishuBot {
2558
2646
  currentMessage: prompt,
2559
2647
  currentSenderName: "Discussion Scheduler",
2560
2648
  deliver: false,
2561
- timeoutMs: 1_800_000,
2649
+ timeoutMs: FOREGROUND_WAIT_MS,
2562
2650
  emptyFinalAsNoReply: true,
2563
2651
  onProgress: (event) => { void liveStatus?.progress(event).catch((err) => console.warn(`[${this.config.name}] live status progress failed:`, err instanceof Error ? err.message : err)); },
2652
+ onWaitPaused: async (notice) => {
2653
+ await liveStatus?.showWaitingForResult(notice.message);
2654
+ await this.sendWaitPaused(chatId, 0, undefined, notice);
2655
+ },
2564
2656
  });
2565
2657
  }
2566
2658
  catch (err) {
2567
- await liveStatus?.fail().catch(() => { });
2568
- throw err;
2659
+ let pause = err instanceof SessionWaitPaused ? err : undefined;
2660
+ if (!pause && isWaitTimeout(err instanceof Error ? err.message : String(err))) {
2661
+ const status = await this.readSessionStatus(chatId);
2662
+ if (status.running)
2663
+ pause = new SessionWaitPaused(status, true);
2664
+ }
2665
+ if (pause) {
2666
+ discussionWaitPaused = true;
2667
+ await liveStatus?.pauseForUnconfirmedResult(pause.message, `${this.config.name} 等待暂停 · ${pause.snapshot.status}`).catch(() => { });
2668
+ await this.sendWaitPaused(chatId, 0, undefined, pause).catch(() => { });
2669
+ }
2670
+ else
2671
+ await liveStatus?.fail().catch(() => { });
2672
+ throw pause || err;
2569
2673
  }
2570
2674
  finally {
2571
2675
  // OpenClaw can emit the final assistant session.message shortly after
2572
2676
  // chatSend/collectReply returns. Keep discussion proactive muted briefly;
2573
2677
  // the discussion coordinator already owns user-visible delivery.
2574
- releaseProactiveMute(120_000);
2678
+ releaseProactiveMute(discussionWaitPaused ? 0 : 120_000);
2575
2679
  }
2576
2680
  const parsedReply = this.extractBridgeAttachments(reply);
2577
2681
  const rawVisibleReply = parsedReply.text.trim();
@@ -2826,21 +2930,25 @@ export class FeishuBot {
2826
2930
  async deleteMessageById(messageId) {
2827
2931
  await this.client.im.v1.message.delete({ path: { message_id: messageId } });
2828
2932
  }
2829
- async replyFinalMessage(messageId, text, model) {
2933
+ async replyFinalMessage(messageId, text, model, status) {
2830
2934
  if (!model?.trim())
2831
2935
  return this.replyMessage(messageId, text);
2832
2936
  this.replyModelFooters.set(messageId, model.trim());
2937
+ if (status)
2938
+ this.replyStatusFooters.set(messageId, status);
2833
2939
  try {
2834
2940
  return await this.replyMessage(messageId, text);
2835
2941
  }
2836
2942
  finally {
2837
2943
  this.replyModelFooters.delete(messageId);
2944
+ this.replyStatusFooters.delete(messageId);
2838
2945
  }
2839
2946
  }
2840
2947
  async replyMessage(messageId, text) {
2841
2948
  // Use Feishu CardKit v2 markdown component for full Markdown rendering.
2842
2949
  const model = this.replyModelFooters.get(messageId);
2843
- const card = this.buildMarkdownCard(text, model);
2950
+ const status = this.replyStatusFooters.get(messageId);
2951
+ const card = this.buildMarkdownCard(text, model, status);
2844
2952
  try {
2845
2953
  const res = await this.client.im.message.reply({
2846
2954
  path: { message_id: messageId },
@@ -2853,7 +2961,7 @@ export class FeishuBot {
2853
2961
  }
2854
2962
  catch {
2855
2963
  // Fallback to plain text if card fails; retain model attribution.
2856
- const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}` : text;
2964
+ const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}${status ? ` · ${status}` : ""}` : text;
2857
2965
  const res = await this.client.im.message.reply({
2858
2966
  path: { message_id: messageId },
2859
2967
  data: {
@@ -3185,20 +3293,24 @@ export class FeishuBot {
3185
3293
  /**
3186
3294
  * Send a proactive message to a chat (not a reply).
3187
3295
  */
3188
- async sendFinalMessage(chatId, text, model) {
3296
+ async sendFinalMessage(chatId, text, model, status) {
3189
3297
  if (!model?.trim())
3190
3298
  return this.sendMessage(chatId, text);
3191
3299
  this.sendModelFooters.set(chatId, model.trim());
3300
+ if (status)
3301
+ this.sendStatusFooters.set(chatId, status);
3192
3302
  try {
3193
3303
  return await this.sendMessage(chatId, text);
3194
3304
  }
3195
3305
  finally {
3196
3306
  this.sendModelFooters.delete(chatId);
3307
+ this.sendStatusFooters.delete(chatId);
3197
3308
  }
3198
3309
  }
3199
3310
  async sendMessage(chatId, text) {
3200
3311
  const model = this.sendModelFooters.get(chatId);
3201
- const card = this.buildMarkdownCard(text, model);
3312
+ const status = this.sendStatusFooters.get(chatId);
3313
+ const card = this.buildMarkdownCard(text, model, status);
3202
3314
  try {
3203
3315
  const res = await this.client.im.message.create({
3204
3316
  params: { receive_id_type: "chat_id" },
@@ -3217,7 +3329,7 @@ export class FeishuBot {
3217
3329
  this.markCurrentBotUnavailable(chatId, err);
3218
3330
  // Fallback to plain text; retain model attribution.
3219
3331
  try {
3220
- const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}` : text;
3332
+ const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}${status ? ` · ${status}` : ""}` : text;
3221
3333
  const res = await this.client.im.message.create({
3222
3334
  params: { receive_id_type: "chat_id" },
3223
3335
  data: {
@@ -50,6 +50,8 @@ export type LiveStatusOptions = {
50
50
  historySize?: number;
51
51
  /** Footer/auto-refresh cadence in ms. */
52
52
  tickMs?: number;
53
+ /** Fixed card refresh lifetime; activity does not extend it. */
54
+ refreshBudgetMs?: number;
53
55
  };
54
56
  export declare class LiveStatusController {
55
57
  private readonly callbacks;
@@ -61,8 +63,11 @@ export declare class LiveStatusController {
61
63
  private startedAt;
62
64
  private createTimer?;
63
65
  private tickTimer?;
66
+ private refreshBudgetTimer?;
64
67
  private createPromise?;
65
68
  private finalized;
69
+ private waitingForResult;
70
+ private updatesFrozen;
66
71
  private disabled;
67
72
  /** Consecutive safeEdit failures. A single transient Feishu error (e.g. card
68
73
  * patch code=2200 "Internal Error") must NOT permanently disable the card:
@@ -79,6 +84,12 @@ export declare class LiveStatusController {
79
84
  get id(): string | undefined;
80
85
  start(initialDetail?: string): void;
81
86
  progress(event: ProgressEvent | string): Promise<void>;
87
+ /** Freeze recurring/progress edits, not the original result observer.
88
+ * A single final/error summary remains allowed when the real outcome arrives. */
89
+ showWaitingForResult(detail: string): Promise<void>;
90
+ /** Release only local observation of a now-idle session, without claiming
91
+ * the previous task succeeded or was aborted. Normal progress is unchanged. */
92
+ pauseForUnconfirmedResult(detail: string, title?: string): Promise<void>;
82
93
  complete(): Promise<void>;
83
94
  /**
84
95
  * Freeze any successful terminal state (text, attachment-only, or NO_REPLY)
@@ -1,3 +1,4 @@
1
+ import { FOREGROUND_WAIT_MS } from "./session-status.js";
1
2
  const DEFAULT_DELAY_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_DELAY_MS || 800);
2
3
  const DEFAULT_MAX_CHARS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_MAX_CHARS || 120);
3
4
  // How many recent activity lines to keep in the card content area.
@@ -22,8 +23,11 @@ export class LiveStatusController {
22
23
  startedAt = Date.now();
23
24
  createTimer;
24
25
  tickTimer;
26
+ refreshBudgetTimer;
25
27
  createPromise;
26
28
  finalized = false;
29
+ waitingForResult = false;
30
+ updatesFrozen = false;
27
31
  disabled = false;
28
32
  /** Consecutive safeEdit failures. A single transient Feishu error (e.g. card
29
33
  * patch code=2200 "Internal Error") must NOT permanently disable the card:
@@ -42,9 +46,15 @@ export class LiveStatusController {
42
46
  }
43
47
  get id() { return this.messageId; }
44
48
  start(initialDetail) {
45
- if (this.disabled || this.finalized || this.createTimer || this.messageId)
49
+ if (this.disabled || this.finalized || this.updatesFrozen || this.createTimer || this.messageId)
46
50
  return;
47
51
  this.startedAt = Date.now();
52
+ this.refreshBudgetTimer = setTimeout(() => {
53
+ void this.showWaitingForResult(this.opts.locale === "en"
54
+ ? "Foreground refresh budget reached; background results are still monitored."
55
+ : "前台刷新时间已到,过程卡停止实时刷新;后台继续接收结果。").catch(err => this.callbacks.warn?.("live status freeze failed", err));
56
+ }, this.opts.refreshBudgetMs ?? FOREGROUND_WAIT_MS);
57
+ this.refreshBudgetTimer.unref?.();
48
58
  if (initialDetail && initialDetail.trim()) {
49
59
  this.pushLine("lifecycle", initialDetail.trim());
50
60
  }
@@ -56,6 +66,12 @@ export class LiveStatusController {
56
66
  async progress(event) {
57
67
  if (this.disabled || this.finalized)
58
68
  return;
69
+ if (this.updatesFrozen) {
70
+ // Keep only the cheap local counter for the eventual one-time summary.
71
+ if (typeof event !== "string" && event.kind === "tool" && event.phase === "start")
72
+ this.toolCallCount++;
73
+ return;
74
+ }
59
75
  // Card patch has no 20-edit cap (verified against Feishu im.message.patch),
60
76
  // so we can show a small rolling window of recent activity: tool start, tool
61
77
  // end, and intermediate assistant text each count as one line. We ignore
@@ -96,9 +112,41 @@ export class LiveStatusController {
96
112
  else {
97
113
  return; // lifecycle ticks: ignore (footer timer already advances)
98
114
  }
115
+ this.waitingForResult = false;
99
116
  await this.ensureCreatedNow();
100
117
  await this.safeEdit(this.buildView());
101
118
  }
119
+ /** Freeze recurring/progress edits, not the original result observer.
120
+ * A single final/error summary remains allowed when the real outcome arrives. */
121
+ async showWaitingForResult(detail) {
122
+ if (this.disabled || this.finalized || this.updatesFrozen)
123
+ return;
124
+ this.updatesFrozen = true;
125
+ this.waitingForResult = true;
126
+ this.pushLine("lifecycle", detail);
127
+ this.stopTimers();
128
+ if (this.createPromise)
129
+ await this.createPromise.catch(() => { });
130
+ this.stopTimers();
131
+ if (this.messageId && !this.disabled)
132
+ await this.safeEditFinal(this.buildView());
133
+ }
134
+ /** Release only local observation of a now-idle session, without claiming
135
+ * the previous task succeeded or was aborted. Normal progress is unchanged. */
136
+ async pauseForUnconfirmedResult(detail, title) {
137
+ if (this.finalized)
138
+ return;
139
+ this.pushLine("lifecycle", detail);
140
+ this.finalized = true;
141
+ this.stopTimers();
142
+ if (this.createPromise)
143
+ await this.createPromise.catch(() => { });
144
+ if (!this.messageId || this.disabled)
145
+ return;
146
+ const view = this.buildView();
147
+ view.title = title || (this.opts.locale === "en" ? `${this.opts.botName} result unconfirmed` : `${this.opts.botName} 结果未确认`);
148
+ await this.safeEditFinal(view);
149
+ }
102
150
  async complete() {
103
151
  this.state = "done";
104
152
  this.finalized = true;
@@ -205,6 +253,10 @@ export class LiveStatusController {
205
253
  this.lines = this.lines.slice(this.lines.length - limit);
206
254
  }
207
255
  stopTimers() {
256
+ if (this.refreshBudgetTimer) {
257
+ clearTimeout(this.refreshBudgetTimer);
258
+ this.refreshBudgetTimer = undefined;
259
+ }
208
260
  if (this.createTimer) {
209
261
  clearTimeout(this.createTimer);
210
262
  this.createTimer = undefined;
@@ -244,11 +296,11 @@ export class LiveStatusController {
244
296
  }
245
297
  /** Refresh the elapsed-time footer periodically even with no new activity. */
246
298
  startTicker() {
247
- if (this.tickTimer || this.disabled || this.finalized)
299
+ if (this.tickTimer || this.disabled || this.finalized || this.updatesFrozen)
248
300
  return;
249
301
  const tickMs = this.opts.tickMs ?? DEFAULT_TICK_MS;
250
302
  this.tickTimer = setInterval(() => {
251
- if (this.finalized || this.disabled || !this.messageId)
303
+ if (this.finalized || this.disabled || this.updatesFrozen || !this.messageId)
252
304
  return;
253
305
  void this.safeEdit(this.buildView());
254
306
  }, tickMs);
@@ -257,7 +309,7 @@ export class LiveStatusController {
257
309
  async safeEdit(view, force = false) {
258
310
  if (!this.messageId || this.disabled)
259
311
  return false;
260
- if (!force && this.finalized)
312
+ if (!force && (this.finalized || this.updatesFrozen))
261
313
  return false;
262
314
  const sig = this.signature(view);
263
315
  if (!force && sig === this.lastSentSignature)
@@ -338,6 +390,8 @@ export class LiveStatusController {
338
390
  title = en ? `\u2705 ${this.opts.botName} done` : `\u2705 ${this.opts.botName} \u5df2\u5b8c\u6210`;
339
391
  else if (this.state === "failed")
340
392
  title = en ? `\u26A0\uFE0F ${this.opts.botName} stopped` : `\u26A0\uFE0F ${this.opts.botName} \u6267\u884c\u4e2d\u65ad`;
393
+ else if (this.waitingForResult)
394
+ title = en ? `${this.opts.botName} waiting (card frozen)` : `${this.opts.botName} 等待后续结果(已停止刷新)`;
341
395
  else
342
396
  title = en ? `${this.opts.botName} is working` : `${this.opts.botName} \u6b63\u5728\u6267\u884c`;
343
397
  // On a clean finish (done / NO_REPLY) show only the compact summary. On a