openclaw-lark-multi-agent 1.4.8 → 1.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { SessionWaitPaused, type SessionRuntimeStatus } from "./session-status.js";
1
2
  import { OpenClawConfig } from "./config.js";
2
3
  import { ChatMessage } from "./message-store.js";
3
4
  export type ProgressEvent = {
@@ -15,6 +16,9 @@ export type ProgressEvent = {
15
16
  kind: "lifecycle";
16
17
  text: string;
17
18
  };
19
+ export declare class InactiveRunObservation extends Error {
20
+ constructor();
21
+ }
18
22
  export type ChatAttachment = {
19
23
  type?: string;
20
24
  mimeType?: string;
@@ -37,6 +41,8 @@ export declare class OpenClawClient {
37
41
  private reconnectDelay;
38
42
  private maxReconnectDelay;
39
43
  private shouldReconnect;
44
+ /** Read-only reconciliation for a collector whose plugin reports no active run. */
45
+ private runStateChecks;
40
46
  /** Callbacks for tool events (verbose mode) */
41
47
  private toolEventCallbacks;
42
48
  private lastToolNamesByItemId;
@@ -102,8 +108,9 @@ export declare class OpenClawClient {
102
108
  * Collect agent reply by polling accumulated events.
103
109
  * Matches by initial runId OR sessionKey to handle multi-turn tool calling
104
110
  * where OpenClaw creates new runIds for each tool-call round.
105
- * No aggressive timeout — waits for lifecycle end as the source of truth.
106
- * 30-minute safety net only for catastrophic WS disconnection.
111
+ * Existing event/anchor matching is retained. After 30 minutes of silence,
112
+ * reconcile the original run instead of aborting or treating a draft as final.
113
+ * Explicit terminal/error/disconnect paths keep their existing behavior.
107
114
  */
108
115
  private collectReply;
109
116
  /**
@@ -179,6 +186,10 @@ export declare class OpenClawClient {
179
186
  /**
180
187
  * Get session info (model, tokens, etc.) for status display.
181
188
  */
189
+ /** Stop claiming an exact run's final output after observation has paused. */
190
+ releasePausedWait(runId: string): void;
191
+ getSessionRuntimeStatus(sessionKey: string): Promise<SessionRuntimeStatus>;
192
+ private classifyWaitTimeout;
182
193
  getSessionInfo(sessionKey: string): Promise<any>;
183
194
  /**
184
195
  * Ensure session is using the expected model. If not, patch it back.
@@ -222,6 +233,7 @@ export declare class OpenClawClient {
222
233
  /** Called after chat.send RPC succeeds and OpenClaw has accepted the user message. */
223
234
  onSubmitted?: (runId: string) => void | Promise<void>;
224
235
  onProgress?: (event: ProgressEvent) => void | Promise<void>;
236
+ onWaitPaused?: (notice: SessionWaitPaused) => void | Promise<void>;
225
237
  }): Promise<string>;
226
238
  private shouldInjectBridgeAttachmentHint;
227
239
  private bridgeAttachmentHint;
@@ -255,6 +267,7 @@ export declare class OpenClawClient {
255
267
  /** Called after chat.send RPC succeeds and OpenClaw has accepted the combined message. */
256
268
  onSubmitted?: (runId: string) => void | Promise<void>;
257
269
  onProgress?: (event: ProgressEvent) => void | Promise<void>;
270
+ onWaitPaused?: (notice: SessionWaitPaused) => void | Promise<void>;
258
271
  }): Promise<string>;
259
272
  private formatContextLines;
260
273
  private writeContextSyncFile;
@@ -1,8 +1,15 @@
1
1
  import WebSocket from "ws";
2
+ import { SessionWaitPaused, normalizeSessionRuntimeStatus, isWaitTimeout, FOREGROUND_WAIT_MS } from "./session-status.js";
2
3
  import { randomUUID } from "crypto";
3
4
  import { mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
4
5
  import { basename, extname, join, resolve, sep } from "path";
5
6
  import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
7
+ export class InactiveRunObservation extends Error {
8
+ constructor() {
9
+ super("Gateway 确认当前会话已空闲,但旧运行结果未能确认;追加消息将继续处理,未停止任何后台任务。");
10
+ this.name = "InactiveRunObservation";
11
+ }
12
+ }
6
13
  export const GATEWAY_PROTOCOL_MIN = 4;
7
14
  export const GATEWAY_PROTOCOL_MAX = 4;
8
15
  const REQUIRED_GATEWAY_METHODS = [
@@ -38,6 +45,8 @@ export class OpenClawClient {
38
45
  reconnectDelay = 1000;
39
46
  maxReconnectDelay = 30000;
40
47
  shouldReconnect = true;
48
+ /** Read-only reconciliation for a collector whose plugin reports no active run. */
49
+ runStateChecks = new Map();
41
50
  /** Callbacks for tool events (verbose mode) */
42
51
  toolEventCallbacks = new Map();
43
52
  lastToolNamesByItemId = new Map();
@@ -222,7 +231,9 @@ export class OpenClawClient {
222
231
  this.agentEvents.get(sk).push({
223
232
  ...frame.payload,
224
233
  stream: "chatFinal",
225
- 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 },
226
237
  });
227
238
  }
228
239
  else if (state === "error") {
@@ -458,6 +469,11 @@ export class OpenClawClient {
458
469
  /** Render protocol-4 structured tool args/results without `[object Object]`.
459
470
  * Prefer useful, non-secret fields and keep the fallback bounded. */
460
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 "";
461
477
  if (value === null || value === undefined || value === "")
462
478
  return "";
463
479
  if (typeof value === "string")
@@ -689,11 +705,13 @@ export class OpenClawClient {
689
705
  * Collect agent reply by polling accumulated events.
690
706
  * Matches by initial runId OR sessionKey to handle multi-turn tool calling
691
707
  * where OpenClaw creates new runIds for each tool-call round.
692
- * No aggressive timeout — waits for lifecycle end as the source of truth.
693
- * 30-minute safety net only for catastrophic WS disconnection.
708
+ * Existing event/anchor matching is retained. After 30 minutes of silence,
709
+ * reconcile the original run instead of aborting or treating a draft as final.
710
+ * Explicit terminal/error/disconnect paths keep their existing behavior.
694
711
  */
695
- collectReply(runId, timeoutMs = 1800000, targetSessionKey, options) {
696
- return new Promise((resolve, reject) => {
712
+ collectReply(runId, timeoutMs = FOREGROUND_WAIT_MS, targetSessionKey, options) {
713
+ let cleanupObservation = () => { };
714
+ return new Promise((resolveResult, rejectResult) => {
697
715
  // Whether we were connected when the run started. We only treat a later
698
716
  // disconnect as a mid-run gateway drop (fail fast) if we began connected;
699
717
  // this keeps unit tests that drive collectReply without a live socket from
@@ -702,6 +720,7 @@ export class OpenClawClient {
702
720
  let text = "";
703
721
  let chatDeltaText = "";
704
722
  let chatFinalText = "";
723
+ let chatFinalSeen = false;
705
724
  let transcriptAssistantText = "";
706
725
  let sessionKey = targetSessionKey ? `agent:main:${targetSessionKey.replace(/^agent:[^:]+:/, "")}` : "";
707
726
  let shortSessionKey = targetSessionKey ? targetSessionKey.replace(/^agent:[^:]+:/, "") : "";
@@ -727,37 +746,117 @@ export class OpenClawClient {
727
746
  return actual === expected || actual.includes(expected) || expected.includes(actual);
728
747
  };
729
748
  let idleTimer;
730
- const resetIdleTimer = () => {
731
- if (idleTimer)
732
- clearTimeout(idleTimer);
733
- idleTimer = setTimeout(() => {
734
- clearInterval(poller);
735
- if (chatFinalTimer)
736
- clearTimeout(chatFinalTimer);
737
- if (lifecycleEndTimer)
738
- clearTimeout(lifecycleEndTimer);
739
- if (replayInvalidTimer)
740
- clearTimeout(replayInvalidTimer);
741
- if (genericChatErrorTimer)
742
- clearTimeout(genericChatErrorTimer);
743
- console.warn(`[OpenClaw] collectReply idle timeout for runId=${runId} sessionKey=${sessionKey}`);
744
- this.abortChat(targetSessionKey || sessionKey, runId).catch((err) => {
745
- console.warn(`[OpenClaw] abort after collectReply idle timeout failed:`, err.message);
746
- });
747
- const visibleText = this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
748
- if (visibleText) {
749
- resolve(visibleText);
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); };
754
+ let checkingState = false;
755
+ let lastStateCheckAt = -Infinity;
756
+ let quietNotified = false;
757
+ let decidingTimeout = false;
758
+ let waitNoticeSent = false;
759
+ let foregroundPaused = false;
760
+ let foregroundYielded = false;
761
+ let turnYielded = false;
762
+ let foregroundWaitTimer;
763
+ const handleTimeoutResult = async (error) => {
764
+ if (observationClosed)
765
+ return;
766
+ if (chatFinalText.trim()) {
767
+ finish(chatFinalText);
768
+ return;
769
+ }
770
+ if (error instanceof SessionWaitPaused && options?.onWaitPaused) {
771
+ // Foreground notice, background observation. Keep the original queue
772
+ // owner/progress path so later input still steers into the same run.
773
+ foregroundPaused = true;
774
+ if (foregroundWaitTimer)
775
+ clearTimeout(foregroundWaitTimer);
776
+ if (!waitNoticeSent) {
777
+ waitNoticeSent = true;
778
+ try {
779
+ await options.onWaitPaused(error);
780
+ }
781
+ catch {
782
+ console.warn("[OpenClaw] wait notice delivery pending; observation retained");
783
+ }
750
784
  }
751
- else if (pendingRuntimeFailureText) {
752
- resolve(`${pendingRuntimeFailureText}\n\nLMA 已连续 ${Math.round(timeoutMs / 60000)} 分钟没有收到新的工具输出或最终回复,已停止等待。`);
785
+ decidingTimeout = false;
786
+ if (!observationClosed)
787
+ resetIdleTimer(60_000, false);
788
+ return;
789
+ }
790
+ reject(error);
791
+ };
792
+ const checkRunState = async () => {
793
+ if (observationClosed || checkingState || Date.now() - lastStateCheckAt < 60_000)
794
+ return;
795
+ checkingState = true;
796
+ lastStateCheckAt = Date.now();
797
+ try {
798
+ const snapshot = await this.rpc("agent.wait", { runId, timeoutMs: 1500 }, 2500);
799
+ if (observationClosed || snapshot?.runId !== runId || snapshot?.pendingError === true
800
+ || snapshot?.timeoutPhase === "queue" || snapshot?.timeoutPhase === "gateway_draining")
801
+ return;
802
+ // A bare wait timeout is not an execution timeout. Require a recorded
803
+ // terminal time; never infer failure from stopReason or replay flags.
804
+ const ended = snapshot.endedAt;
805
+ if (typeof ended !== "number" || !Number.isFinite(ended) || ended <= 0
806
+ || (typeof snapshot.startedAt === "number" && ended < snapshot.startedAt))
807
+ return;
808
+ if (snapshot.status === "ok" && snapshot.yielded !== true) {
809
+ const terminal = snapshot.terminalReply;
810
+ const visible = terminal?.disposition === "visible" && typeof terminal.text === "string"
811
+ ? terminal.text : terminal?.disposition === "silent" ? "NO_REPLY"
812
+ : this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
813
+ resolve(visible || "NO_REPLY");
753
814
  }
754
- else if (lastActivitySummary) {
755
- resolve(`⚠️ Agent 长时间没有产生最终回复\n最后活动: ${lastActivitySummary}\n时间: ${new Date(lastActivityAt).toLocaleString()}\n\nLMA 已连续 ${Math.round(timeoutMs / 60000)} 分钟没有收到新的工具输出或最终回复,已停止等待。`);
815
+ else if (snapshot.status === "error" || snapshot.status === "timeout") {
816
+ if (snapshot.status === "timeout" || isWaitTimeout(String(snapshot.error || ""))) {
817
+ const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
818
+ if (!observationClosed && status.running) {
819
+ await handleTimeoutResult(new SessionWaitPaused(status, true));
820
+ return;
821
+ }
822
+ }
823
+ if (observationClosed)
824
+ return;
825
+ reject(new Error(`Agent error: ${typeof snapshot.error === "string" && snapshot.error.trim()
826
+ ? snapshot.error : `Gateway 已确认运行以 ${snapshot.status} 结束`}`));
756
827
  }
757
- else {
758
- resolve("(timeout: no reply received)");
828
+ }
829
+ catch { /* Unavailable evidence means keep waiting, never abort/replay. */ }
830
+ finally {
831
+ checkingState = false;
832
+ }
833
+ };
834
+ const resetIdleTimer = (delayMs = timeoutMs, activity = true) => {
835
+ if (activity) {
836
+ if (!foregroundPaused)
837
+ waitNoticeSent = false;
838
+ quietNotified = false;
839
+ }
840
+ if (idleTimer)
841
+ clearTimeout(idleTimer);
842
+ if (observationClosed)
843
+ return;
844
+ idleTimer = setTimeout(async () => {
845
+ if (!quietNotified) {
846
+ quietNotified = true;
847
+ console.warn(`[OpenClaw] quiet run ${runId}; checking Gateway state without stopping execution`);
759
848
  }
760
- }, timeoutMs);
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
+ }
856
+ }
857
+ if (!observationClosed)
858
+ resetIdleTimer(60_000, false);
859
+ }, delayMs);
761
860
  };
762
861
  const summarizeActivity = (ev) => {
763
862
  if (ev.stream === "item") {
@@ -897,6 +996,16 @@ export class OpenClawClient {
897
996
  }
898
997
  continue;
899
998
  }
999
+ // After handoff, an unowned new run (including requester-settle)
1000
+ // retains its existing proactive delivery route. A session/lifecycle
1001
+ // start alone must not turn it into the old run's final. Discussion's
1002
+ // transcript mute marks scheduler-owned collection; retain its existing
1003
+ // anchored collector path rather than changing discussion routing.
1004
+ if (eventSessionMatches && foregroundYielded && evRunId && !matchesRun
1005
+ && !this.mutedProactiveSessions.has(sessionKey) && !this.mutedProactiveSessions.has(shortSessionKey)) {
1006
+ bucket.splice(i, 1);
1007
+ continue;
1008
+ }
900
1009
  if (eventSessionMatches && expectedUserText && !anchorSeen && !matchesRun) {
901
1010
  bucket.splice(i, 1);
902
1011
  preAnchorEvents.push(ev);
@@ -950,6 +1059,10 @@ export class OpenClawClient {
950
1059
  if (ev.stream !== "lifecycle") {
951
1060
  pendingRuntimeFailureText = "";
952
1061
  }
1062
+ // A matched resumed turn can again complete via the existing
1063
+ // assistant/transcript + lifecycle path, even without chat final.
1064
+ if (ev.stream === "lifecycle" && ev.data?.phase === "start")
1065
+ turnYielded = false;
953
1066
  if (ev.stream === "lifecycle" && ev.data?.phase === "start" && !lifecycleStartedLogged) {
954
1067
  lifecycleStartedLogged = true;
955
1068
  console.log(`[OpenClaw] lifecycle start for runId=${runId} after ${Date.now() - collectStartedAt}ms`);
@@ -982,17 +1095,48 @@ export class OpenClawClient {
982
1095
  }
983
1096
  }
984
1097
  if (ev.stream === "chatFinal") {
1098
+ chatFinalSeen = true;
1099
+ if (ev.data?.gatewayYielded === true) {
1100
+ // A yielded turn is not a terminal answer, even if its buffered
1101
+ // message contains a preface. Preserve the collector/queue owner
1102
+ // and final-delivery key; only pause foreground presentation.
1103
+ if (chatFinalText.trim())
1104
+ continue; // an actual final wins
1105
+ foregroundYielded = true;
1106
+ turnYielded = true;
1107
+ text = chatDeltaText = transcriptAssistantText = "";
1108
+ if (chatFinalTimer) {
1109
+ clearTimeout(chatFinalTimer);
1110
+ chatFinalTimer = null;
1111
+ }
1112
+ if (lifecycleEndTimer) {
1113
+ clearTimeout(lifecycleEndTimer);
1114
+ lifecycleEndTimer = null;
1115
+ }
1116
+ // Finish scanning this batch first so an already-arrived real
1117
+ // final/error wins over a stale waiting notice. No status RPC
1118
+ // (or ten-minute budget) is needed to confirm this wire fact.
1119
+ if (options?.onWaitPaused)
1120
+ void Promise.resolve().then(() => handleTimeoutResult(new SessionWaitPaused(normalizeSessionRuntimeStatus(undefined), false, "yield")));
1121
+ continue;
1122
+ }
985
1123
  chatFinalText = ev.data?.text || "";
1124
+ if (chatFinalText.trim())
1125
+ turnYielded = false;
1126
+ else if (turnYielded)
1127
+ continue;
986
1128
  // Fallback: if lifecycle end doesn't arrive within 5s, resolve
987
1129
  if (!chatFinalTimer) {
988
1130
  chatFinalTimer = setTimeout(() => {
989
1131
  console.warn(`[OpenClaw] collectReply: lifecycle end missing, using chatFinal fallback`);
990
- this.abortChat(targetSessionKey || sessionKey, runId).catch((err) => {
991
- console.warn(`[OpenClaw] abort after chatFinal fallback failed:`, err.message);
992
- });
993
1132
  // Prefer final chat message over accumulated deltas: some providers may
994
1133
  // emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
995
1134
  const latestFinalText = this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
1135
+ // Never abort a yielded original run, nor an empty final.
1136
+ if (!foregroundYielded && latestFinalText)
1137
+ this.abortChat(targetSessionKey || sessionKey, runId).catch((err) => {
1138
+ console.warn(`[OpenClaw] abort after chatFinal fallback failed:`, err.message);
1139
+ });
996
1140
  if (latestFinalText) {
997
1141
  finish(latestFinalText);
998
1142
  }
@@ -1007,6 +1151,11 @@ export class OpenClawClient {
1007
1151
  }
1008
1152
  }
1009
1153
  if (ev.stream === "lifecycle" && ev.data?.phase === "end") {
1154
+ // paused/end_turn can beat the confirming chat event, including
1155
+ // discussion's emptyFinalAsNoReply path. It is neither success
1156
+ // nor failure and alone does NOT authorize a handoff notice.
1157
+ if (!chatFinalText.trim() && (turnYielded || (ev.data?.livenessState === "paused" && ev.data?.stopReason === "end_turn")))
1158
+ continue;
1010
1159
  // Prefer final chat message over accumulated deltas: some providers may
1011
1160
  // emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
1012
1161
  const finalText = this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
@@ -1041,7 +1190,14 @@ export class OpenClawClient {
1041
1190
  return;
1042
1191
  }
1043
1192
  if (state !== "working") {
1044
- finish(failureText);
1193
+ if (isWaitTimeout(failureText)) {
1194
+ if (!decidingTimeout) {
1195
+ decidingTimeout = true;
1196
+ void this.classifyWaitTimeout(failureText, targetSessionKey || sessionKey).then(handleTimeoutResult);
1197
+ }
1198
+ }
1199
+ else
1200
+ finish(failureText);
1045
1201
  return;
1046
1202
  }
1047
1203
  console.warn(`[OpenClaw] empty lifecycle end ignored for runId=${evRunId || runId}; waiting for real text or idle timeout`);
@@ -1051,7 +1207,12 @@ export class OpenClawClient {
1051
1207
  };
1052
1208
  // If lifecycle end beats chat final, a short delta like "N" can be a truncated
1053
1209
  // final reply. Wait for chatFinal before resolving; otherwise suppress lone "N".
1054
- if (!options?.emptyFinalAsNoReply && ev.data?.livenessState === "working" && !chatFinalText && text.length <= 1) {
1210
+ if ((!options?.emptyFinalAsNoReply && ev.data?.livenessState === "working" && !chatFinalText && text.length <= 1)
1211
+ || (options?.emptyFinalAsNoReply && !chatFinalSeen && !finalText)) {
1212
+ // A lifecycle-only empty discussion end also needs this small
1213
+ // window for a confirming yielded chat final, not instant NO_REPLY.
1214
+ if (lifecycleEndTimer)
1215
+ clearTimeout(lifecycleEndTimer);
1055
1216
  lifecycleEndTimer = setTimeout(finishFromLifecycle, 5000);
1056
1217
  }
1057
1218
  else {
@@ -1076,6 +1237,14 @@ export class OpenClawClient {
1076
1237
  console.warn(`[OpenClaw] recoverable lifecycle error for runId=${evRunId || runId}; deferring (agent may auto-recover): ${errText.slice(0, 120)}`);
1077
1238
  return;
1078
1239
  }
1240
+ if (isWaitTimeout(errText)) {
1241
+ bucket.splice(i, 1);
1242
+ if (!decidingTimeout) {
1243
+ decidingTimeout = true;
1244
+ void this.classifyWaitTimeout(errText, targetSessionKey || sessionKey).then(handleTimeoutResult);
1245
+ }
1246
+ continue;
1247
+ }
1079
1248
  clearTimeout(idleTimer);
1080
1249
  clearInterval(poller);
1081
1250
  if (chatFinalTimer)
@@ -1101,7 +1270,13 @@ export class OpenClawClient {
1101
1270
  if (!genericChatErrorTimer) {
1102
1271
  genericChatErrorTimer = setTimeout(() => {
1103
1272
  genericChatErrorTimer = null;
1104
- void this.resolveTerminalChatError(evRunId || runId).then((detail) => {
1273
+ void this.resolveTerminalChatError(evRunId || runId, targetSessionKey || sessionKey).then((detail) => {
1274
+ if (observationClosed)
1275
+ return;
1276
+ if (chatFinalText.trim()) {
1277
+ finish(chatFinalText);
1278
+ return;
1279
+ }
1105
1280
  clearTimeout(idleTimer);
1106
1281
  clearInterval(poller);
1107
1282
  if (chatFinalTimer)
@@ -1109,7 +1284,7 @@ export class OpenClawClient {
1109
1284
  if (lifecycleEndTimer)
1110
1285
  clearTimeout(lifecycleEndTimer);
1111
1286
  reject(new Error(`Agent error: ${detail}`));
1112
- });
1287
+ }, error => { void handleTimeoutResult(error); });
1113
1288
  }, 350);
1114
1289
  }
1115
1290
  continue;
@@ -1121,6 +1296,14 @@ export class OpenClawClient {
1121
1296
  bucket.splice(i, 1);
1122
1297
  continue;
1123
1298
  }
1299
+ if (isWaitTimeout(errText)) {
1300
+ bucket.splice(i, 1);
1301
+ if (!decidingTimeout) {
1302
+ decidingTimeout = true;
1303
+ void this.classifyWaitTimeout(errText, targetSessionKey || sessionKey).then(handleTimeoutResult);
1304
+ }
1305
+ continue;
1306
+ }
1124
1307
  clearTimeout(idleTimer);
1125
1308
  clearInterval(poller);
1126
1309
  if (chatFinalTimer)
@@ -1141,7 +1324,61 @@ export class OpenClawClient {
1141
1324
  }
1142
1325
  }
1143
1326
  }, 50);
1144
- });
1327
+ const observationKey = targetSessionKey ? this.canonicalSessionKey(targetSessionKey) : "";
1328
+ const checkAfterNoActiveRun = async () => {
1329
+ await checkRunState();
1330
+ if (observationClosed || !observationKey)
1331
+ return;
1332
+ try {
1333
+ // Used only after the plugin reports no active run. Missing terminal
1334
+ // history is not failure, but an explicit idle session must not keep
1335
+ // a phantom queue owner blocking this newly-arrived user message.
1336
+ const history = await this.rpc("chat.history", { sessionKey: observationKey, limit: 1 }, 2500);
1337
+ if (!observationClosed && history?.sessionKey === observationKey && history?.sessionInfo?.hasActiveRun === false) {
1338
+ reject(new InactiveRunObservation());
1339
+ }
1340
+ }
1341
+ catch { /* Unknown activity cannot authorize releasing an active owner. */ }
1342
+ };
1343
+ if (observationKey)
1344
+ 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
+ // Bound local resources without cancelling an execution we cannot prove stuck.
1361
+ const observationHorizon = setTimeout(() => reject(new Error("LMA 本地监听达到 24 小时上限,结果未确认;未发送停止请求,请检查原运行。")), 24 * 60 * 60_000);
1362
+ observationHorizon.unref?.();
1363
+ cleanupObservation = () => {
1364
+ observationClosed = true;
1365
+ clearTimeout(idleTimer);
1366
+ if (foregroundWaitTimer)
1367
+ clearTimeout(foregroundWaitTimer);
1368
+ clearTimeout(observationHorizon);
1369
+ clearInterval(poller);
1370
+ if (chatFinalTimer)
1371
+ clearTimeout(chatFinalTimer);
1372
+ if (lifecycleEndTimer)
1373
+ clearTimeout(lifecycleEndTimer);
1374
+ if (replayInvalidTimer)
1375
+ clearTimeout(replayInvalidTimer);
1376
+ if (genericChatErrorTimer)
1377
+ clearTimeout(genericChatErrorTimer);
1378
+ if (observationKey && this.runStateChecks.get(observationKey) === checkAfterNoActiveRun)
1379
+ this.runStateChecks.delete(observationKey);
1380
+ };
1381
+ }).finally(() => cleanupObservation());
1145
1382
  }
1146
1383
  /**
1147
1384
  * Recoverable agent errors are lifecycle errors that OpenClaw can self-heal
@@ -1155,12 +1392,19 @@ export class OpenClawClient {
1155
1392
  const t = (errText || "").trim().toLowerCase();
1156
1393
  return t === "chat error" || t === "agent error: chat error";
1157
1394
  }
1158
- async resolveTerminalChatError(runId) {
1395
+ async resolveTerminalChatError(runId, sessionKey) {
1159
1396
  try {
1160
1397
  const snapshot = await this.rpc("agent.wait", { runId, timeoutMs: 1500 }, 2500);
1398
+ if (sessionKey && (snapshot?.status === "timeout" || isWaitTimeout(String(snapshot?.error || "")))) {
1399
+ const status = await this.getSessionRuntimeStatus(sessionKey);
1400
+ if (status.running)
1401
+ throw new SessionWaitPaused(status, true);
1402
+ }
1161
1403
  return this.formatTerminalRunError(snapshot);
1162
1404
  }
1163
1405
  catch (err) {
1406
+ if (err instanceof SessionWaitPaused)
1407
+ throw err;
1164
1408
  const detail = err instanceof Error ? err.message : String(err);
1165
1409
  return `OpenClaw 运行失败,但未返回具体原因${detail ? `(${detail})` : ""}`;
1166
1410
  }
@@ -1242,18 +1486,25 @@ export class OpenClawClient {
1242
1486
  */
1243
1487
  async steer(sessionKey, text, displayText) {
1244
1488
  const key = this.canonicalSessionKey(sessionKey);
1489
+ // Consumption can arrive before the plugin RPC response at a tool boundary.
1490
+ const cancelPending = this.registerPendingSteer(key, text, displayText);
1245
1491
  try {
1246
1492
  const res = await this.rpc("lma.steer", { sessionKey: key, text }, 10000);
1247
1493
  if (res?.status !== "steered") {
1494
+ // A locally busy collector can outlive the actual Gateway turn. Check
1495
+ // its exact terminal record now, rather than parking input for 30 min.
1496
+ if (res?.status === "no_active_run")
1497
+ await this.runStateChecks.get(key)?.();
1248
1498
  if (res?.status && res.status !== "no_active_run") {
1249
1499
  console.warn(`[OpenClaw] lma.steer ${res.status} for ${key.slice(-8)}`);
1250
1500
  }
1501
+ cancelPending();
1251
1502
  return { status: "unavailable" };
1252
1503
  }
1253
- const cancelPending = this.registerPendingSteer(key, text, displayText);
1254
1504
  return { status: "steered", cancelPending };
1255
1505
  }
1256
1506
  catch (err) {
1507
+ cancelPending();
1257
1508
  const message = err?.message || String(err);
1258
1509
  console.warn(`[OpenClaw] lma.steer unavailable for ${key.slice(-8)}: ${message}`);
1259
1510
  return { status: "unavailable" };
@@ -1393,6 +1644,37 @@ export class OpenClawClient {
1393
1644
  /**
1394
1645
  * Get session info (model, tokens, etc.) for status display.
1395
1646
  */
1647
+ /** Stop claiming an exact run's final output after observation has paused. */
1648
+ releasePausedWait(runId) {
1649
+ if (!runId)
1650
+ return;
1651
+ const timer = this.ownedDeliveryRunTimers.get(runId);
1652
+ if (timer)
1653
+ clearTimeout(timer);
1654
+ this.ownedDeliveryRunTimers.delete(runId);
1655
+ this.ownedDeliveryRuns.delete(runId);
1656
+ }
1657
+ async getSessionRuntimeStatus(sessionKey) {
1658
+ let timer;
1659
+ try {
1660
+ const value = await Promise.race([
1661
+ this.rpc("sessions.describe", { key: this.canonicalSessionKey(sessionKey) }, 1500),
1662
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("status lookup timeout")), 1500); }),
1663
+ ]);
1664
+ return normalizeSessionRuntimeStatus(value);
1665
+ }
1666
+ catch {
1667
+ return normalizeSessionRuntimeStatus(undefined);
1668
+ }
1669
+ finally {
1670
+ if (timer)
1671
+ clearTimeout(timer);
1672
+ }
1673
+ }
1674
+ async classifyWaitTimeout(text, sessionKey) {
1675
+ const status = await this.getSessionRuntimeStatus(sessionKey).catch(() => normalizeSessionRuntimeStatus(undefined));
1676
+ return status.running ? new SessionWaitPaused(status, true) : new Error(`Agent error: ${text}`);
1677
+ }
1396
1678
  async getSessionInfo(sessionKey) {
1397
1679
  return this.rpc("sessions.describe", { key: this.canonicalSessionKey(sessionKey) });
1398
1680
  }
@@ -1636,6 +1918,7 @@ export class OpenClawClient {
1636
1918
  const fullSessionKey = this.canonicalSessionKey(params.sessionKey);
1637
1919
  this.clearVerboseAssistantState(sk);
1638
1920
  let ownedRunId = "";
1921
+ let waitPaused = false;
1639
1922
  try {
1640
1923
  // Drop stale buffered events for this session before starting a new run.
1641
1924
  // This prevents an old final text (e.g. previous "ok") from being consumed by
@@ -1667,7 +1950,11 @@ export class OpenClawClient {
1667
1950
  this.progressCallbacks.set(fullSessionKey, params.onProgress);
1668
1951
  void params.onProgress({ kind: "lifecycle", text: "等待 OpenClaw 回复" });
1669
1952
  }
1670
- return await this.collectReply(result.runId, params.timeoutMs || 1800000, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply, expectedUserText: params.message });
1953
+ return await this.collectReply(result.runId, params.timeoutMs || FOREGROUND_WAIT_MS, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply, expectedUserText: params.message, onWaitPaused: params.onWaitPaused });
1954
+ }
1955
+ catch (error) {
1956
+ waitPaused = error instanceof SessionWaitPaused;
1957
+ throw error;
1671
1958
  }
1672
1959
  finally {
1673
1960
  // OpenClaw can emit the final assistant session.message a moment after
@@ -1676,7 +1963,10 @@ export class OpenClawClient {
1676
1963
  // are unaffected because they do not go through chatSend.
1677
1964
  this.progressCallbacks.delete(sk);
1678
1965
  this.progressCallbacks.delete(fullSessionKey);
1679
- this.releaseOwnedDeliveryRunAfter(ownedRunId, 30000);
1966
+ if (waitPaused)
1967
+ this.releasePausedWait(ownedRunId);
1968
+ else
1969
+ this.releaseOwnedDeliveryRunAfter(ownedRunId, 30000);
1680
1970
  }
1681
1971
  }
1682
1972
  shouldInjectBridgeAttachmentHint(text) {
@@ -1728,6 +2018,7 @@ export class OpenClawClient {
1728
2018
  onSendAttempt: params.onSendAttempt,
1729
2019
  onSubmitted: params.onSubmitted,
1730
2020
  onProgress: params.onProgress,
2021
+ onWaitPaused: params.onWaitPaused,
1731
2022
  });
1732
2023
  }
1733
2024
  // Build context block + actual message in one chat.send, or write the
@@ -1764,6 +2055,7 @@ export class OpenClawClient {
1764
2055
  onSendAttempt: params.onSendAttempt,
1765
2056
  onSubmitted: params.onSubmitted,
1766
2057
  onProgress: params.onProgress,
2058
+ onWaitPaused: params.onWaitPaused,
1767
2059
  });
1768
2060
  }
1769
2061
  formatContextLines(messages) {