openclaw-lark-multi-agent 1.4.10 → 1.4.11

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
@@ -601,10 +601,20 @@ State and context usage come from one bounded Gateway status lookup. K values
601
601
  are rounded integers (1000 tokens per K); unavailable/stale values are not shown
602
602
  as zero. The footer is a send-time snapshot, not a live status widget.
603
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
604
+ LMA pauses foreground waiting after **10 consecutive minutes without effective
605
+ task activity**, not after ten minutes of total runtime. New owned assistant
606
+ text or distinct tool start/completion resets the collector's idle clock;
607
+ card elapsed ticks, status polling, usage-only and duplicate events do not.
608
+ There is no independent card lifetime, and time before chat.send admission /
609
+ collector start is not charged as task silence. A write completion at 9:35 means
610
+ no silence freeze before 19:35, not a freeze at 10:00.
611
+ LMA sends a normal wait notice rather than claiming execution failed, displaying
612
+ the actual status even if unavailable. Confirmed chat-final `yielded: true`
613
+ still pauses immediately. Once frozen, the old card does not auto-resume after
614
+ later tool activity (same published policy for silence and yield); background
607
615
  result handling and the original queue remain, with no automatic abort/replay.
608
616
  Real results are sent separately; necessary terminal card cleanup is retained.
609
617
  This does not shorten the Gateway's execution limit. Unverified steer V2 and
610
618
  question adapters are not included in this release.
619
+
620
+ Timing/API parameters and limitations: [foreground idle semantics](docs/foreground-idle-2026-09-11.md).
package/README.zh-CN.md CHANGED
@@ -509,8 +509,16 @@ MIT
509
509
  1000 token 四舍五入为整数。缺失或明确过期的数据不冒充 0。尾部反映发送前
510
510
  查询的状态,不会持续自动刷新历史回复。
511
511
 
512
- 前台结果等待与过程卡刷新预算调整为 10 分钟。会话仍在 running 时发送普通
513
- 等待提示,不把它当作整个任务执行失败;旧卡不再周期刷新或随工具进度编辑。
512
+ 前台等待改为**连续 10 分钟没有有效任务活动才暂停**,不是任务总时长上限。
513
+ 已归属本次任务的新 assistant 文本、不同工具调用的开始/结束等会重置 collector
514
+ 的静默计时;卡片 elapsed ticker、状态轮询、usage-only 与重复事件不会续时。
515
+ 卡片没有独立总时长计时器;chat.send 接受及 collector 启动前的排队/内部耗时不算
516
+ 任务静默。9:35 的 write 完成后,最早 19:35 静默到期,不能在 10:00 冻结。
517
+ 到期发送普通等待提示,显示实际状态(不可用也不冒充 running),不判任务失败。
518
+ 真正的 chat-final `yielded: true` 仍立即提示并冻结,不等 10 分钟。
519
+ 普通静默暂停与 yield 都沿用已发布策略:后续工具活动不会自动解冻旧卡。
514
520
  后台仍监听原任务,保留原队列,不因此停止或自动重发请求。真实结果另行回复,
515
521
  保留必要的终态收尾。本改动不缩短 Gateway 的执行时限,也不包含未验收的
516
522
  steer V2 与问答适配器。
523
+
524
+ 计时、API 参数及限制详见 [前台静默计时说明](docs/foreground-idle-2026-09-11.md)。
@@ -1361,8 +1361,8 @@ export class FeishuBot {
1361
1361
  currentMessage: mergedContent,
1362
1362
  currentSenderName: lastHuman.senderName,
1363
1363
  deliver: false,
1364
- // Keep bridge UX responsive; long agent/tool loops should surface a clear failure
1365
- // instead of leaving reactions stuck forever.
1364
+ // Pause foreground only after consecutive effective-activity silence;
1365
+ // active long-running tool loops are not a failure.
1366
1366
  timeoutMs: FOREGROUND_WAIT_MS,
1367
1367
  includeContext: !isNativeCommandTrigger,
1368
1368
  includeBridgeAttachmentHint: !isNativeCommandTrigger,
@@ -50,8 +50,6 @@ 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;
55
53
  };
