openclaw-lark-multi-agent 1.4.9 → 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,
@@ -1388,7 +1388,10 @@ export class FeishuBot {
1388
1388
  onProgress: (event) => liveStatus?.progress(event),
1389
1389
  onWaitPaused: async (notice) => {
1390
1390
  await liveStatus?.showWaitingForResult(notice.message);
1391
- if (Date.now() - (this.lastRealDeliveryAt.get(chatId) || 0) >= 5000)
1391
+ // Yield is a confirmed handoff, not the timeout heuristic. A
1392
+ // recent unrelated reply must not suppress this turn's notice.
1393
+ if (notice.reason === "yield" ? !this.store.hasDeliveredReply(this.config.name, chatId, triggerId)
1394
+ : Date.now() - (this.lastRealDeliveryAt.get(chatId) || 0) >= 5000)
1392
1395
  await this.sendWaitPaused(chatId, triggerId, lastHuman.messageId, notice);
1393
1396
  },
1394
1397
  }));
@@ -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";
@@ -231,7 +231,9 @@ export class OpenClawClient {
231
231
  this.agentEvents.get(sk).push({
232
232
  ...frame.payload,
233
233
  stream: "chatFinal",
234
- data: { text: textParts.join("\n") },
234
+ // Only the Gateway chat-final envelope confirms a handoff.
235
+ // Never infer it from tool names, arguments or raw tool output.
236
+ data: { text: textParts.join("\n"), gatewayYielded: frame.payload?.yielded === true },
235
237
  });
236
238
  }
