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.
@@ -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();
@@ -689,10 +698,12 @@ export class OpenClawClient {
689
698
  * Collect agent reply by polling accumulated events.
690
699
  * Matches by initial runId OR sessionKey to handle multi-turn tool calling
691
700
  * 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.
701
+ * Existing event/anchor matching is retained. After 30 minutes of silence,
702
+ * reconcile the original run instead of aborting or treating a draft as final.
703
+ * Explicit terminal/error/disconnect paths keep their existing behavior.
694
704
  */
695
- collectReply(runId, timeoutMs = 1800000, targetSessionKey, options) {
705
+ collectReply(runId, timeoutMs = FOREGROUND_WAIT_MS, targetSessionKey, options) {
706
+ let cleanupObservation = () => { };
696
707
  return new Promise((resolve, reject) => {
697
708
  // Whether we were connected when the run started. We only treat a later
698
709
  // disconnect as a mid-run gateway drop (fail fast) if we began connected;
@@ -727,37 +738,111 @@ export class OpenClawClient {
727
738
  return actual === expected || actual.includes(expected) || expected.includes(actual);
728
739
  };
729
740
  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);
741
+ let observationClosed = false;
742
+ let checkingState = false;
743
+ let lastStateCheckAt = -Infinity;
744
+ let quietNotified = false;
745
+ let decidingTimeout = false;
746
+ let waitNoticeSent = false;
747
+ let foregroundPaused = false;
748
+ let foregroundWaitTimer;
749
+ const handleTimeoutResult = async (error) => {
750
+ if (observationClosed)
751
+ return;
752
+ if (chatFinalText.trim()) {
753
+ finish(chatFinalText);
754
+ return;
755
+ }
756
+ if (error instanceof SessionWaitPaused && options?.onWaitPaused) {
757
+ // Foreground notice, background observation. Keep the original queue
758
+ // owner/progress path so later input still steers into the same run.
759
+ foregroundPaused = true;
760
+ if (foregroundWaitTimer)
761
+ clearTimeout(foregroundWaitTimer);
762
+ if (!waitNoticeSent) {
763
+ waitNoticeSent = true;
764
+ try {
765
+ await options.onWaitPaused(error);
766
+ }
767
+ catch {
768
+ console.warn("[OpenClaw] wait notice delivery pending; observation retained");
769
+ }
770
+ }
771
+ decidingTimeout = false;
772
+ if (!observationClosed)
773
+ resetIdleTimer(60_000, false);
774
+ return;
775
+ }
776
+ reject(error);
777
+ };
778
+ const checkRunState = async () => {
779
+ if (observationClosed || checkingState || Date.now() - lastStateCheckAt < 60_000)
780
+ return;
781
+ checkingState = true;
782
+ lastStateCheckAt = Date.now();
783
+ try {
784
+ const snapshot = await this.rpc("agent.wait", { runId, timeoutMs: 1500 }, 2500);
785
+ if (observationClosed || snapshot?.runId !== runId || snapshot?.pendingError === true
786
+ || snapshot?.timeoutPhase === "queue" || snapshot?.timeoutPhase === "gateway_draining")
787
+ return;
788
+ // A bare wait timeout is not an execution timeout. Require a recorded
789
+ // terminal time; never infer failure from stopReason or replay flags.
790
+ const ended = snapshot.endedAt;
791
+ if (typeof ended !== "number" || !Number.isFinite(ended) || ended <= 0
792
+ || (typeof snapshot.startedAt === "number" && ended < snapshot.startedAt))
793
+ return;
794
+ if (snapshot.status === "ok" && snapshot.yielded !== true) {
795
+ const terminal = snapshot.terminalReply;
796
+ const visible = terminal?.disposition === "visible" && typeof terminal.text === "string"
797
+ ? terminal.text : terminal?.disposition === "silent" ? "NO_REPLY"
798
+ : this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
799
+ resolve(visible || "NO_REPLY");
750
800
  }
751
- else if (pendingRuntimeFailureText) {
752
- resolve(`${pendingRuntimeFailureText}\n\nLMA 已连续 ${Math.round(timeoutMs / 60000)} 分钟没有收到新的工具输出或最终回复,已停止等待。`);
801
+ else if (snapshot.status === "error" || snapshot.status === "timeout") {
802
+ if (snapshot.status === "timeout" || isWaitTimeout(String(snapshot.error || ""))) {
803
+ const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
804
+ if (!observationClosed && status.running) {
805
+ await handleTimeoutResult(new SessionWaitPaused(status, true));
806
+ return;
807
+ }
808
+ }
809
+ if (observationClosed)
810
+ return;
811
+ reject(new Error(`Agent error: ${typeof snapshot.error === "string" && snapshot.error.trim()
812
+ ? snapshot.error : `Gateway 已确认运行以 ${snapshot.status} 结束`}`));
753
813
  }
754
- else if (lastActivitySummary) {
755
- resolve(`⚠️ Agent 长时间没有产生最终回复\n最后活动: ${lastActivitySummary}\n时间: ${new Date(lastActivityAt).toLocaleString()}\n\nLMA 已连续 ${Math.round(timeoutMs / 60000)} 分钟没有收到新的工具输出或最终回复,已停止等待。`);
814
+ }
815
+ catch { /* Unavailable evidence means keep waiting, never abort/replay. */ }
816
+ finally {
817
+ checkingState = false;
818
+ }
819
+ };
820
+ const resetIdleTimer = (delayMs = timeoutMs, activity = true) => {
821
+ if (activity) {
822
+ if (!foregroundPaused)
823
+ waitNoticeSent = false;
824
+ quietNotified = false;
825
+ }
826
+ if (idleTimer)
827
+ clearTimeout(idleTimer);
828
+ if (observationClosed)
829
+ return;
830
+ idleTimer = setTimeout(async () => {
831
+ if (!quietNotified) {
832
+ quietNotified = true;
833
+ console.warn(`[OpenClaw] quiet run ${runId}; checking Gateway state without stopping execution`);
756
834
  }
757
- else {
758
- resolve("(timeout: no reply received)");
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
+ }
759
842
  }
760
- }, timeoutMs);
843
+ if (!observationClosed)
844
+ resetIdleTimer(60_000, false);
845
+ }, delayMs);
761
846
  };
762
847
  const summarizeActivity = (ev) => {
763
848
  if (ev.stream === "item") {
@@ -1041,7 +1126,14 @@ export class OpenClawClient {
1041
1126
  return;
1042
1127
  }
1043
1128
  if (state !== "working") {
1044
- finish(failureText);
1129
+ if (isWaitTimeout(failureText)) {
1130
+ if (!decidingTimeout) {
1131
+ decidingTimeout = true;
1132
+ void this.classifyWaitTimeout(failureText, targetSessionKey || sessionKey).then(handleTimeoutResult);
1133
+ }
1134
+ }
1135
+ else
1136
+ finish(failureText);
1045
1137
  return;
1046
1138
  }
1047
1139
  console.warn(`[OpenClaw] empty lifecycle end ignored for runId=${evRunId || runId}; waiting for real text or idle timeout`);
@@ -1076,6 +1168,14 @@ export class OpenClawClient {
1076
1168
  console.warn(`[OpenClaw] recoverable lifecycle error for runId=${evRunId || runId}; deferring (agent may auto-recover): ${errText.slice(0, 120)}`);
1077
1169
  return;
1078
1170
  }
1171
+ if (isWaitTimeout(errText)) {
1172
+ bucket.splice(i, 1);
1173
+ if (!decidingTimeout) {
1174
+ decidingTimeout = true;
1175
+ void this.classifyWaitTimeout(errText, targetSessionKey || sessionKey).then(handleTimeoutResult);
1176
+ }
1177
+ continue;
1178
+ }
1079
1179
  clearTimeout(idleTimer);
1080
1180
  clearInterval(poller);
1081
1181
  if (chatFinalTimer)
@@ -1101,7 +1201,13 @@ export class OpenClawClient {
1101
1201
  if (!genericChatErrorTimer) {
1102
1202
  genericChatErrorTimer = setTimeout(() => {
1103
1203
  genericChatErrorTimer = null;
1104
- void this.resolveTerminalChatError(evRunId || runId).then((detail) => {
1204
+ void this.resolveTerminalChatError(evRunId || runId, targetSessionKey || sessionKey).then((detail) => {
1205
+ if (observationClosed)
1206
+ return;
1207
+ if (chatFinalText.trim()) {
1208
+ finish(chatFinalText);
1209
+ return;
1210
+ }
1105
1211
  clearTimeout(idleTimer);
1106
1212
  clearInterval(poller);
1107
1213
  if (chatFinalTimer)
@@ -1109,7 +1215,7 @@ export class OpenClawClient {
1109
1215
  if (lifecycleEndTimer)
1110
1216
  clearTimeout(lifecycleEndTimer);
1111
1217
  reject(new Error(`Agent error: ${detail}`));
1112
- });
1218
+ }, error => { void handleTimeoutResult(error); });
1113
1219
  }, 350);
1114
1220
  }
1115
1221
  continue;
@@ -1121,6 +1227,14 @@ export class OpenClawClient {
1121
1227
  bucket.splice(i, 1);
1122
1228
  continue;
1123
1229
  }
1230
+ if (isWaitTimeout(errText)) {
1231
+ bucket.splice(i, 1);
1232
+ if (!decidingTimeout) {
1233
+ decidingTimeout = true;
1234
+ void this.classifyWaitTimeout(errText, targetSessionKey || sessionKey).then(handleTimeoutResult);
1235
+ }
1236
+ continue;
1237
+ }
1124
1238
  clearTimeout(idleTimer);
1125
1239
  clearInterval(poller);
1126
1240
  if (chatFinalTimer)
@@ -1141,7 +1255,61 @@ export class OpenClawClient {
1141
1255
  }
1142
1256
  }
1143
1257
  }, 50);
1144
- });
1258
+ const observationKey = targetSessionKey ? this.canonicalSessionKey(targetSessionKey) : "";
1259
+ const checkAfterNoActiveRun = async () => {
1260
+ await checkRunState();
1261
+ if (observationClosed || !observationKey)
1262
+ return;
1263
+ try {
1264
+ // Used only after the plugin reports no active run. Missing terminal
1265
+ // history is not failure, but an explicit idle session must not keep
1266
+ // a phantom queue owner blocking this newly-arrived user message.
1267
+ const history = await this.rpc("chat.history", { sessionKey: observationKey, limit: 1 }, 2500);
1268
+ if (!observationClosed && history?.sessionKey === observationKey && history?.sessionInfo?.hasActiveRun === false) {
1269
+ reject(new InactiveRunObservation());
1270
+ }
1271
+ }
1272
+ catch { /* Unknown activity cannot authorize releasing an active owner. */ }
1273
+ };
1274
+ if (observationKey)
1275
+ 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
+ // Bound local resources without cancelling an execution we cannot prove stuck.
1292
+ const observationHorizon = setTimeout(() => reject(new Error("LMA 本地监听达到 24 小时上限,结果未确认;未发送停止请求,请检查原运行。")), 24 * 60 * 60_000);
1293
+ observationHorizon.unref?.();
1294
+ cleanupObservation = () => {
1295
+ observationClosed = true;
1296
+ clearTimeout(idleTimer);
1297
+ if (foregroundWaitTimer)
1298
+ clearTimeout(foregroundWaitTimer);
1299
+ clearTimeout(observationHorizon);
1300
+ clearInterval(poller);
1301
+ if (chatFinalTimer)
1302
+ clearTimeout(chatFinalTimer);
1303
+ if (lifecycleEndTimer)
1304
+ clearTimeout(lifecycleEndTimer);
1305
+ if (replayInvalidTimer)
1306
+ clearTimeout(replayInvalidTimer);
1307
+ if (genericChatErrorTimer)
1308
+ clearTimeout(genericChatErrorTimer);
1309
+ if (observationKey && this.runStateChecks.get(observationKey) === checkAfterNoActiveRun)
1310
+ this.runStateChecks.delete(observationKey);
1311
+ };
1312
+ }).finally(() => cleanupObservation());
1145
1313
  }