56
54
  export declare class LiveStatusController {
57
55
  private readonly callbacks;
@@ -63,7 +61,6 @@ export declare class LiveStatusController {
63
61
  private startedAt;
64
62
  private createTimer?;
65
63
  private tickTimer?;
66
- private refreshBudgetTimer?;
67
64
  private createPromise?;
68
65
  private finalized;
69
66
  private waitingForResult;
@@ -1,4 +1,3 @@
1
- import { FOREGROUND_WAIT_MS } from "./session-status.js";
2
1
  const DEFAULT_DELAY_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_DELAY_MS || 800);
3
2
  const DEFAULT_MAX_CHARS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_MAX_CHARS || 120);
4
3
  // How many recent activity lines to keep in the card content area.
@@ -23,7 +22,6 @@ export class LiveStatusController {
23
22
  startedAt = Date.now();
24
23
  createTimer;
25
24
  tickTimer;
26
- refreshBudgetTimer;
27
25
  createPromise;
28
26
  finalized = false;
29
27
  waitingForResult = false;
@@ -49,12 +47,9 @@ export class LiveStatusController {
49
47
  if (this.disabled || this.finalized || this.updatesFrozen || this.createTimer || this.messageId)
50
48
  return;
51
49
  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?.();
50
+ // Presentation may start before chat.send admission. Only the collector's
51
+ // effective-activity idle decision (or confirmed yield) freezes this card;
52
+ // elapsed ticks are presentation, never evidence of task progress.
58
53
  if (initialDetail && initialDetail.trim()) {
59
54
  this.pushLine("lifecycle", initialDetail.trim());
60
55
  }
@@ -253,10 +248,6 @@ export class LiveStatusController {
253
248
  this.lines = this.lines.slice(this.lines.length - limit);
254
249
  }
255
250
  stopTimers() {
256
- if (this.refreshBudgetTimer) {
257
- clearTimeout(this.refreshBudgetTimer);
258
- this.refreshBudgetTimer = undefined;
259
- }
260
251
  if (this.createTimer) {
261
252
  clearTimeout(this.createTimer);
262
253
  this.createTimer = undefined;
@@ -226,6 +226,7 @@ export declare class OpenClawClient {
226
226
  message: string;
227
227
  attachments?: ChatAttachment[];
228
228
  deliver?: boolean;
229
+ /** Consecutive effective-activity silence after admission/collector start; default 10 minutes, not total run time. */
229
230
  timeoutMs?: number;
230
231
  emptyFinalAsNoReply?: boolean;
231
232
  /** Called immediately before issuing chat.send RPC; use for at-most-once bookkeeping. */
@@ -256,6 +257,7 @@ export declare class OpenClawClient {
256
257
  currentMessage: string;
257
258
  currentSenderName: string;
258
259
  deliver?: boolean;
260
+ /** Consecutive effective-activity silence after admission/collector start; default 10 minutes, not total run time. */
259
261
  timeoutMs?: number;
260
262
  emptyFinalAsNoReply?: boolean;
261
263
  /** Native escaped commands (//status -> /status) should not receive catch-up context. */
@@ -1,6 +1,6 @@
1
1
  import WebSocket from "ws";
2
2
  import { SessionWaitPaused, normalizeSessionRuntimeStatus, isWaitTimeout, FOREGROUND_WAIT_MS } from "./session-status.js";
3
- import { randomUUID } from "crypto";
3
+ import { createHash, randomUUID } from "crypto";
4
4
  import { mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
5
5
  import { basename, extname, join, resolve, sep } from "path";
6
6
  import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
@@ -759,7 +759,6 @@ export class OpenClawClient {
759
759
  let foregroundPaused = false;
760
760
  let foregroundYielded = false;
761
761
  let turnYielded = false;
762
- let foregroundWaitTimer;
763
762
  const handleTimeoutResult = async (error) => {
764
763
  if (observationClosed)
765
764
  return;
@@ -771,8 +770,6 @@ export class OpenClawClient {
771
770
  // Foreground notice, background observation. Keep the original queue
772
771
  // owner/progress path so later input still steers into the same run.
773
772
  foregroundPaused = true;
774
- if (foregroundWaitTimer)
775
- clearTimeout(foregroundWaitTimer);
776
773
  if (!waitNoticeSent) {
777
774
  waitNoticeSent = true;
778
775
  try {
@@ -789,7 +786,7 @@ export class OpenClawClient {
789
786
  }
790
787
  reject(error);
791
788
  };
792
- const checkRunState = async () => {
789
+ const checkRunState = async (idleStillCurrent = () => true) => {
793
790
  if (observationClosed || checkingState || Date.now() - lastStateCheckAt < 60_000)
794
791
  return;
795
792
  checkingState = true;
@@ -815,8 +812,10 @@ export class OpenClawClient {
815
812
  else if (snapshot.status === "error" || snapshot.status === "timeout") {
816
813
  if (snapshot.status === "timeout" || isWaitTimeout(String(snapshot.error || ""))) {
817
814
  const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
815
+ collectEvents();
818
816
  if (!observationClosed && status.running) {
819
- await handleTimeoutResult(new SessionWaitPaused(status, true));
817
+ if (idleStillCurrent())
818
+ await handleTimeoutResult(new SessionWaitPaused(status, true));
820
819
  return;
821
820
  }
822
821
  }
@@ -831,33 +830,102 @@ export class OpenClawClient {
831
830
  checkingState = false;
832
831
  }
833
832
  };
833
+ // One clock for foreground silence and subsequent low-frequency observation.
834
+ // A generation fences status RPCs that complete after newer real activity.
835
+ let idleGeneration = 0;
834
836
  const resetIdleTimer = (delayMs = timeoutMs, activity = true) => {
835
837
  if (activity) {
836
838
  if (!foregroundPaused)
837
839
  waitNoticeSent = false;
838
840
  quietNotified = false;
839
841
  }
842
+ const generation = ++idleGeneration;
843
+ const idleStillCurrent = () => !observationClosed && generation === idleGeneration;
840
844
  if (idleTimer)
841
845
  clearTimeout(idleTimer);
842
846
  if (observationClosed)
843
847
  return;
844
848
  idleTimer = setTimeout(async () => {
849
+ // Drain already-received frames before judging silence: the 50ms
850
+ // collection poll may otherwise run just after this deadline.
851
+ collectEvents();
852
+ if (!idleStillCurrent())
853
+ return;
845
854
  if (!quietNotified) {
846
855
  quietNotified = true;
847
856
  console.warn(`[OpenClaw] quiet run ${runId}; checking Gateway state without stopping execution`);
848
857
  }
849
- await checkRunState();
850
- if (!observationClosed) {
851
- const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
852
- if (!observationClosed && status.running) {
853
- await handleTimeoutResult(new SessionWaitPaused(status));
854
- return;
855
- }
858
+ await checkRunState(idleStillCurrent);
859
+ if (!idleStillCurrent())
860
+ return;
861
+ const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
862
+ collectEvents();
863
+ if (!idleStillCurrent())
864
+ return;
865
+ // With a foreground callback, silence freezes presentation even when
866
+ // status is unknown/idle; never invent running. Without one, retain
867
+ // the published running-only typed outcome and background checks.
868
+ if (status.running || options?.onWaitPaused) {
869
+ await handleTimeoutResult(new SessionWaitPaused(status));
870
+ return;
856
871
  }
857
- if (!observationClosed)
858
- resetIdleTimer(60_000, false);
872
+ resetIdleTimer(60_000, false);
859
873
  }, delayMs);
860
874
  };
875
+ // Activity filtering is deliberately separate from reply assembly/matching:
876
+ // legacy session fallback must keep delivering replies as before, but an
877
+ // unrelated/unidentified run must not extend this run's foreground wait.
878
+ const activityKeys = new Set();
879
+ const activityText = new Map();
880
+ const claimActivity = (value) => {
881
+ // Retain fingerprints rather than copies of cumulative/private text.
882
+ const key = createHash("sha256").update(value).digest("hex");
883
+ if (activityKeys.has(key))
884
+ return false;
885
+ activityKeys.add(key);
886
+ return true;
887
+ };
888
+ const hasEffectiveActivity = (ev) => {
889
+ if (!ev.runId || !activeRunIds.has(ev.runId))
890
+ return false;
891
+ const data = ev.data || {};
892
+ const run = String(ev.runId);
893
+ if (ev.stream === "tool" || (ev.stream === "item" && data.kind === "tool")) {
894
+ const phase = data.phase === "result" ? (data.isError || data.error ? "error" : "end") : data.phase;
895
+ if (phase !== "start" && phase !== "end" && phase !== "error")
896
+ return false;
897
+ // Canonical tool and legacy item mirrors share identity and phase.
898
+ // Without a call id, conservatively recognize each name/phase once.
899
+ const id = data.toolCallId || data.itemId || data.id || data.name || data.toolName;
900
+ return Boolean(id) && claimActivity(JSON.stringify([run, "tool", id, phase]));
901
+ }
902
+ if (ev.stream === "lifecycle" && data.phase === "start") {
903
+ return claimActivity(JSON.stringify([run, "start"]));
904
+ }
905
+ if (ev.stream === "assistant" || ev.stream === "chatDelta" || ev.stream === "transcriptAssistant") {
906
+ const fullText = typeof data.text === "string" ? data.text : "";
907
+ const chunk = fullText || data.deltaText || data.delta;
908
+ const replace = Boolean(fullText || data.replace);
909
+ if (typeof chunk !== "string" || !chunk.trim())
910
+ return false;
911
+ const source = JSON.stringify([run, ev.stream]);
912
+ // seq identifies repeated incremental frames, not progress by itself.
913
+ // No-seq identical fragments are ambiguous: do not let replay extend idle.
914
+ if (!claimActivity(JSON.stringify([source, ev.seq ?? null, replace, chunk])))
915
+ return false;
916
+ const previous = activityText.get(source) || "";
917
+ const next = replace ? chunk : previous + chunk;
918
+ activityText.set(source, next);
919
+ if (next === previous || (replace && previous.includes(next)))
920
+ return false;
921
+ // Replayed replacement snapshots and assistant/chat/transcript mirrors
922
+ // with the same accumulated text are not additional task progress.
923
+ return claimActivity(JSON.stringify([run, "text", next]));
924
+ }
925
+ // Usage, ticks, user receipts, status/wait responses and unknown streams
926
+ // are not verified task progress. Tool output is counted at completion.
927
+ return false;
928
+ };
861
929
  const summarizeActivity = (ev) => {
862
930
  if (ev.stream === "item") {
863
931
  const data = ev.data || {};
@@ -920,10 +988,12 @@ export class OpenClawClient {
920
988
  clearTimeout(genericChatErrorTimer);
921
989
  resolve(finalText);
922
990
  };
923
- const poller = setInterval(() => {
991
+ const collectEvents = () => {
992
+ if (observationClosed)
993
+ return;
924
994
  // Gateway connection dropped mid-run (e.g. gateway restart): the run will
925
995
  // never deliver a reply on this connection, so stop waiting instead of
926
- // hanging until the 30-min idle timeout. Failing fast lets the bridge
996
+ // hanging until the idle timeout. Failing fast lets the bridge
927
997
  // release its busy lock so new messages are not stuck "waiting".
928
998
  if (startedConnected && !this.connected) {
929
999
  clearInterval(poller);
@@ -974,7 +1044,6 @@ export class OpenClawClient {
974
1044
  bucket.splice(i, 1);
975
1045
  if (!anchorSeen && isExpectedUserText(ev.data?.text || "")) {
976
1046
  anchorSeen = true;
977
- resetIdleTimer();
978
1047
  for (const pendingEv of preAnchorEvents) {
979
1048
  const pendingRunId = typeof pendingEv.runId === "string" ? pendingEv.runId : "";
980
1049
  if (pendingRunId && pendingEv.stream === "lifecycle" && pendingEv.data?.phase === "start") {
@@ -1045,10 +1114,8 @@ export class OpenClawClient {
1045
1114
  continue;
1046
1115
  }
1047
1116
  bucket.splice(i, 1);
1048
- // Any matching event — including toolCall/toolResult/item/lifecycle —
1049
- // means the agent is still alive. Use an idle timeout, not an absolute
1050
- // wall-clock timeout, so long tool-heavy tasks are not killed while active.
1051
- resetIdleTimer();
1117
+ if (hasEffectiveActivity(ev))
1118
+ resetIdleTimer();
1052
1119
  rememberActivity(ev);
1053
1120
  // If more events arrive after a replay-invalid lifecycle end, that lifecycle
1054
1121
  // was not terminal for the user-visible run. Keep waiting for the real final.
@@ -1115,7 +1182,7 @@ export class OpenClawClient {
1115
1182
  }
1116
1183
  // Finish scanning this batch first so an already-arrived real
1117
1184
  // final/error wins over a stale waiting notice. No status RPC
1118
- // (or ten-minute budget) is needed to confirm this wire fact.
1185
+ // (or ten-minute silence) is needed to confirm this wire fact.
1119
1186
  if (options?.onWaitPaused)
1120
1187
  void Promise.resolve().then(() => handleTimeoutResult(new SessionWaitPaused(normalizeSessionRuntimeStatus(undefined), false, "yield")));
1121
1188
  continue;
@@ -1233,7 +1300,6 @@ export class OpenClawClient {
1233
1300
  // back to this text.
1234
1301
  if (this.isRecoverableAgentError(errText)) {
1235
1302
  pendingRuntimeFailureText = `⚠️ Agent 未正常完成\n原因: ${errText}`;
1236
- resetIdleTimer();
1237
1303
  console.warn(`[OpenClaw] recoverable lifecycle error for runId=${evRunId || runId}; deferring (agent may auto-recover): ${errText.slice(0, 120)}`);
1238
1304
  return;
1239
1305
  }
@@ -1291,7 +1357,6 @@ export class OpenClawClient {
1291
1357
  }
1292
1358
  if (this.isRecoverableAgentError(errText)) {
1293
1359
  pendingRuntimeFailureText = `⚠️ Agent 未正常完成\n原因: ${errText}`;
1294
- resetIdleTimer();
1295
1360
  console.warn(`[OpenClaw] recoverable chat error for runId=${evRunId || runId}; deferring: ${errText.slice(0, 120)}`);
1296
1361
  bucket.splice(i, 1);
1297
1362
  continue;
@@ -1323,7 +1388,8 @@ export class OpenClawClient {
1323
1388
  }
1324
1389
  }
1325
1390
  }
1326
- }, 50);
1391
+ };
1392
+ const poller = setInterval(collectEvents, 50);
1327
1393
  const observationKey = targetSessionKey ? this.canonicalSessionKey(targetSessionKey) : "";
1328
1394
  const checkAfterNoActiveRun = async () => {
1329
1395
  await checkRunState();
@@ -1342,29 +1408,12 @@ export class OpenClawClient {
1342
1408
  };
1343
1409
  if (observationKey)
1344
1410
  this.runStateChecks.set(observationKey, checkAfterNoActiveRun);
1345
- // Fixed foreground budget: tool/assistant activity must not keep a card
1346
- // refreshing indefinitely. The original result observer stays alive.
1347
- if (options?.onWaitPaused) {
1348
- foregroundWaitTimer = setTimeout(async () => {
1349
- if (observationClosed || foregroundPaused)
1350
- return;
1351
- await checkRunState();
1352
- if (observationClosed || foregroundPaused)
1353
- return;
1354
- const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
1355
- if (!observationClosed && !foregroundPaused)
1356
- await handleTimeoutResult(new SessionWaitPaused(status));
1357
- }, timeoutMs);
1358
- foregroundWaitTimer.unref?.();
1359
- }
1360
1411
  // Bound local resources without cancelling an execution we cannot prove stuck.
1361
1412
  const observationHorizon = setTimeout(() => reject(new Error("LMA 本地监听达到 24 小时上限,结果未确认;未发送停止请求,请检查原运行。")), 24 * 60 * 60_000);
1362
1413
  observationHorizon.unref?.();
1363
1414
  cleanupObservation = () => {
1364
1415
  observationClosed = true;
1365
1416
  clearTimeout(idleTimer);
1366
- if (foregroundWaitTimer)
1367
- clearTimeout(foregroundWaitTimer);
1368
1417
  clearTimeout(observationHorizon);
1369
1418
  clearInterval(poller);
1370
1419
  if (chatFinalTimer)
@@ -1,4 +1,5 @@
1
- /** Frontend budget only; does not change Gateway execution limits. */
1
+ /** Consecutive effective-activity silence, not a total foreground lifetime.
2
+ * Starts with accepted-request collection; does not change Gateway execution limits. */
2
3
  export declare const FOREGROUND_WAIT_MS: number;
3
4
  export type SessionRuntimeStatus = {
4
5
  status: string;
@@ -1,4 +1,5 @@
1
- /** Frontend budget only; does not change Gateway execution limits. */
1
+ /** Consecutive effective-activity silence, not a total foreground lifetime.
2
+ * Starts with accepted-request collection; does not change Gateway execution limits. */
2
3
  export const FOREGROUND_WAIT_MS = 10 * 60_000;
3
4
  export const isWaitTimeout = (text) => /\btimeout\b|timed\s*out|超时/i.test(text) && !/invalid|validation|schema|malformed|permission denied|forbidden|unauthorized|authentication failed|quota|rate limit|无效参数|参数错误|校验失败/i.test(text);
4
5
  export function normalizeSessionRuntimeStatus(value) {
@@ -35,7 +36,7 @@ export class SessionWaitPaused extends Error {
35
36
  ? "⏳ 本轮已转入后台等待;若有后续结果,将继续在此回复。未发送停止请求,也未自动重发请求。"
36
37
  : reportedTimeout
37
38
  ? "⏳ 本轮返回了超时,LMA 已暂停本次等待,但不据此认定整个会话已经结束。会话状态见下方;你可以继续等待后续结果。"
38
- : "⏳ 本次等待时间已到,LMA 暂停本轮等待,先返回会话状态供你判断。\n没有因此停止运行或自动重发本次请求;你可以继续等待后续结果。");
39
+ : "⏳ 本轮连续无有效进展的等待时间已到,LMA 暂停本轮等待,先返回会话状态供你判断。\n没有因此停止运行或自动重发本次请求;你可以继续等待后续结果。");
39
40
  this.snapshot = snapshot;
40
41
  this.reason = reason;
41
42
  this.name = "SessionWaitPaused";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.4.10",
3
+ "version": "1.4.11",
4
4
  "description": "Multi-bot Lark/Feishu bridge for OpenClaw, with per-bot model routing and isolated sessions",
5
5
  "type": "module",
6
6
  "scripts": {