237
239
  else if (state === "error") {
@@ -467,6 +469,11 @@ export class OpenClawClient {
467
469
  /** Render protocol-4 structured tool args/results without `[object Object]`.
468
470
  * Prefer useful, non-secret fields and keep the fallback bounded. */
469
471
  formatToolValue(value, toolName, phase) {
472
+ // sessions_yield.message is private resumed context. Do not mirror its
473
+ // arguments/results (including JSON strings) into process cards or verbose
474
+ // tool replies. This is redaction only, never evidence of a handoff.
475
+ if (toolName === "sessions_yield")
476
+ return "";
470
477
  if (value === null || value === undefined || value === "")
471
478
  return "";
472
479
  if (typeof value === "string")
@@ -704,7 +711,7 @@ export class OpenClawClient {
704
711
  */
705
712
  collectReply(runId, timeoutMs = FOREGROUND_WAIT_MS, targetSessionKey, options) {
706
713
  let cleanupObservation = () => { };
707
- return new Promise((resolve, reject) => {
714
+ return new Promise((resolveResult, rejectResult) => {
708
715
  // Whether we were connected when the run started. We only treat a later
709
716
  // disconnect as a mid-run gateway drop (fail fast) if we began connected;
710
717
  // this keeps unit tests that drive collectReply without a live socket from
@@ -713,6 +720,7 @@ export class OpenClawClient {
713
720
  let text = "";
714
721
  let chatDeltaText = "";
715
722
  let chatFinalText = "";
723
+ let chatFinalSeen = false;
716
724
  let transcriptAssistantText = "";
717
725
  let sessionKey = targetSessionKey ? `agent:main:${targetSessionKey.replace(/^agent:[^:]+:/, "")}` : "";
718
726
  let shortSessionKey = targetSessionKey ? targetSessionKey.replace(/^agent:[^:]+:/, "") : "";
@@ -739,13 +747,18 @@ export class OpenClawClient {
739
747
  };
740
748
  let idleTimer;
741
749
  let observationClosed = false;
750
+ // Close synchronously: a deferred yield notice must not overtake a final
751
+ // or error just because Promise.finally cleanup has not run yet.
752
+ const resolve = (value) => { observationClosed = true; resolveResult(value); };
753
+ const reject = (error) => { observationClosed = true; rejectResult(error); };
742
754
  let checkingState = false;
743
755
  let lastStateCheckAt = -Infinity;
744
756
  let quietNotified = false;
745
757
  let decidingTimeout = false;
746
758
  let waitNoticeSent = false;
747
759
  let foregroundPaused = false;
748
- let foregroundWaitTimer;
760
+ let foregroundYielded = false;
761
+ let turnYielded = false;
749
762
  const handleTimeoutResult = async (error) => {
750
763
  if (observationClosed)
751
764
  return;
@@ -757,8 +770,6 @@ export class OpenClawClient {
757
770
  // Foreground notice, background observation. Keep the original queue
758
771
  // owner/progress path so later input still steers into the same run.
759
772
  foregroundPaused = true;
760
- if (foregroundWaitTimer)
761
- clearTimeout(foregroundWaitTimer);
762
773
  if (!waitNoticeSent) {
763
774
  waitNoticeSent = true;
764
775
  try {
@@ -775,7 +786,7 @@ export class OpenClawClient {
775
786
  }
776
787
  reject(error);
777
788
  };
778
- const checkRunState = async () => {
789
+ const checkRunState = async (idleStillCurrent = () => true) => {
779
790
  if (observationClosed || checkingState || Date.now() - lastStateCheckAt < 60_000)
780
791
  return;
781
792
  checkingState = true;
@@ -801,8 +812,10 @@ export class OpenClawClient {
801
812
  else if (snapshot.status === "error" || snapshot.status === "timeout") {
802
813
  if (snapshot.status === "timeout" || isWaitTimeout(String(snapshot.error || ""))) {
803
814
  const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
815
+ collectEvents();
804
816
  if (!observationClosed && status.running) {
805
- await handleTimeoutResult(new SessionWaitPaused(status, true));
817
+ if (idleStillCurrent())
818
+ await handleTimeoutResult(new SessionWaitPaused(status, true));
806
819
  return;
807
820
  }
808
821
  }
@@ -817,33 +830,102 @@ export class OpenClawClient {
817
830
  checkingState = false;
818
831
  }
819
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;
820
836
  const resetIdleTimer = (delayMs = timeoutMs, activity = true) => {
821
837
  if (activity) {
822
838
  if (!foregroundPaused)
823
839
  waitNoticeSent = false;
824
840
  quietNotified = false;
825
841
  }
842
+ const generation = ++idleGeneration;
843
+ const idleStillCurrent = () => !observationClosed && generation === idleGeneration;
826
844
  if (idleTimer)
827
845
  clearTimeout(idleTimer);
828
846
  if (observationClosed)
829
847
  return;
830
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;
831
854
  if (!quietNotified) {
832
855
  quietNotified = true;
833
856
  console.warn(`[OpenClaw] quiet run ${runId}; checking Gateway state without stopping execution`);
834
857
  }
835
- await checkRunState();
836
- if (!observationClosed) {
837
- const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
838
- if (!observationClosed && status.running) {
839
- await handleTimeoutResult(new SessionWaitPaused(status));
840
- return;
841
- }
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;
842
871
  }
843
- if (!observationClosed)
844
- resetIdleTimer(60_000, false);
872
+ resetIdleTimer(60_000, false);
845
873
  }, delayMs);
846
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
+ };
847
929
  const summarizeActivity = (ev) => {
848
930
  if (ev.stream === "item") {
849
931
  const data = ev.data || {};
@@ -906,10 +988,12 @@ export class OpenClawClient {
906
988
  clearTimeout(genericChatErrorTimer);
907
989
  resolve(finalText);
908
990
  };
909
- const poller = setInterval(() => {
991
+ const collectEvents = () => {
992
+ if (observationClosed)
993
+ return;
910
994
  // Gateway connection dropped mid-run (e.g. gateway restart): the run will
911
995
  // never deliver a reply on this connection, so stop waiting instead of
912
- // hanging until the 30-min idle timeout. Failing fast lets the bridge
996
+ // hanging until the idle timeout. Failing fast lets the bridge
913
997
  // release its busy lock so new messages are not stuck "waiting".
914
998
  if (startedConnected && !this.connected) {
915
999
  clearInterval(poller);
@@ -960,7 +1044,6 @@ export class OpenClawClient {
960
1044
  bucket.splice(i, 1);
961
1045
  if (!anchorSeen && isExpectedUserText(ev.data?.text || "")) {
962
1046
  anchorSeen = true;
963
- resetIdleTimer();
964
1047
  for (const pendingEv of preAnchorEvents) {
965
1048
  const pendingRunId = typeof pendingEv.runId === "string" ? pendingEv.runId : "";
966
1049
  if (pendingRunId && pendingEv.stream === "lifecycle" && pendingEv.data?.phase === "start") {
@@ -982,6 +1065,16 @@ export class OpenClawClient {
982
1065
  }
983
1066
  continue;
984
1067
  }
1068
+ // After handoff, an unowned new run (including requester-settle)
1069
+ // retains its existing proactive delivery route. A session/lifecycle
1070
+ // start alone must not turn it into the old run's final. Discussion's
1071
+ // transcript mute marks scheduler-owned collection; retain its existing
1072
+ // anchored collector path rather than changing discussion routing.
1073
+ if (eventSessionMatches && foregroundYielded && evRunId && !matchesRun
1074
+ && !this.mutedProactiveSessions.has(sessionKey) && !this.mutedProactiveSessions.has(shortSessionKey)) {
1075
+ bucket.splice(i, 1);
1076
+ continue;
1077
+ }
985
1078
  if (eventSessionMatches && expectedUserText && !anchorSeen && !matchesRun) {
986
1079
  bucket.splice(i, 1);
987
1080
  preAnchorEvents.push(ev);
@@ -1021,10 +1114,8 @@ export class OpenClawClient {
1021
1114
  continue;
1022
1115
  }
1023
1116
  bucket.splice(i, 1);
1024
- // Any matching event — including toolCall/toolResult/item/lifecycle —
1025
- // means the agent is still alive. Use an idle timeout, not an absolute
1026
- // wall-clock timeout, so long tool-heavy tasks are not killed while active.
1027
- resetIdleTimer();
1117
+ if (hasEffectiveActivity(ev))
1118
+ resetIdleTimer();
1028
1119
  rememberActivity(ev);
1029
1120
  // If more events arrive after a replay-invalid lifecycle end, that lifecycle
1030
1121
  // was not terminal for the user-visible run. Keep waiting for the real final.
@@ -1035,6 +1126,10 @@ export class OpenClawClient {
1035
1126
  if (ev.stream !== "lifecycle") {
1036
1127
  pendingRuntimeFailureText = "";
1037
1128
  }
1129
+ // A matched resumed turn can again complete via the existing
1130
+ // assistant/transcript + lifecycle path, even without chat final.
1131
+ if (ev.stream === "lifecycle" && ev.data?.phase === "start")
1132
+ turnYielded = false;
1038
1133
  if (ev.stream === "lifecycle" && ev.data?.phase === "start" && !lifecycleStartedLogged) {
1039
1134
  lifecycleStartedLogged = true;
1040
1135
  console.log(`[OpenClaw] lifecycle start for runId=${runId} after ${Date.now() - collectStartedAt}ms`);
@@ -1067,17 +1162,48 @@ export class OpenClawClient {
1067
1162
  }
1068
1163
  }
1069
1164
  if (ev.stream === "chatFinal") {
1165
+ chatFinalSeen = true;
1166
+ if (ev.data?.gatewayYielded === true) {
1167
+ // A yielded turn is not a terminal answer, even if its buffered
1168
+ // message contains a preface. Preserve the collector/queue owner
1169
+ // and final-delivery key; only pause foreground presentation.
1170
+ if (chatFinalText.trim())
1171
+ continue; // an actual final wins
1172
+ foregroundYielded = true;
1173
+ turnYielded = true;
1174
+ text = chatDeltaText = transcriptAssistantText = "";
1175
+ if (chatFinalTimer) {
1176
+ clearTimeout(chatFinalTimer);
1177
+ chatFinalTimer = null;
1178
+ }
1179
+ if (lifecycleEndTimer) {
1180
+ clearTimeout(lifecycleEndTimer);
1181
+ lifecycleEndTimer = null;
1182
+ }
1183
+ // Finish scanning this batch first so an already-arrived real
1184
+ // final/error wins over a stale waiting notice. No status RPC
1185
+ // (or ten-minute silence) is needed to confirm this wire fact.
1186
+ if (options?.onWaitPaused)
1187
+ void Promise.resolve().then(() => handleTimeoutResult(new SessionWaitPaused(normalizeSessionRuntimeStatus(undefined), false, "yield")));
1188
+ continue;
1189
+ }
1070
1190
  chatFinalText = ev.data?.text || "";
1191
+ if (chatFinalText.trim())
1192
+ turnYielded = false;
1193
+ else if (turnYielded)
1194
+ continue;
1071
1195
  // Fallback: if lifecycle end doesn't arrive within 5s, resolve
1072
1196
  if (!chatFinalTimer) {
1073
1197
  chatFinalTimer = setTimeout(() => {
1074
1198
  console.warn(`[OpenClaw] collectReply: lifecycle end missing, using chatFinal fallback`);
1075
- this.abortChat(targetSessionKey || sessionKey, runId).catch((err) => {
1076
- console.warn(`[OpenClaw] abort after chatFinal fallback failed:`, err.message);
1077
- });
1078
1199
  // Prefer final chat message over accumulated deltas: some providers may
1079
1200
  // emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
1080
1201
  const latestFinalText = this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
1202
+ // Never abort a yielded original run, nor an empty final.
1203
+ if (!foregroundYielded && latestFinalText)
1204
+ this.abortChat(targetSessionKey || sessionKey, runId).catch((err) => {
1205
+ console.warn(`[OpenClaw] abort after chatFinal fallback failed:`, err.message);
1206
+ });
1081
1207
  if (latestFinalText) {
1082
1208
  finish(latestFinalText);
1083
1209
  }
@@ -1092,6 +1218,11 @@ export class OpenClawClient {
1092
1218
  }
1093
1219
  }
1094
1220
  if (ev.stream === "lifecycle" && ev.data?.phase === "end") {
1221
+ // paused/end_turn can beat the confirming chat event, including
1222
+ // discussion's emptyFinalAsNoReply path. It is neither success
1223
+ // nor failure and alone does NOT authorize a handoff notice.
1224
+ if (!chatFinalText.trim() && (turnYielded || (ev.data?.livenessState === "paused" && ev.data?.stopReason === "end_turn")))
1225
+ continue;
1095
1226
  // Prefer final chat message over accumulated deltas: some providers may
1096
1227
  // emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
1097
1228
  const finalText = this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
@@ -1143,7 +1274,12 @@ export class OpenClawClient {
1143
1274
  };
1144
1275
  // If lifecycle end beats chat final, a short delta like "N" can be a truncated
1145
1276
  // final reply. Wait for chatFinal before resolving; otherwise suppress lone "N".
1146
- if (!options?.emptyFinalAsNoReply && ev.data?.livenessState === "working" && !chatFinalText && text.length <= 1) {
1277
+ if ((!options?.emptyFinalAsNoReply && ev.data?.livenessState === "working" && !chatFinalText && text.length <= 1)
1278
+ || (options?.emptyFinalAsNoReply && !chatFinalSeen && !finalText)) {
1279
+ // A lifecycle-only empty discussion end also needs this small
1280
+ // window for a confirming yielded chat final, not instant NO_REPLY.
1281
+ if (lifecycleEndTimer)
1282
+ clearTimeout(lifecycleEndTimer);
1147
1283
  lifecycleEndTimer = setTimeout(finishFromLifecycle, 5000);
1148
1284
  }
1149
1285
  else {
@@ -1164,7 +1300,6 @@ export class OpenClawClient {
1164
1300
  // back to this text.
1165
1301
  if (this.isRecoverableAgentError(errText)) {
1166
1302
  pendingRuntimeFailureText = `⚠️ Agent 未正常完成\n原因: ${errText}`;
1167
- resetIdleTimer();
1168
1303
  console.warn(`[OpenClaw] recoverable lifecycle error for runId=${evRunId || runId}; deferring (agent may auto-recover): ${errText.slice(0, 120)}`);
1169
1304
  return;
1170
1305
  }
@@ -1222,7 +1357,6 @@ export class OpenClawClient {
1222
1357
  }
1223
1358
  if (this.isRecoverableAgentError(errText)) {
1224
1359
  pendingRuntimeFailureText = `⚠️ Agent 未正常完成\n原因: ${errText}`;
1225
- resetIdleTimer();
1226
1360
  console.warn(`[OpenClaw] recoverable chat error for runId=${evRunId || runId}; deferring: ${errText.slice(0, 120)}`);
1227
1361
  bucket.splice(i, 1);
1228
1362
  continue;
@@ -1254,7 +1388,8 @@ export class OpenClawClient {
1254
1388
  }
1255
1389
  }
1256
1390
  }
1257
- }, 50);
1391
+ };
1392
+ const poller = setInterval(collectEvents, 50);
1258
1393
  const observationKey = targetSessionKey ? this.canonicalSessionKey(targetSessionKey) : "";
1259
1394
  const checkAfterNoActiveRun = async () => {
1260
1395
  await checkRunState();
@@ -1273,29 +1408,12 @@ export class OpenClawClient {
1273
1408
  };
1274
1409
  if (observationKey)
1275
1410
  this.runStateChecks.set(observationKey, checkAfterNoActiveRun);
1276
- // Fixed foreground budget: tool/assistant activity must not keep a card
1277
- // refreshing indefinitely. The original result observer stays alive.
1278
- if (options?.onWaitPaused) {
1279
- foregroundWaitTimer = setTimeout(async () => {
1280
- if (observationClosed || foregroundPaused)
1281
- return;
1282
- await checkRunState();
1283
- if (observationClosed || foregroundPaused)
1284
- return;
1285
- const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
1286
- if (!observationClosed && !foregroundPaused)
1287
- await handleTimeoutResult(new SessionWaitPaused(status));
1288
- }, timeoutMs);
1289
- foregroundWaitTimer.unref?.();
1290
- }
1291
1411
  // Bound local resources without cancelling an execution we cannot prove stuck.
1292
1412
  const observationHorizon = setTimeout(() => reject(new Error("LMA 本地监听达到 24 小时上限,结果未确认;未发送停止请求,请检查原运行。")), 24 * 60 * 60_000);
1293
1413
  observationHorizon.unref?.();
1294
1414
  cleanupObservation = () => {
1295
1415
  observationClosed = true;
1296
1416
  clearTimeout(idleTimer);
1297
- if (foregroundWaitTimer)
1298
- clearTimeout(foregroundWaitTimer);
1299
1417
  clearTimeout(observationHorizon);
1300
1418
  clearInterval(poller);
1301
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;
@@ -11,8 +12,9 @@ export declare const isWaitTimeout: (text: string) => boolean;
11
12
  export declare function normalizeSessionRuntimeStatus(value: any): SessionRuntimeStatus;
12
13
  /** Compact presentation only; never replaces the actual status/model values. */
13
14
  export declare function formatSessionFooter(snapshot: SessionRuntimeStatus): string;
14
- /** Local observation has ended, NOT an assertion that execution failed. */
15
+ /** Foreground waiting paused, NOT an assertion that execution ended. */
15
16
  export declare class SessionWaitPaused extends Error {
16
17
  readonly snapshot: SessionRuntimeStatus;
17
- constructor(snapshot: SessionRuntimeStatus, reportedTimeout?: boolean);
18
+ readonly reason: "timeout" | "yield";
19
+ constructor(snapshot: SessionRuntimeStatus, reportedTimeout?: boolean, reason?: "timeout" | "yield");
18
20
  }
@@ -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) {
@@ -26,14 +27,18 @@ export function formatSessionFooter(snapshot) {
26
27
  const limitK = limit === undefined ? "?" : Math.max(1, Math.round(limit / 1000));
27
28
  return `${snapshot.status} · ${usedK}K/${limitK}K`;
28
29
  }
29
- /** Local observation has ended, NOT an assertion that execution failed. */
30
+ /** Foreground waiting paused, NOT an assertion that execution ended. */
30
31
  export class SessionWaitPaused extends Error {
31
32
  snapshot;
32
- constructor(snapshot, reportedTimeout = false) {
33
- super(reportedTimeout
34
- ? "⏳ 本轮返回了超时,LMA 已暂停本次等待,但不据此认定整个会话已经结束。会话状态见下方;你可以继续等待后续结果。"
35
- : "⏳ 本次等待时间已到,LMA 暂停本轮等待,先返回会话状态供你判断。\n没有因此停止运行或自动重发本次请求;你可以继续等待后续结果。");
33
+ reason;
34
+ constructor(snapshot, reportedTimeout = false, reason = "timeout") {
35
+ super(reason === "yield"
36
+ ? "⏳ 本轮已转入后台等待;若有后续结果,将继续在此回复。未发送停止请求,也未自动重发请求。"
37
+ : reportedTimeout
38
+ ? "⏳ 本轮返回了超时,LMA 已暂停本次等待,但不据此认定整个会话已经结束。会话状态见下方;你可以继续等待后续结果。"
39
+ : "⏳ 本轮连续无有效进展的等待时间已到,LMA 暂停本轮等待,先返回会话状态供你判断。\n没有因此停止运行或自动重发本次请求;你可以继续等待后续结果。");
36
40
  this.snapshot = snapshot;
41
+ this.reason = reason;
37
42
  this.name = "SessionWaitPaused";
38
43
  }
39
44
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.4.9",
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": {