1146
1314
  /**
1147
1315
  * Recoverable agent errors are lifecycle errors that OpenClaw can self-heal
@@ -1155,12 +1323,19 @@ export class OpenClawClient {
1155
1323
  const t = (errText || "").trim().toLowerCase();
1156
1324
  return t === "chat error" || t === "agent error: chat error";
1157
1325
  }
1158
- async resolveTerminalChatError(runId) {
1326
+ async resolveTerminalChatError(runId, sessionKey) {
1159
1327
  try {
1160
1328
  const snapshot = await this.rpc("agent.wait", { runId, timeoutMs: 1500 }, 2500);
1329
+ if (sessionKey && (snapshot?.status === "timeout" || isWaitTimeout(String(snapshot?.error || "")))) {
1330
+ const status = await this.getSessionRuntimeStatus(sessionKey);
1331
+ if (status.running)
1332
+ throw new SessionWaitPaused(status, true);
1333
+ }
1161
1334
  return this.formatTerminalRunError(snapshot);
1162
1335
  }
1163
1336
  catch (err) {
1337
+ if (err instanceof SessionWaitPaused)
1338
+ throw err;
1164
1339
  const detail = err instanceof Error ? err.message : String(err);
1165
1340
  return `OpenClaw 运行失败,但未返回具体原因${detail ? `(${detail})` : ""}`;
1166
1341
  }
@@ -1242,18 +1417,25 @@ export class OpenClawClient {
1242
1417
  */
1243
1418
  async steer(sessionKey, text, displayText) {
1244
1419
  const key = this.canonicalSessionKey(sessionKey);
1420
+ // Consumption can arrive before the plugin RPC response at a tool boundary.
1421
+ const cancelPending = this.registerPendingSteer(key, text, displayText);
1245
1422
  try {
1246
1423
  const res = await this.rpc("lma.steer", { sessionKey: key, text }, 10000);
1247
1424
  if (res?.status !== "steered") {
1425
+ // A locally busy collector can outlive the actual Gateway turn. Check
1426
+ // its exact terminal record now, rather than parking input for 30 min.
1427
+ if (res?.status === "no_active_run")
1428
+ await this.runStateChecks.get(key)?.();
1248
1429
  if (res?.status && res.status !== "no_active_run") {
1249
1430
  console.warn(`[OpenClaw] lma.steer ${res.status} for ${key.slice(-8)}`);
1250
1431
  }
1432
+ cancelPending();
1251
1433
  return { status: "unavailable" };
1252
1434
  }
1253
- const cancelPending = this.registerPendingSteer(key, text, displayText);
1254
1435
  return { status: "steered", cancelPending };
1255
1436
  }
1256
1437
  catch (err) {
1438
+ cancelPending();
1257
1439
  const message = err?.message || String(err);
1258
1440
  console.warn(`[OpenClaw] lma.steer unavailable for ${key.slice(-8)}: ${message}`);
1259
1441
  return { status: "unavailable" };
@@ -1393,6 +1575,37 @@ export class OpenClawClient {
1393
1575
  /**
1394
1576
  * Get session info (model, tokens, etc.) for status display.
1395
1577
  */
1578
+ /** Stop claiming an exact run's final output after observation has paused. */
1579
+ releasePausedWait(runId) {
1580
+ if (!runId)
1581
+ return;
1582
+ const timer = this.ownedDeliveryRunTimers.get(runId);
1583
+ if (timer)
1584
+ clearTimeout(timer);
1585
+ this.ownedDeliveryRunTimers.delete(runId);
1586
+ this.ownedDeliveryRuns.delete(runId);
1587
+ }
1588
+ async getSessionRuntimeStatus(sessionKey) {
1589
+ let timer;
1590
+ try {
1591
+ const value = await Promise.race([
1592
+ this.rpc("sessions.describe", { key: this.canonicalSessionKey(sessionKey) }, 1500),
1593
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("status lookup timeout")), 1500); }),
1594
+ ]);
1595
+ return normalizeSessionRuntimeStatus(value);
1596
+ }
1597
+ catch {
1598
+ return normalizeSessionRuntimeStatus(undefined);
1599
+ }
1600
+ finally {
1601
+ if (timer)
1602
+ clearTimeout(timer);
1603
+ }
1604
+ }
1605
+ async classifyWaitTimeout(text, sessionKey) {
1606
+ const status = await this.getSessionRuntimeStatus(sessionKey).catch(() => normalizeSessionRuntimeStatus(undefined));
1607
+ return status.running ? new SessionWaitPaused(status, true) : new Error(`Agent error: ${text}`);
1608
+ }
1396
1609
  async getSessionInfo(sessionKey) {
1397
1610
  return this.rpc("sessions.describe", { key: this.canonicalSessionKey(sessionKey) });
1398
1611
  }
@@ -1636,6 +1849,7 @@ export class OpenClawClient {
1636
1849
  const fullSessionKey = this.canonicalSessionKey(params.sessionKey);
1637
1850
  this.clearVerboseAssistantState(sk);
1638
1851
  let ownedRunId = "";
1852
+ let waitPaused = false;
1639
1853
  try {
1640
1854
  // Drop stale buffered events for this session before starting a new run.
1641
1855
  // This prevents an old final text (e.g. previous "ok") from being consumed by
@@ -1667,7 +1881,11 @@ export class OpenClawClient {
1667
1881
  this.progressCallbacks.set(fullSessionKey, params.onProgress);
1668
1882
  void params.onProgress({ kind: "lifecycle", text: "等待 OpenClaw 回复" });
1669
1883
  }
1670
- return await this.collectReply(result.runId, params.timeoutMs || 1800000, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply, expectedUserText: params.message });
1884
+ return await this.collectReply(result.runId, params.timeoutMs || FOREGROUND_WAIT_MS, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply, expectedUserText: params.message, onWaitPaused: params.onWaitPaused });
1885
+ }
1886
+ catch (error) {
1887
+ waitPaused = error instanceof SessionWaitPaused;
1888
+ throw error;
1671
1889
  }
1672
1890
  finally {
1673
1891
  // OpenClaw can emit the final assistant session.message a moment after
@@ -1676,7 +1894,10 @@ export class OpenClawClient {
1676
1894
  // are unaffected because they do not go through chatSend.
1677
1895
  this.progressCallbacks.delete(sk);
1678
1896
  this.progressCallbacks.delete(fullSessionKey);
1679
- this.releaseOwnedDeliveryRunAfter(ownedRunId, 30000);
1897
+ if (waitPaused)
1898
+ this.releasePausedWait(ownedRunId);
1899
+ else
1900
+ this.releaseOwnedDeliveryRunAfter(ownedRunId, 30000);
1680
1901
  }
1681
1902
  }
1682
1903
  shouldInjectBridgeAttachmentHint(text) {
@@ -1728,6 +1949,7 @@ export class OpenClawClient {
1728
1949
  onSendAttempt: params.onSendAttempt,
1729
1950
  onSubmitted: params.onSubmitted,
1730
1951
  onProgress: params.onProgress,
1952
+ onWaitPaused: params.onWaitPaused,
1731
1953
  });
1732
1954
  }
1733
1955
  // Build context block + actual message in one chat.send, or write the
@@ -1764,6 +1986,7 @@ export class OpenClawClient {
1764
1986
  onSendAttempt: params.onSendAttempt,
1765
1987
  onSubmitted: params.onSubmitted,
1766
1988
  onProgress: params.onProgress,
1989
+ onWaitPaused: params.onWaitPaused,
1767
1990
  });
1768
1991
  }
1769
1992
  formatContextLines(messages) {
@@ -0,0 +1,18 @@
1
+ /** Frontend budget only; does not change Gateway execution limits. */
2
+ export declare const FOREGROUND_WAIT_MS: number;
3
+ export type SessionRuntimeStatus = {
4
+ status: string;
5
+ running: boolean;
6
+ checkedAt: number;
7
+ contextUsedTokens?: number;
8
+ contextLimitTokens?: number;
9
+ };
10
+ export declare const isWaitTimeout: (text: string) => boolean;
11
+ export declare function normalizeSessionRuntimeStatus(value: any): SessionRuntimeStatus;
12
+ /** Compact presentation only; never replaces the actual status/model values. */
13
+ export declare function formatSessionFooter(snapshot: SessionRuntimeStatus): string;
14
+ /** Local observation has ended, NOT an assertion that execution failed. */
15
+ export declare class SessionWaitPaused extends Error {
16
+ readonly snapshot: SessionRuntimeStatus;
17
+ constructor(snapshot: SessionRuntimeStatus, reportedTimeout?: boolean);
18
+ }
@@ -0,0 +1,39 @@
1
+ /** Frontend budget only; does not change Gateway execution limits. */
2
+ export const FOREGROUND_WAIT_MS = 10 * 60_000;
3
+ 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
+ export function normalizeSessionRuntimeStatus(value) {
5
+ const session = value?.session || value?.sessionInfo;
6
+ const raw = typeof session?.status === "string" ? session.status.trim().toLowerCase() : "unknown";
7
+ const status = /^[a-z][a-z0-9 _|,-]{0,63}$/.test(raw) ? raw : "unknown";
8
+ const running = session?.hasActiveRun === true || status.split(/[|,]/).some((part) => part.trim() === "running");
9
+ // Same context counters as /status, not cumulative input/output billing.
10
+ // A missing/stale count must not be presented as an empty context.
11
+ const used = session?.totalTokens;
12
+ const limit = session?.contextTokens;
13
+ const contextUsedTokens = session?.totalTokensFresh !== false && typeof used === "number" && Number.isFinite(used) && used >= 0 ? used : undefined;
14
+ const contextLimitTokens = typeof limit === "number" && Number.isFinite(limit) && limit > 0 ? limit : undefined;
15
+ return { status: running ? "running" : status, running, checkedAt: Date.now(),
16
+ ...(contextUsedTokens !== undefined ? { contextUsedTokens } : {}),
17
+ ...(contextLimitTokens !== undefined ? { contextLimitTokens } : {}),
18
+ };
19
+ }
20
+ /** Compact presentation only; never replaces the actual status/model values. */
21
+ export function formatSessionFooter(snapshot) {
22
+ const { contextUsedTokens: used, contextLimitTokens: limit } = snapshot;
23
+ if (used === undefined && limit === undefined)
24
+ return snapshot.status;
25
+ const usedK = used === undefined ? "?" : Math.round(used / 1000);
26
+ const limitK = limit === undefined ? "?" : Math.max(1, Math.round(limit / 1000));
27
+ return `${snapshot.status} · ${usedK}K/${limitK}K`;
28
+ }
29
+ /** Local observation has ended, NOT an assertion that execution failed. */
30
+ export class SessionWaitPaused extends Error {
31
+ snapshot;
32
+ constructor(snapshot, reportedTimeout = false) {
33
+ super(reportedTimeout
34
+ ? "⏳ 本轮返回了超时,LMA 已暂停本次等待,但不据此认定整个会话已经结束。会话状态见下方;你可以继续等待后续结果。"
35
+ : "⏳ 本次等待时间已到,LMA 暂停本轮等待,先返回会话状态供你判断。\n没有因此停止运行或自动重发本次请求;你可以继续等待后续结果。");
36
+ this.snapshot = snapshot;
37
+ this.name = "SessionWaitPaused";
38
+ }
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.4.8",
3
+ "version": "1.4.9",
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": {
@@ -8,13 +8,13 @@
8
8
  "build:steer-plugin": "tsc -p plugins/lma-steer/tsconfig.json",
9
9
  "start": "node dist/index.js",
10
10
  "dev": "tsx src/index.ts",
11
- "test": "vitest run",
12
- "test:coverage": "vitest run --coverage",
11
+ "test": "vitest run --config tests/normal-flow-offline.config.ts",
12
+ "test:coverage": "vitest run --config tests/normal-flow-offline.config.ts --coverage",
13
13
  "prepack": "npm run build",
14
14
  "prepublishOnly": "npm test && npm pack --dry-run"
15
15
  },
16
16
  "dependencies": {
17
- "@larksuiteoapi/node-sdk": "^1.62.1",
17
+ "@larksuiteoapi/node-sdk": "^1.73.3",
18
18
  "@types/ws": "^8.18.1",
19
19
  "better-sqlite3": "^12.9.0",
20
20
  "ws": "^8.20.0"
@@ -33,3 +33,13 @@ normal queue.
33
33
  - method: `lma.steer`
34
34
  - params: `{ sessionKey: string, text: string }`
35
35
  - result: `{ status: "steered" | "no_active_run" | "rejected", sessionId?: string }`
36
+
37
+ ## 0.1.1 compatibility refresh
38
+
39
+ This release retains the existing `lma.steer` runtime behavior. A synchronous
40
+ `steered` result is provisional; LMA waits for a matching transcript message
41
+ before reporting consumption. The experimental steer V2 and question service
42
+ are not part of this release. A previously installed strict `questionBridge`
43
+ configuration shape is accepted for upgrade compatibility only; the plugin
44
+ ignores it and does not enable question handling. Existing values need not be
45
+ removed or exposed during this update.