openclaw-lark-multi-agent 1.8.13 → 1.8.15

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,7 +1,7 @@
1
1
  import WebSocket from "ws";
2
2
  import { safeErrorSummary, rawErrorExcerpt } from "./provider-errors.js";
3
3
  import { inspectError, genericFailure, announceCandidate } from "./terminal-errors.js";
4
- import { SessionWaitPaused, normalizeSessionRuntimeStatus, isWaitTimeout, FOREGROUND_WAIT_MS } from "./session-status.js";
4
+ import { SessionWaitPaused, normalizeSessionRuntimeStatus, isWaitTimeout, FOREGROUND_WAIT_MS, IDLE_GUARD_MS } from "./session-status.js";
5
5
  import { createHash, randomUUID } from "crypto";
6
6
  import { mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
7
7
  import { basename, extname, join, resolve, sep } from "path";
@@ -25,6 +25,10 @@ export class SupersededObservation extends Error {
25
25
  this.name = "SupersededObservation";
26
26
  }
27
27
  }
28
+ /** Local observer only: the exact background result remains owned by durable recovery. */
29
+ export class CollectorStalledError extends Error {
30
+ constructor() { super('本地状态观察未及时返回,已转为后台结果监听;没有停止任务或自动重发输入。'); this.name = 'CollectorStalledError'; }
31
+ }
28
32
  export class AdmissionUnconfirmed extends Error {
29
33
  submissionId;
30
34
  constructor(submissionId, cause) {
@@ -53,6 +57,12 @@ const CONTEXT_SYNC_DIR = join(getDataDir(), "context-sync");
53
57
  const FEISHU_DOCS_DIR = join(getDataDir(), "feishu-docs");
54
58
  const MAX_FEISHU_DOC_ATTACHMENT_BYTES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_FEISHU_DOC_ATTACHMENT_BYTES || 1024 * 1024);
55
59
  const MAX_INLINE_CONTEXT_MESSAGES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_INLINE_CONTEXT_MESSAGES || 20);
60
+ // Byte budgets apply to lossy progress projections, NEVER authoritative terminal
61
+ // text. Terminal records stay complete until exact readers + ACK reservations
62
+ // release, then successful result/durable observation custody can retire them.
63
+ const EVENT_PROGRESS_BUCKET_BYTES = 256 * 1024;
64
+ const EVENT_PROGRESS_TOTAL_BYTES = 4 * 1024 * 1024;
65
+ const EVENT_PROGRESS_FIELD_CHARS = 2048;
56
66
  const MAX_INLINE_CONTEXT_BYTES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_INLINE_CONTEXT_BYTES || 128 * 1024);
57
67
  /**
58
68
  * OpenClaw Gateway WebSocket client.
@@ -68,9 +78,14 @@ export class OpenClawClient {
68
78
  config;
69
79
  ws = null;
70
80
  pending = new Map();
81
+ admissionResponseHooks = new Map();
71
82
  connected = false;
72
83
  connectPromise = null;
73
84
  agentEvents = new Map();
85
+ eventProgressSizes = new WeakMap();
86
+ runEventReaders = new Map();
87
+ runEventReservations = new Map();
88
+ completedRunEventCustody = new Set();
74
89
  reconnectDelay = 1000;
75
90
  maxReconnectDelay = 30000;
76
91
  shouldReconnect = true;
@@ -83,10 +98,12 @@ export class OpenClawClient {
83
98
  * `stream=item` mirrors when a runtime emits both representations. */
84
99
  recentToolEventKeys = new Map();
85
100
  privateInputToolCalls = new Set();
101
+ publicToolCalls = new Set();
86
102
  sessionSettlementCallbacks = new Map();
87
103
  sessionMessageCallbacks = new Map();
88
104
  progressCallbacks = new Map();
89
105
  progressGenerations = new Map();
106
+ progressRuns = new Map();
90
107
  /** Live collectReply fences per canonical session key. The most recent
91
108
  * collector for a session owns the shared event bucket; a new batch (or a
92
109
  * queue-custody revocation) fences stale ones synchronously so they cannot
@@ -225,23 +242,166 @@ export class OpenClawClient {
225
242
  this.recoveryChecking = false;
226
243
  }
227
244
  }
245
+ runEventKey(sessionKey, runId) {
246
+ return JSON.stringify([sessionKey === undefined ? null : this.shortKey(sessionKey), runId]);
247
+ }
248
+ retainRunEventCustody(kind, sessionKey, runId) {
249
+ const key = this.runEventKey(sessionKey, runId);
250
+ const refs = kind === 'reader' ? this.runEventReaders : this.runEventReservations;
251
+ refs.set(key, (refs.get(key) || 0) + 1);
252
+ let released = false;
253
+ return () => {
254
+ if (released)
255
+ return;
256
+ released = true;
257
+ const count = (refs.get(key) || 1) - 1;
258
+ if (count)
259
+ refs.set(key, count);
260
+ else
261
+ refs.delete(key);
262
+ for (const completed of this.completedRunEventCustody) {
263
+ if (JSON.parse(completed)[1] === runId)
264
+ this.retireCompletedRunEvents(completed);
265
+ }
266
+ };
267
+ }
268
+ retireCompletedRunEvents(key) {
269
+ const [session, runId] = JSON.parse(key);
270
+ // A same-run second reader or receiver ACK reservation can still need the
271
+ // exact terminal. Never reclaim on merely seeing another session's frame.
272
+ if (this.runEventReaders.has(key) || this.runEventReaders.has(this.runEventKey(undefined, runId)) || this.runEventReservations.has(key))
273
+ return;
274
+ for (const [bucketKey, bucket] of this.agentEvents) {
275
+ const retained = bucket.filter(ev => !(ev.runId === runId && this.shortKey(ev.sessionKey || bucketKey) === session));
276
+ if (retained.length !== bucket.length)
277
+ this.agentEvents.set(bucketKey, retained);
278
+ }
279
+ this.completedRunEventCustody.delete(key);
280
+ }
281
+ isTerminalBufferedEvent(event) {
282
+ return event.stream === 'chatFinal' || event.stream === 'chatError'
283
+ || event.stream === 'lifecycle' && event.data?.phase === 'error';
284
+ }
285
+ /** Buffers are observation indexes, not raw tool-output/transcript caches.
286
+ * Presentation/evidence callbacks still see the already privacy-filtered wire
287
+ * payload. Keep only the fields collectReply actually consumes here. */
288
+ projectBufferedEvent(event) {
289
+ const data = event.data || {}, clip = (value) => typeof value === 'string' ? value.slice(0, EVENT_PROGRESS_FIELD_CHARS) : value;
290
+ const out = { runId: event.runId, sessionKey: event.sessionKey, stream: event.stream, seq: event.seq, data: {} };
291
+ if (event.stream === 'chatFinal') {
292
+ out.data = { text: data.gatewayYielded === true ? '' : typeof data.text === 'string' ? data.text : '', gatewayYielded: data.gatewayYielded === true };
293
+ return out; // Entire authority, not a preview; no duplicated message body.
294
+ }
295
+ if (event.stream === 'chatError') {
296
+ out.data = { error: data.error };
297
+ return out;
298
+ }
299
+ const keep = event.stream === 'lifecycle'
300
+ ? ['phase', 'livenessState', 'status', 'stopReason', 'replayInvalid', 'aborted', ...(data.phase === 'error' ? ['error'] : [])]
301
+ : event.stream === 'tool' || event.stream === 'item'
302
+ ? ['kind', 'phase', 'name', 'toolName', 'toolCallId', 'itemId', 'id', 'isError']
303
+ : ['text', 'deltaText', 'delta', 'replace'];
304
+ for (const key of keep)
305
+ if (data[key] !== undefined)
306
+ out.data[key] = key === 'error' ? data[key] : clip(data[key]);
307
+ if (['assistant', 'chatDelta', 'transcriptAssistant'].includes(event.stream)) {
308
+ const full = typeof data.text === 'string' ? data.text : '';
309
+ const chunk = full || data.deltaText || data.delta;
310
+ if (typeof chunk === 'string' && chunk.length > EVENT_PROGRESS_FIELD_CHARS) {
311
+ // Activity identity must notice changes beyond a retained preview, and
312
+ // dedup cumulative snapshots/mirrors, without retaining the raw draft.
313
+ if (chunk.trim())
314
+ out.data.activityFingerprint = createHash('sha256').update(chunk).digest('hex');
315
+ else
316
+ out.data.text = out.data.deltaText = out.data.delta = '';
317
+ }
318
+ }
319
+ if (event.stream === 'tool' || event.stream === 'item') {
320
+ if (typeof data.meta === 'string')
321
+ out.data.meta = clip(data.meta);
322
+ // The ONLY tool-result semantic consumed by collectReply is a top-level
323
+ // message terminate hint. Preserve that boolean, not an echoed body.
324
+ if (String(data.name || data.toolName) === 'message') {
325
+ let value = data.output ?? data.result ?? data.error;
326
+ if (typeof value === 'string') {
327
+ try {
328
+ value = JSON.parse(value);
329
+ }
330
+ catch {
331
+ value = /^terminate\s*=\s*true$/i.test(value.trim()) ? { terminate: true } : undefined;
332
+ }
333
+ }
334
+ if (value && typeof value === 'object' && value.terminate === true)
335
+ out.data.output = { terminate: true };
336
+ }
337
+ }
338
+ return out;
339
+ }
340
+ progressEventBytes(event) {
341
+ if (this.isTerminalBufferedEvent(event))
342
+ return 0;
343
+ let bytes = this.eventProgressSizes.get(event);
344
+ if (bytes === undefined) {
345
+ bytes = Buffer.byteLength(JSON.stringify(event), 'utf8') + 2;
346
+ this.eventProgressSizes.set(event, bytes);
347
+ }
348
+ return bytes;
349
+ }
350
+ enforceEventProgressBudget() {
351
+ let total = 0;
352
+ for (const [key, bucket] of this.agentEvents) {
353
+ let bytes = bucket.reduce((n, event) => n + this.progressEventBytes(event), 0);
354
+ let excessCount = Math.max(0, bucket.length - 2048);
355
+ for (let i = 0; i < bucket.length && (bytes > EVENT_PROGRESS_BUCKET_BYTES || excessCount > 0);) {
356
+ const event = bucket[i];
357
+ if (this.isTerminalBufferedEvent(event)) {
358
+ i++;
359
+ continue;
360
+ }
361
+ bytes -= this.progressEventBytes(event);
362
+ bucket.splice(i, 1);
363
+ excessCount--;
364
+ }
365
+ total += bytes;
366
+ }
367
+ if (total > EVENT_PROGRESS_TOTAL_BYTES)
368
+ for (const [key, bucket] of this.agentEvents) {
369
+ for (let i = 0; i < bucket.length && total > EVENT_PROGRESS_TOTAL_BYTES;) {
370
+ if (this.isTerminalBufferedEvent(bucket[i])) {
371
+ i++;
372
+ continue;
373
+ }
374
+ total -= this.progressEventBytes(bucket[i]);
375
+ bucket.splice(i, 1);
376
+ }
377
+ if (total <= EVENT_PROGRESS_TOTAL_BYTES)
378
+ break;
379
+ }
380
+ // Empty/nonterminal-only idle buckets may go; unknown terminal custody is
381
+ // explicitly exempt, not silently discarded to satisfy a count ceiling.
382
+ if (this.agentEvents.size > 512)
383
+ for (const [key, bucket] of this.agentEvents) {
384
+ if (this.agentEvents.size <= 512)
385
+ break;
386
+ if (!this.sessionCollectorFences.has(this.canonicalSessionKey(key)) && !bucket.some(ev => this.isTerminalBufferedEvent(ev)))
387
+ this.agentEvents.delete(key);
388
+ }
389
+ }
228
390
  bufferEvent(key, event) {
229
391
  let bucket = this.agentEvents.get(key);
230
392
  if (!bucket) {
231
393
  bucket = [];
232
394
  this.agentEvents.set(key, bucket);
233
395
  }
234
- bucket.push(event);
235
- if (bucket.length > 2048)
236
- bucket.splice(0, bucket.length - 2048);
237
- if (this.agentEvents.size > 512) {
238
- for (const candidate of this.agentEvents.keys()) {
239
- if (candidate !== key && !this.sessionCollectorFences.has(this.canonicalSessionKey(candidate))) {
240
- this.agentEvents.delete(candidate);
241
- break;
242
- }
243
- }
244
- }
396
+ bucket.push(this.projectBufferedEvent(event));
397
+ }
398
+ dispatchBufferedEvents() {
399
+ // Deliver the just-received projection to every current exact reader BEFORE
400
+ // applying lossy progress pressure, even if this is the oldest session in
401
+ // the map. Progress pressure must never manufacture effective silence.
402
+ for (const wake of this.eventCollectors)
403
+ wake();
404
+ this.enforceEventProgressBudget();
245
405
  }
246
406
  constructor(config) {
247
407
  this.config = config;
@@ -339,14 +499,18 @@ export class OpenClawClient {
339
499
  if (p) {
340
500
  this.pending.delete(frame.id);
341
501
  clearTimeout(p.timer);
342
- if (frame.ok)
502
+ if (frame.ok) {
503
+ p.onSuccess?.(frame.payload);
343
504
  p.resolve(frame.payload);
505
+ }
344
506
  else
345
507
  p.reject(new Error(`RPC error: ${JSON.stringify(frame.error)}`));
346
508
  }
347
509
  }
348
- if (frame.event === 'agent' && ['tool', 'item'].includes(frame.payload?.stream))
510
+ if (frame.event === 'agent' && ['tool', 'item', 'command_output'].includes(frame.payload?.stream))
349
511
  this.redactPrivateInputTool(frame.payload);
512
+ if (frame.event === 'session.message')
513
+ this.redactPrivateTranscriptTools(frame.payload);
350
514
  // Public transcript projections can render an INPUT as role=assistant
351
515
  // (forwarded sessions_send / internal receipts). Reject its structured
352
516
  // identity before any collector, proactive callback or progress buffer.
@@ -415,13 +579,9 @@ export class OpenClawClient {
415
579
  }
416
580
  }
417
581
  if (frame.type === "event" && ["agent", "chat"].includes(frame.event)) {
418
- for (const wake of this.eventCollectors)
419
- wake();
420
- // Bound disconnected/unobserved event buffers. Terminal recovery uses
421
- // exact public agent.wait, never an unbounded transcript cache.
422
- for (const [key, bucket] of this.agentEvents)
423
- if (bucket.length > 2048)
424
- bucket.splice(0, bucket.length - 2048);
582
+ // No unconditional count splice: a foreign progress burst cannot
583
+ // evict a reserved/unknown full terminal.
584
+ this.dispatchBufferedEvents();
425
585
  }
426
586
  if (frame.type === "event" && ["agent", "chat", "session.message"].includes(frame.event) &&
427
587
  !(frame.event === 'session.message' && this.isInputRecord(frame.payload))) {
@@ -458,7 +618,7 @@ export class OpenClawClient {
458
618
  // now actually consumed it (session.message is emitted as the message
459
619
  // is committed to the transcript). Render it on the live-status card
460
620
  // in correct time order and flip the reaction to Get.
461
- this.handleSteerConsumption(rawKey, visibleUserText);
621
+ this.handleSteerConsumption(rawKey, visibleUserText, frame.payload.runId);
462
622
  }
463
623
  const inputRecord = this.isInputRecord(frame.payload);
464
624
  const visibleAssistantText = inputRecord ? "" : this.extractVisibleAssistantText(msg);
@@ -474,8 +634,7 @@ export class OpenClawClient {
474
634
  }
475
635
  if (!inputRecord)
476
636
  this.handleProactiveSessionMessage(rawKey, msg, frame.payload.runId, frame.payload.sessionId);
477
- for (const wake of this.eventCollectors)
478
- wake();
637
+ this.dispatchBufferedEvents();
479
638
  // Tool calls in assistant messages — skip, using agent item events instead
480
639
  // (session.message toolCall events are batched, not real-time)
481
640
  }
@@ -488,8 +647,8 @@ export class OpenClawClient {
488
647
  }
489
648
  if (frame.event === "agent" && frame.payload?.stream === "lifecycle" && frame.payload?.data?.phase === "end") {
490
649
  const rawKey = frame.payload.sessionKey || "";
491
- this.flushVerboseAssistantState(rawKey);
492
- this.clearVerboseAssistantState(rawKey);
650
+ this.flushVerboseAssistantState(rawKey, frame.payload.runId);
651
+ this.clearVerboseAssistantState(rawKey, frame.payload.runId);
493
652
  }
494
653
  // Agent item / command output events — real-time tool tracking for verbose mode.
495
654
  if (frame.event === "agent" && frame.payload?.stream === "item") {
@@ -500,20 +659,20 @@ export class OpenClawClient {
500
659
  if (data.itemId && data.name)
501
660
  this.lastToolNamesByItemId.set(String(data.itemId), String(data.name));
502
661
  if (data.kind === "tool" && data.phase === "start") {
503
- this.flushVerboseAssistantState(rawKey);
504
- this.clearVerboseAssistantState(rawKey);
662
+ this.flushVerboseAssistantState(rawKey, frame.payload.runId);
663
+ this.clearVerboseAssistantState(rawKey, frame.payload.runId);
505
664
  }
506
665
  if (data.kind === "tool" && data.name && (data.phase === "start" || data.phase === "end" || data.phase === "error")) {
507
666
  const phase = data.phase;
508
667
  const eventId = String(data.toolCallId || data.itemId || data.id || data.name);
509
- if (this.claimToolEvent(rawKey, eventId, phase)) {
668
+ if (this.claimToolEvent(rawKey, eventId, phase, Date.now(), frame.payload.runId)) {
510
669
  const meta = data.meta || data.input || data.args || "";
511
670
  const output = data.output || data.result || data.error || "";
512
671
  const inputText = this.formatToolValue(meta, String(data.name), "start");
513
672
  const outputText = this.formatToolValue(output, String(data.name), phase);
514
673
  if (toolCb)
515
674
  toolCb(`${data.name} ${phase}`.trim(), inputText, outputText);
516
- const progressCb = this.progressCallbacks.get(rawKey) || this.progressCallbacks.get(shortKey);
675
+ const progressCb = this.progressForRun(rawKey, frame.payload.runId);
517
676
  if (progressCb) {
518
677
  const detail = phase === "start" ? inputText : (outputText || inputText);
519
678
  void progressCb({ kind: "tool", phase, name: String(data.name), text: `${data.name} ${phase}${detail ? `: ${detail}` : ""}` });
@@ -535,10 +694,10 @@ export class OpenClawClient {
535
694
  : undefined;
536
695
  const name = String(data.name || data.toolName || "tool");
537
696
  const eventId = String(data.toolCallId || data.itemId || data.id || name);
538
- if (phase && this.claimToolEvent(rawKey, eventId, phase)) {
697
+ if (phase && this.claimToolEvent(rawKey, eventId, phase, Date.now(), frame.payload.runId)) {
539
698
  if (phase === "start") {
540
- this.flushVerboseAssistantState(rawKey);
541
- this.clearVerboseAssistantState(rawKey);
699
+ this.flushVerboseAssistantState(rawKey, frame.payload.runId);
700
+ this.clearVerboseAssistantState(rawKey, frame.payload.runId);
542
701
  }
543
702
  const input = data.input || data.args || data.meta || "";
544
703
  const output = data.output || data.result || data.error || "";
@@ -547,7 +706,7 @@ export class OpenClawClient {
547
706
  const toolCb = this.toolEventCallbacks.get(rawKey) || this.toolEventCallbacks.get(shortKey);
548
707
  if (toolCb)
549
708
  toolCb(`${name} ${phase}`, inputText, outputText);
550
- const progressCb = this.progressCallbacks.get(rawKey) || this.progressCallbacks.get(shortKey);
709
+ const progressCb = this.progressForRun(rawKey, frame.payload.runId);
551
710
  if (progressCb) {
552
711
  const detail = phase === "start" ? inputText : (outputText || inputText);
553
712
  void progressCb({ kind: "tool", phase, name, text: `${name} ${phase}${detail ? `: ${detail}` : ""}` });
@@ -659,8 +818,27 @@ export class OpenClawClient {
659
818
  const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
660
819
  return this.verboseTranscriptSessions.has(rawKey) || this.verboseTranscriptSessions.has(shortKey);
661
820
  }
662
- clearVerboseAssistantState(sessionKey) {
663
- for (const key of [sessionKey, `agent:main:${sessionKey}`]) {
821
+ progressKey(sessionKey, runId) {
822
+ return JSON.stringify([this.canonicalSessionKey(sessionKey), runId || ""]);
823
+ }
824
+ progressForRun(sessionKey, runId) {
825
+ const key = this.canonicalSessionKey(this.shortKey(sessionKey));
826
+ // Installed public agent events already map runtime continuations to the
827
+ // admitted client runId. Session proximity/lifecycle starts are not lineage.
828
+ if (!runId || this.progressRuns.get(key) !== runId)
829
+ return undefined;
830
+ return this.progressCallbacks.get(key) || this.progressCallbacks.get(this.shortKey(key));
831
+ }
832
+ clearVerboseAssistantState(sessionKey, runId) {
833
+ const canonical = this.canonicalSessionKey(sessionKey);
834
+ const keys = runId !== undefined ? [this.progressKey(sessionKey, runId)]
835
+ : [...this.verboseAssistantLastTouched.keys()].filter(key => { try {
836
+ return JSON.parse(key)[0] === canonical;
837
+ }
838
+ catch {
839
+ return false;
840
+ } });
841
+ for (const key of keys) {
664
842
  const timer = this.verboseAssistantTimers.get(key);
665
843
  if (timer)
666
844
  clearTimeout(timer);
@@ -670,49 +848,145 @@ export class OpenClawClient {
670
848
  this.verboseAssistantLastTouched.delete(key);
671
849
  }
672
850
  }
673
- /** Internal message tools carry private input, not assistant narration.
674
- * Redact before buffering as well as progress callbacks (including failure
675
- * summaries). Preserve tool identity/phase for activity and acceptance logic. */
676
- redactPrivateInputTool(payload) {
677
- const data = payload?.data;
678
- if (!data || payload.stream === 'item' && data.kind !== 'tool')
679
- return;
680
- const name = String(data.name || data.toolName || '').split('.').pop();
681
- const eventId = String(data.toolCallId || data.itemId || data.id || '').replace(/^(?:tool|command):/, '');
682
- const key = JSON.stringify([this.canonicalSessionKey(payload.sessionKey || ''), payload.runId || '', eventId]);
683
- let args = data.input ?? data.args ?? data.meta;
851
+ canonicalToolName(name) {
852
+ return String(name || "").split(/[.:]/).pop() || "";
853
+ }
854
+ /** Inspect supported public wrappers recursively, including JSON arguments.
855
+ * A wrapper containing any private coordination call has private results too;
856
+ * do not try to salvage arbitrary echoed result/error strings. */
857
+ isPrivateCoordination(name, value, depth = 0) {
858
+ const tool = this.canonicalToolName(name);
859
+ if (['sessions_spawn', 'sessions_send', 'sessions_yield'].includes(tool))
860
+ return true;
861
+ if (depth > 16)
862
+ return ['tool_call', 'parallel'].includes(tool);
863
+ let args = value;
684
864
  if (typeof args === 'string') {
685
865
  try {
686
866
  args = JSON.parse(args);
687
867
  }
688
868
  catch {
689
- args = undefined;
869
+ return false;
690
870
  }
691
871
  }
692
- const wrapped = name === 'tool_call' && /(?:^|[.:])sessions_(?:send|yield)$/.test(String(args?.id || ''));
693
- const privateBody = name === 'sessions_send' || name === 'sessions_yield' || wrapped;
872
+ if (!args || typeof args !== 'object')
873
+ return false;
874
+ if (tool === 'tool_call')
875
+ return this.isPrivateCoordination(args.id || args.name, args.args ?? args.parameters, depth + 1);
876
+ if (tool === 'parallel' && Array.isArray(args.tool_uses))
877
+ return args.tool_uses.some((call) => this.isPrivateCoordination(call?.recipient_name || call?.name || call?.id, call?.parameters ?? call?.args, depth + 1));
878
+ return false;
879
+ }
880
+ knownPublicWrapper(name, value, depth = 0) {
881
+ if (depth > 16)
882
+ return false;
883
+ const tool = this.canonicalToolName(name);
884
+ if (['sessions_spawn', 'sessions_send', 'sessions_yield'].includes(tool))
885
+ return false;
886
+ if (!['tool_call', 'parallel'].includes(tool))
887
+ return Boolean(tool);
888
+ let args = value;
889
+ if (typeof args === 'string') {
890
+ try {
891
+ args = JSON.parse(args);
892
+ }
893
+ catch {
894
+ return false;
895
+ }
896
+ }
897
+ if (!args || typeof args !== 'object')
898
+ return false;
899
+ if (tool === 'tool_call')
900
+ return this.knownPublicWrapper(args.id || args.name, args.args ?? args.parameters, depth + 1);
901
+ return Array.isArray(args.tool_uses) && args.tool_uses.every((call) => this.knownPublicWrapper(call?.recipient_name || call?.name || call?.id, call?.parameters ?? call?.args, depth + 1));
902
+ }
903
+ /** Redact BEFORE event buffers, logging, observers, cards and verbose sinks.
904
+ * Call custody is session+run+id scoped, so item/command/result mirrors stay
905
+ * private even after the original wrapper arguments have disappeared. */
906
+ redactPrivateInputTool(payload) {
907
+ const data = payload?.data;
908
+ if (!data || payload.stream === 'item' && data.kind !== 'tool')
909
+ return false;
910
+ const eventId = String(data.toolCallId || data.itemId || data.id || '').replace(/^(?:tool|command):/, '');
911
+ const key = this.progressKey(payload.sessionKey || '', payload.runId) + ':' + eventId;
912
+ const name = data.name || data.toolName;
913
+ const wrapper = ['tool_call', 'parallel'].includes(this.canonicalToolName(name));
914
+ const privateBody = [data.input, data.args, data.meta, data.output, data.result, data.error].some(value => this.isPrivateCoordination(name, value));
915
+ if (data.phase === 'start' && eventId && !privateBody && this.knownPublicWrapper(name, data.input ?? data.args ?? data.meta)) {
916
+ this.publicToolCalls.add(key);
917
+ if (this.publicToolCalls.size > 2048)
918
+ this.publicToolCalls.delete(this.publicToolCalls.values().next().value);
919
+ }
920
+ // An orphan wrapper result can echo hidden arguments. Only a correlated
921
+ // known-public call may expose its arbitrary scalar output.
922
+ const ambiguousResult = (wrapper || ['', 'tool', 'command_output'].includes(this.canonicalToolName(name))) && data.phase !== 'start' && !this.publicToolCalls.has(key);
694
923
  if (privateBody && eventId) {
695
924
  this.privateInputToolCalls.add(key);
696
925
  if (this.privateInputToolCalls.size > 2048)
697
926
  this.privateInputToolCalls.delete(this.privateInputToolCalls.values().next().value);
698
927
  }
699
- if (!privateBody && !this.privateInputToolCalls.has(key))
700
- return;
928
+ if (!privateBody && !ambiguousResult && (!eventId || !this.privateInputToolCalls.has(key)))
929
+ return false;
701
930
  payload.data = Object.fromEntries(['kind', 'phase', 'name', 'toolName', 'toolCallId', 'itemId', 'id', 'isError'].filter(k => data[k] !== undefined).map(k => [k, data[k]]));
931
+ return true;
932
+ }
933
+ redactPrivateTranscriptTools(payload) {
934
+ const msg = payload?.message || payload;
935
+ if (!msg || typeof msg !== 'object')
936
+ return;
937
+ const redact = (part, phase) => {
938
+ const synthetic = { sessionKey: payload?.sessionKey, runId: payload?.runId, stream: 'tool', data: {
939
+ ...part, name: part?.name || part?.toolName, toolCallId: part?.toolCallId || part?.tool_use_id || part?.id,
940
+ phase, input: part?.arguments ?? part?.input ?? part?.args, result: part?.content ?? part?.output ?? part?.result,
941
+ } };
942
+ return this.redactPrivateInputTool(synthetic);
943
+ };
944
+ if (['tool', 'toolResult'].includes(msg.role) && redact(msg, 'result')) {
945
+ msg.content = [];
946
+ delete msg.output;
947
+ delete msg.result;
948
+ delete msg.error;
949
+ delete msg.arguments;
950
+ delete msg.input;
951
+ delete msg.args;
952
+ }
953
+ if (Array.isArray(msg.content))
954
+ msg.content = msg.content.map((part) => {
955
+ const type = String(part?.type || '').toLowerCase();
956
+ const phase = ['toolcall', 'tool_call', 'tooluse', 'tool_use'].includes(type) ? 'start'
957
+ : ['toolresult', 'tool_result'].includes(type) ? 'result' : undefined;
958
+ if (!phase || !redact(part, phase))
959
+ return part;
960
+ return Object.fromEntries(['type', 'id', 'name', 'toolName', 'toolCallId', 'tool_use_id', 'isError'].filter(k => part[k] !== undefined).map(k => [k, part[k]]));
961
+ });
702
962
  }
703
963
  /** Render protocol-4 structured tool args/results without `[object Object]`.
704
964
  * Prefer useful, non-secret fields and keep the fallback bounded. */
705
965
  formatToolValue(value, toolName, phase) {
706
- // sessions_yield.message is private resumed context. Do not mirror its
707
- // arguments/results (including JSON strings) into process cards or verbose
708
- // tool replies. This is redaction only, never evidence of a handoff.
709
- if (toolName === "sessions_yield" || toolName === "sessions_send")
966
+ if (this.isPrivateCoordination(toolName, value))
710
967
  return "";
711
- // Generic public-tool wrapper: redact only the internal send operation,
712
- // not all tool bodies or outputs. Its returned body can echo the input.
713
- if (toolName === 'tool_call' && value && typeof value === 'object' &&
714
- /(?:^|[.:])sessions_send$/.test(String(value.id || '')))
968
+ toolName = this.canonicalToolName(toolName);
969
+ if (phase === 'start' && ['tool_call', 'parallel'].includes(toolName)) {
970
+ let args = value;
971
+ if (typeof args === 'string') {
972
+ try {
973
+ args = JSON.parse(args);
974
+ }
975
+ catch {
976
+ return "";
977
+ }
978
+ }
979
+ if (toolName === 'tool_call' && args?.id) {
980
+ const name = this.canonicalToolName(args.id), detail = this.formatToolValue(args.args ?? args.parameters, name, phase);
981
+ return `${name}${detail ? ': ' + detail : ''}`;
982
+ }
983
+ if (toolName === 'parallel' && Array.isArray(args?.tool_uses))
984
+ return args.tool_uses.slice(0, 4).map((call) => {
985
+ const name = this.canonicalToolName(call?.recipient_name || call?.name || call?.id), detail = this.formatToolValue(call?.parameters ?? call?.args, name, phase);
986
+ return `${name}${detail ? ': ' + detail : ''}`;
987
+ }).join('; ');
715
988
  return "";
989
+ }
716
990
  if (value === null || value === undefined || value === "")
717
991
  return "";
718
992
  if (typeof value === "string")
@@ -779,10 +1053,10 @@ export class OpenClawClient {
779
1053
  }
780
1054
  return parts.join(", ");
781
1055
  }
782
- claimToolEvent(sessionKey, eventId, phase, now = Date.now()) {
1056
+ claimToolEvent(sessionKey, eventId, phase, now = Date.now(), runId) {
783
1057
  const normalizedPhase = phase === "error" ? "end" : phase;
784
1058
  const normalizedId = eventId.replace(/^(?:tool|command):/, "");
785
- const key = `${this.shortKey(sessionKey)}:${normalizedId}:${normalizedPhase}`;
1059
+ const key = `${this.progressKey(sessionKey, runId)}:${normalizedId}:${normalizedPhase}`;
786
1060
  const seenAt = this.recentToolEventKeys.get(key);
787
1061
  if (seenAt !== undefined && now - seenAt < 60_000)
788
1062
  return false;
@@ -817,15 +1091,15 @@ export class OpenClawClient {
817
1091
  this.recentToolEventKeys.delete(key);
818
1092
  }
819
1093
  }
820
- flushVerboseAssistantState(rawKey) {
1094
+ flushVerboseAssistantState(rawKey, runId) {
821
1095
  const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
822
1096
  const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
823
- const progressCb = this.progressCallbacks.get(rawKey) || this.progressCallbacks.get(shortKey);
1097
+ const progressCb = this.progressForRun(rawKey, runId);
824
1098
  if (!this.isVerboseTranscriptEnabled(rawKey) && !progressCb)
825
1099
  return false;
826
1100
  if (!cb && !progressCb)
827
1101
  return false;
828
- const key = rawKey;
1102
+ const key = this.progressKey(rawKey, runId);
829
1103
  const latest = this.verboseAssistantLatest.get(key)?.trim() || "";
830
1104
  if (!latest)
831
1105
  return false;
@@ -840,7 +1114,7 @@ export class OpenClawClient {
840
1114
  return false;
841
1115
  this.verboseAssistantSent.set(key, latest);
842
1116
  if (this.isVerboseTranscriptEnabled(rawKey) && cb)
843
- cb(toSend, { sourceType: "verbose_transcript" });
1117
+ cb(toSend, { sourceType: "verbose_transcript", ...(runId ? { runId, sessionKey: this.canonicalSessionKey(rawKey) } : {}) });
844
1118
  if (progressCb)
845
1119
  void progressCb({ kind: "assistant_note", text: toSend });
846
1120
  return true;
@@ -853,7 +1127,7 @@ export class OpenClawClient {
853
1127
  return;
854
1128
  const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
855
1129
  const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
856
- const progressCb = this.progressCallbacks.get(rawKey) || this.progressCallbacks.get(shortKey);
1130
+ const progressCb = this.progressForRun(rawKey, payload.runId);
857
1131
  if (!this.isVerboseTranscriptEnabled(rawKey) && !progressCb)
858
1132
  return;
859
1133
  if (!cb && !progressCb)
@@ -861,7 +1135,7 @@ export class OpenClawClient {
861
1135
  const data = payload.data || {};
862
1136
  const fullText = typeof data.text === "string" ? data.text : typeof data.deltaText === "string" && data.replace ? data.deltaText : "";
863
1137
  const deltaText = !fullText && typeof data.delta === "string" ? data.delta : !fullText && typeof data.deltaText === "string" ? data.deltaText : "";
864
- const key = rawKey;
1138
+ const key = this.progressKey(rawKey, payload.runId);
865
1139
  const previous = this.verboseAssistantLatest.get(key) || "";
866
1140
  const text = (fullText || (previous + deltaText)).trim().slice(-1024 * 1024);
867
1141
  if (!text)
@@ -871,7 +1145,7 @@ export class OpenClawClient {
871
1145
  this.verboseAssistantLastTouched.set(key, now);
872
1146
  this.pruneVerboseCaches(now);
873
1147
  if (text.length >= 3000) {
874
- this.flushVerboseAssistantState(rawKey);
1148
+ this.flushVerboseAssistantState(rawKey, payload.runId);
875
1149
  }
876
1150
  }
877
1151
  handleProactiveSessionMessage(rawKey, msg, runId, sessionId) {
@@ -948,17 +1222,16 @@ export class OpenClawClient {
948
1222
  this.pending.delete(id);
949
1223
  reject(new Error(`RPC timeout: ${method}`));
950
1224
  }, timeoutMs);
951
- this.pending.set(id, { resolve, reject, timer });
1225
+ this.pending.set(id, { resolve, reject, timer,
1226
+ onSuccess: method === 'chat.send' ? this.admissionResponseHooks.get(params?.idempotencyKey) : undefined });
952
1227
  this.ws.send(JSON.stringify({ type: "req", id, method, params }));
953
1228
  });
954
1229
  }
955
- /**
956
- * Collect agent reply by polling accumulated events.
957
- * Matches by initial runId OR sessionKey to handle multi-turn tool calling
958
- * where OpenClaw creates new runIds for each tool-call round.
959
- * Existing event/anchor matching is retained. Foreground effective silence
960
- * triggers read-only reconciliation, never a task-lifetime cutoff or draft final.
961
- * Explicit terminal/error/disconnect paths keep their existing behavior.
1230
+ /** Exact admitted-root observation with non-destructive event cursors.
1231
+ * Gateway maps runtime continuations to its public client runId; an unrelated
1232
+ * session lifecycle start is not lineage. Separate requester/task continuation
1233
+ * lineage remains owned by handoffLinkedContinuation + exact root recovery.
1234
+ * Effective silence prompts read-only reconciliation, never a lifetime cutoff.
962
1235
  */
963
1236
  collectReply(runId, timeoutMs = FOREGROUND_WAIT_MS, targetSessionKey, options) {
964
1237
  let cleanupObservation = () => { };
@@ -968,16 +1241,7 @@ export class OpenClawClient {
968
1241
  // this keeps unit tests that drive collectReply without a live socket from
969
1242
  // tripping the disconnect path.
970
1243
  const startedConnected = this.connected;
971
- let text = "";
972
- let chatDeltaText = "";
973
1244
  let chatFinalText = "";
974
- let chatFinalSeen = false;
975
- let transcriptAssistantText = "";
976
- let draftOverflow = false;
977
- const boundedDraft = (value) => { if (value.length > 1024 * 1024) {
978
- draftOverflow = true;
979
- return value.slice(-1024 * 1024);
980
- } return value; };
981
1245
  let sessionKey = targetSessionKey ? `agent:main:${targetSessionKey.replace(/^agent:[^:]+:/, "")}` : "";
982
1246
  let shortSessionKey = targetSessionKey ? targetSessionKey.replace(/^agent:[^:]+:/, "") : "";
983
1247
  let chatFinalTimer = null;
@@ -995,19 +1259,9 @@ export class OpenClawClient {
995
1259
  let lastActivityAt = 0;
996
1260
  const collectStartedAt = Date.now();
997
1261
  let lifecycleStartedLogged = false;
998
- const expectedUserText = options?.expectedUserText?.trim() || "";
999
- let anchorSeen = !expectedUserText;
1000
1262
  const activeRunIds = new Set([runId]);
1001
- let preAnchorEvents = [];
1002
- const foreignUserRuns = new Set();
1003
- const normalizeText = (value) => value.replace(/\s+/g, " ").trim();
1004
- const isExpectedUserText = (value) => {
1005
- const actual = normalizeText(value);
1006
- const expected = normalizeText(expectedUserText);
1007
- if (!actual || !expected)
1008
- return false;
1009
- return actual === expected || actual.includes(expected) || expected.includes(actual);
1010
- };
1263
+ const seenEvents = new WeakSet();
1264
+ const releaseEventReader = this.retainRunEventCustody('reader', targetSessionKey, runId);
1011
1265
  let idleTimer;
1012
1266
  let observationClosed = false;
1013
1267
  // Close synchronously: a deferred yield notice must not overtake a final
@@ -1020,11 +1274,15 @@ export class OpenClawClient {
1020
1274
  let decidingTimeout = false;
1021
1275
  let waitNoticeSent = false;
1022
1276
  let foregroundPaused = false;
1023
- let foregroundYielded = false;
1024
1277
  let turnYielded = false;
1025
- const handleTimeoutResult = async (error) => {
1278
+ const handleTimeoutResult = async (error, stillCurrent = () => true) => {
1279
+ collectEvents();
1026
1280
  if (observationClosed)
1027
1281
  return;
1282
+ if (!stillCurrent()) {
1283
+ decidingTimeout = false;
1284
+ return;
1285
+ }
1028
1286
  if (chatFinalText.trim()) {
1029
1287
  finish(chatFinalText);
1030
1288
  return;
@@ -1043,7 +1301,7 @@ export class OpenClawClient {
1043
1301
  }
1044
1302
  }
1045
1303
  decidingTimeout = false;
1046
- if (!observationClosed)
1304
+ if (!observationClosed && stillCurrent())
1047
1305
  resetIdleTimer(60_000, false);
1048
1306
  return;
1049
1307
  }
@@ -1054,10 +1312,17 @@ export class OpenClawClient {
1054
1312
  return;
1055
1313
  checkingState = true;
1056
1314
  lastStateCheckAt = Date.now();
1315
+ const decisionGeneration = idleGeneration;
1057
1316
  try {
1058
1317
  const snapshot = await this.rpc("agent.wait", { runId, timeoutMs: 1500 }, 2500);
1318
+ // A query can return a terminal projection sampled before a newer
1319
+ // same-root continuation made real progress. Drain the received wire
1320
+ // first and fence that stale cut; a fresh exact query may still fail.
1321
+ collectEvents();
1322
+ if (!idleStillCurrent() || idleGeneration !== decisionGeneration)
1323
+ return;
1059
1324
  if (observationClosed || snapshot?.runId !== runId || snapshot?.pendingError === true
1060
- || snapshot?.timeoutPhase === "queue" || snapshot?.timeoutPhase === "gateway_draining")
1325
+ || snapshot?.yielded === true || snapshot?.timeoutPhase === "queue" || snapshot?.timeoutPhase === "gateway_draining")
1061
1326
  return;
1062
1327
  // A bare wait timeout is not an execution timeout. Require a recorded
1063
1328
  // terminal time; never infer failure from stopReason or replay flags.
@@ -1069,26 +1334,17 @@ export class OpenClawClient {
1069
1334
  const terminal = snapshot.terminalReply;
1070
1335
  // B6 (2026-10-16 audit): disposition "empty" is an authoritative
1071
1336
  // no-visible-text terminal — never recycle an interim draft as the
1072
- // final answer. Only genuinely unspecified metadata may fall back.
1337
+ // final answer. Unspecified/malformed metadata retains observation.
1073
1338
  const visible = terminal?.disposition === "visible" && typeof terminal.text === "string"
1074
1339
  ? terminal.text
1075
1340
  : terminal?.disposition === "silent" || terminal?.disposition === "empty"
1076
1341
  ? "NO_REPLY"
1077
1342
  : "";
1078
- if (!visible && terminal?.disposition !== "visible")
1343
+ if (!visible.trim())
1079
1344
  return;
1080
- resolve(visible || "NO_REPLY");
1345
+ resolve(visible);
1081
1346
  }
1082
1347
  else if (snapshot.status === "error" || snapshot.status === "timeout") {
1083
- if (snapshot.status === "timeout" || isWaitTimeout(String(snapshot.error || ""))) {
1084
- const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
1085
- collectEvents();
1086
- if (!observationClosed && status.running) {
1087
- if (idleStillCurrent())
1088
- await handleTimeoutResult(new SessionWaitPaused(status, true));
1089
- return;
1090
- }
1091
- }
1092
1348
  if (observationClosed)
1093
1349
  return;
1094
1350
  reject(new Error(`Agent error: ${typeof snapshot.error === "string" && snapshot.error.trim()
@@ -1103,6 +1359,9 @@ export class OpenClawClient {
1103
1359
  // One clock for foreground silence and subsequent low-frequency observation.
1104
1360
  // A generation fences status RPCs that complete after newer real activity.
1105
1361
  let idleGeneration = 0;
1362
+ let idleGuardTimer;
1363
+ const clearIdleGuard = () => { if (idleGuardTimer)
1364
+ clearTimeout(idleGuardTimer); idleGuardTimer = undefined; };
1106
1365
  const resetIdleTimer = (delayMs = timeoutMs, activity = true) => {
1107
1366
  if (activity) {
1108
1367
  if (!foregroundPaused)
@@ -1113,33 +1372,54 @@ export class OpenClawClient {
1113
1372
  const idleStillCurrent = () => !observationClosed && generation === idleGeneration;
1114
1373
  if (idleTimer)
1115
1374
  clearTimeout(idleTimer);
1375
+ clearIdleGuard();
1116
1376
  if (observationClosed)
1117
1377
  return;
1118
- idleTimer = setTimeout(async () => {
1119
- // Drain already-received frames before judging silence: the 50ms
1120
- // collection poll may otherwise run just after this deadline.
1121
- collectEvents();
1122
- if (!idleStillCurrent())
1123
- return;
1124
- if (!quietNotified) {
1125
- quietNotified = true;
1126
- console.warn(`[OpenClaw] quiet run ${runId}; checking Gateway state without stopping execution`);
1127
- }
1128
- await checkRunState(idleStillCurrent);
1129
- if (!idleStillCurrent())
1130
- return;
1131
- const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
1132
- collectEvents();
1133
- if (!idleStillCurrent())
1134
- return;
1135
- // With a foreground callback, silence freezes presentation even when
1136
- // status is unknown/idle; never invent running. Without one, retain
1137
- // the published running-only typed outcome and background checks.
1138
- if (status.running || options?.onWaitPaused) {
1139
- await handleTimeoutResult(new SessionWaitPaused(status));
1140
- return;
1141
- }
1142
- resetIdleTimer(60_000, false);
1378
+ idleTimer = setTimeout(() => {
1379
+ const ownGuard = setTimeout(() => {
1380
+ if (!idleStillCurrent())
1381
+ return;
1382
+ // Remove success suppression synchronously BEFORE closing this exact
1383
+ // observer. Late final/error stays on its durable original route.
1384
+ this.releasePausedWait(runId);
1385
+ cleanupObservation();
1386
+ reject(new CollectorStalledError());
1387
+ }, IDLE_GUARD_MS);
1388
+ idleGuardTimer = ownGuard;
1389
+ ownGuard.unref?.();
1390
+ void (async () => {
1391
+ // Drain already-received frames before judging silence: the 50ms
1392
+ // collection poll may otherwise run just after this deadline.
1393
+ collectEvents();
1394
+ if (!idleStillCurrent())
1395
+ return;
1396
+ if (!quietNotified) {
1397
+ quietNotified = true;
1398
+ console.warn(`[OpenClaw] quiet run ${runId}; checking Gateway state without stopping execution`);
1399
+ }
1400
+ await checkRunState(idleStillCurrent);
1401
+ if (!idleStillCurrent())
1402
+ return;
1403
+ const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
1404
+ collectEvents();
1405
+ if (!idleStillCurrent())
1406
+ return;
1407
+ // With a foreground callback, silence freezes presentation even when
1408
+ // status is unknown/idle; never invent running. Without one, retain
1409
+ // the published running-only typed outcome and background checks.
1410
+ if (status.running || options?.onWaitPaused) {
1411
+ await handleTimeoutResult(new SessionWaitPaused(status), idleStillCurrent);
1412
+ return;
1413
+ }
1414
+ resetIdleTimer(60_000, false);
1415
+ })().catch(error => { if (idleStillCurrent())
1416
+ reject(error instanceof Error ? error : new Error(String(error))); })
1417
+ .finally(() => {
1418
+ // An obsolete callback must never clear a newer callback's guard.
1419
+ clearTimeout(ownGuard);
1420
+ if (idleGuardTimer === ownGuard)
1421
+ idleGuardTimer = undefined;
1422
+ });
1143
1423
  }, delayMs);
1144
1424
  };
1145
1425
  // Activity filtering is deliberately separate from reply assembly/matching:
@@ -1176,7 +1456,9 @@ export class OpenClawClient {
1176
1456
  }
1177
1457
  if (ev.stream === "assistant" || ev.stream === "chatDelta" || ev.stream === "transcriptAssistant") {
1178
1458
  const fullText = typeof data.text === "string" ? data.text : "";
1179
- const chunk = fullText || data.deltaText || data.delta;
1459
+ const rawChunk = fullText || data.deltaText || data.delta;
1460
+ const chunk = typeof data.activityFingerprint === 'string'
1461
+ ? `[activity-sha256:${data.activityFingerprint}]` : rawChunk;
1180
1462
  const replace = Boolean(fullText || data.replace);
1181
1463
  if (typeof chunk !== "string" || !chunk.trim())
1182
1464
  return false;
@@ -1234,26 +1516,8 @@ export class OpenClawClient {
1234
1516
  lastActivityAt = Date.now();
1235
1517
  }
1236
1518
  };
1237
- const buildFailureText = (ev) => {
1238
- const state = ev.data?.livenessState || ev.data?.status || "unknown";
1239
- const reason = ev.data?.stopReason || "";
1240
- const replayInvalid = ev.data?.replayInvalid ? ", replayInvalid" : "";
1241
- const lines = [`⚠️ Agent 未正常完成`, `状态: ${state}${replayInvalid}${reason ? "\n原因: " + reason : ""}`];
1242
- if (lastActivitySummary) {
1243
- lines.push(`最后活动: ${lastActivitySummary}`);
1244
- lines.push(`最后活动时间: ${new Date(lastActivityAt).toLocaleString()}`);
1245
- }
1246
- lines.push("请重试,或用 /reset 重置会话");
1247
- return lines.join("\n");
1248
- };
1249
1519
  resetIdleTimer();
1250
1520
  const finish = (finalText) => {
1251
- if (draftOverflow && finalText !== 'NO_REPLY' && finalText !== chatFinalText) {
1252
- // Never publish a truncated accumulator. Keep exact result custody
1253
- // until authoritative root terminalReply or full root chat final.
1254
- void checkRunState(() => true, true);
1255
- return;
1256
- }
1257
1521
  clearTimeout(idleTimer);
1258
1522
  clearInterval(poller);
1259
1523
  if (chatFinalTimer)
@@ -1294,111 +1558,24 @@ export class OpenClawClient {
1294
1558
  continue;
1295
1559
  let i = 0;
1296
1560
  while (i < bucket.length) {
1297
- const ev = bucket[i];
1561
+ const ev = bucket[i++];
1562
+ if (seenEvents.has(ev))
1563
+ continue;
1298
1564
  const evRunId = typeof ev.runId === "string" ? ev.runId : "";
1299
- const eventSessionMatches = Boolean(sessionKey && (ev.sessionKey === sessionKey || ev.sessionKey === targetSessionKey || ev.sessionKey === shortSessionKey || activeRunIds.has(evRunId) && this.shortKey(ev.sessionKey || '') === shortSessionKey));
1565
+ const eventSessionMatches = Boolean(sessionKey && this.shortKey(ev.sessionKey || bucketKey) === this.shortKey(sessionKey));
1300
1566
  const matchesRun = evRunId ? activeRunIds.has(evRunId) : false;
1301
- if (options?.exactRunIdentity && !matchesRun) {
1302
- i++;
1303
- continue;
1304
- }
1305
- if (evRunId && foreignUserRuns.has(evRunId)) {
1306
- i++;
1567
+ // Non-destructive per-observer cursor: ACK and a replacement final
1568
+ // may be delivered synchronously before the awaiting sender resumes.
1569
+ // Leave every foreign/unidentified frame available to its own owner.
1570
+ if (!matchesRun || sessionKey && !eventSessionMatches)
1307
1571
  continue;
1308
- }
1572
+ seenEvents.add(ev);
1309
1573
  if (!sessionKey && ev.sessionKey) {
1310
1574
  shortSessionKey = String(ev.sessionKey).replace(/^agent:[^:]+:/, "");
1311
- sessionKey = `agent:main:${shortSessionKey}`;
1312
- }
1313
- if (eventSessionMatches && ev.stream === "sessionUser") {
1314
- if (anchorSeen && evRunId && !matchesRun) {
1315
- foreignUserRuns.add(evRunId);
1316
- i++;
1317
- continue;
1318
- }
1319
- bucket.splice(i, 1);
1320
- if (!anchorSeen && isExpectedUserText(ev.data?.text || "")) {
1321
- anchorSeen = true;
1322
- for (const pendingEv of preAnchorEvents) {
1323
- const pendingRunId = typeof pendingEv.runId === "string" ? pendingEv.runId : "";
1324
- if (pendingRunId && pendingEv.stream === "lifecycle" && pendingEv.data?.phase === "start") {
1325
- activeRunIds.add(pendingRunId);
1326
- }
1327
- }
1328
- const replay = preAnchorEvents.filter((pendingEv) => {
1329
- const pendingRunId = typeof pendingEv.runId === "string" ? pendingEv.runId : "";
1330
- return !pendingRunId || activeRunIds.has(pendingRunId);
1331
- });
1332
- preAnchorEvents = [];
1333
- if (replay.length)
1334
- bucket.splice(i, 0, ...replay);
1335
- }
1336
- else if (!anchorSeen) {
1337
- // A different user/runtime continuation belongs to stale queued work;
1338
- // anything before it should not be attributed to the next real user turn.
1339
- preAnchorEvents = [];
1340
- }
1341
- continue;
1342
- }
1343
- // After handoff, an unowned new run (including requester-settle)
1344
- // retains its existing proactive delivery route. A session/lifecycle
1345
- // start alone must not turn it into the old run's final. Discussion's
1346
- // transcript mute marks scheduler-owned collection; retain its existing
1347
- // anchored collector path rather than changing discussion routing.
1348
- if (eventSessionMatches && foregroundYielded && evRunId && !matchesRun
1349
- && !this.mutedProactiveSessions.has(sessionKey) && !this.mutedProactiveSessions.has(shortSessionKey)) {
1350
- bucket.splice(i, 1);
1351
- continue;
1352
- }
1353
- if (eventSessionMatches && expectedUserText && !anchorSeen && !matchesRun) {
1354
- bucket.splice(i, 1);
1355
- preAnchorEvents.push(ev);
1356
- if (preAnchorEvents.length > 2048)
1357
- preAnchorEvents.splice(0, preAnchorEvents.length - 2048);
1358
- if (preAnchorEvents.length > 200)
1359
- preAnchorEvents.shift();
1360
- continue;
1361
- }
1362
- let matchesSession = false;
1363
- if (!options?.exactRunIdentity && eventSessionMatches && anchorSeen) {
1364
- if (!expectedUserText) {
1365
- // Backward-compatible mode for tests/internal callers that do not
1366
- // provide an anchor: allow session-key matching as before.
1367
- matchesSession = true;
1368
- }
1369
- else if (!evRunId) {
1370
- matchesSession = true;
1371
- }
1372
- else if (activeRunIds.has(evRunId)) {
1373
- matchesSession = true;
1374
- }
1375
- else if (ev.stream === "lifecycle" && ev.data?.phase === "start") {
1376
- activeRunIds.add(evRunId);
1377
- matchesSession = true;
1378
- }
1379
- }
1380
- if (!(matchesRun || matchesSession)) {
1381
- // When collectReply is anchored to a specific user message, discard stale
1382
- // session events that arrive before that anchor (or from unrelated runIds)
1383
- // so replayInvalid/aborted events from older runtime continuations cannot
1384
- // be misattributed to the current user turn.
1385
- if (eventSessionMatches && expectedUserText) {
1386
- bucket.splice(i, 1);
1387
- }
1388
- else {
1389
- i++;
1390
- }
1391
- continue;
1575
+ sessionKey = this.canonicalSessionKey(ev.sessionKey);
1392
1576
  }
1393
- bucket.splice(i, 1);
1394
- // Continuation progress is valid, but only the admitted root's
1395
- // committed terminalReply can authorize another run's final text.
1396
- if (evRunId && evRunId !== runId && (ev.stream === 'chatFinal' || ev.stream === 'chatError' || ev.stream === 'lifecycle' && ['end', 'error'].includes(ev.data?.phase))) {
1397
- if (ev.stream === 'chatFinal' && ev.data?.gatewayYielded === true)
1398
- continue;
1399
- void checkRunState(() => true, true);
1577
+ if (ev.stream === 'sessionUser')
1400
1578
  continue;
1401
- }
1402
1579
  if (hasEffectiveActivity(ev))
1403
1580
  resetIdleTimer();
1404
1581
  rememberActivity(ev);
@@ -1462,48 +1639,18 @@ export class OpenClawClient {
1462
1639
  lifecycleStartedLogged = true;
1463
1640
  console.log(`[OpenClaw] lifecycle start for runId=${runId} after ${Date.now() - collectStartedAt}ms`);
1464
1641
  }
1465
- if ((!evRunId || evRunId === runId) && (ev.stream === "assistant" || ev.stream === "chatDelta" || ev.stream === "transcriptAssistant") && (ev.data?.deltaText || ev.data?.delta)) {
1466
- const chunk = ev.data.deltaText || ev.data.delta;
1467
- if (ev.stream === "assistant") {
1468
- if (ev.data?.replace) {
1469
- text = boundedDraft(chunk);
1470
- }
1471
- else {
1472
- text = boundedDraft(text + chunk);
1473
- }
1474
- }
1475
- else if (ev.stream === "chatDelta") {
1476
- if (ev.data?.replace) {
1477
- chatDeltaText = boundedDraft(chunk);
1478
- }
1479
- else {
1480
- chatDeltaText = boundedDraft(chatDeltaText + chunk);
1481
- }
1482
- }
1483
- else {
1484
- if (ev.data?.replace) {
1485
- transcriptAssistantText = boundedDraft(chunk);
1486
- }
1487
- else {
1488
- transcriptAssistantText = boundedDraft(transcriptAssistantText + chunk);
1489
- }
1490
- }
1491
- }
1492
1642
  if (ev.stream === "chatFinal") {
1493
1643
  // Only the exact admitted root's public chat envelope can yield
1494
1644
  // scheduling custody. Session fallback/foreign/tool flags cannot.
1495
1645
  if (ev.data?.gatewayYielded === true && (evRunId !== runId || !eventSessionMatches))
1496
1646
  continue;
1497
- chatFinalSeen = true;
1498
1647
  if (ev.data?.gatewayYielded === true) {
1499
1648
  // A yielded turn is not a terminal answer, even if its buffered
1500
1649
  // message contains a preface. Keep the final key unclaimed;
1501
1650
  // opt-in durable handoff can retire only foreground observation.
1502
1651
  if (chatFinalText.trim())
1503
1652
  continue; // an actual final wins
1504
- foregroundYielded = true;
1505
1653
  turnYielded = true;
1506
- text = chatDeltaText = transcriptAssistantText = "";
1507
1654
  if (chatFinalTimer) {
1508
1655
  clearTimeout(chatFinalTimer);
1509
1656
  chatFinalTimer = null;
@@ -1539,96 +1686,27 @@ export class OpenClawClient {
1539
1686
  turnYielded = false;
1540
1687
  else if (turnYielded)
1541
1688
  continue;
1542
- // Fallback: if lifecycle end doesn't arrive within 5s, resolve
1689
+ // A nonempty exact root chat terminal is authoritative. Empty
1690
+ // envelopes/timers never promote streaming/transcript drafts.
1543
1691
  if (!chatFinalTimer) {
1544
1692
  chatFinalTimer = setTimeout(() => {
1545
- console.warn(`[OpenClaw] collectReply: lifecycle end missing, using chatFinal fallback`);
1546
- // Prefer final chat message over accumulated deltas: some providers may
1547
- // emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
1548
- const latestFinalText = this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
1549
- // Never abort a yielded original run, nor an empty final.
1550
- if (latestFinalText) {
1551
- finish(latestFinalText);
1552
- }
1553
- else if (options?.emptyFinalAsNoReply) {
1554
- finish("NO_REPLY");
1555
- }
1556
- else {
1557
- console.warn(`[OpenClaw] collectReply: empty chatFinal fallback ignored; waiting for real text or idle timeout`);
1558
- chatFinalTimer = null;
1559
- }
1693
+ chatFinalTimer = null;
1694
+ if (chatFinalText.trim())
1695
+ finish(chatFinalText);
1696
+ else
1697
+ void checkRunState(() => true, true);
1560
1698
  }, 5000);
1561
1699
  }
1562
1700
  }
1563
1701
  if (ev.stream === "lifecycle" && ev.data?.phase === "end") {
1564
- // paused/end_turn can beat the confirming chat event, including
1565
- // discussion's emptyFinalAsNoReply path. It is neither success
1566
- // nor failure and alone does NOT authorize a handoff notice.
1567
- if (!chatFinalText.trim() && (turnYielded || (ev.data?.livenessState === "paused" && ev.data?.stopReason === "end_turn")))
1568
- continue;
1569
- // Prefer final chat message over accumulated deltas: some providers may
1570
- // emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
1571
- const finalText = this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
1572
- const finishFromLifecycle = () => {
1573
- const latestFinalText = this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
1574
- if (!chatFinalText && latestFinalText.trim() === "N") {
1575
- // Some providers stream the first character of NO_REPLY ("N") but
1576
- // never deliver a final chat message in time. Never surface a lone
1577
- // "N" to the user; treat it as a suppressed reply.
1578
- finish("NO_REPLY");
1579
- return;
1580
- }
1581
- if (!latestFinalText && options?.emptyFinalAsNoReply) {
1582
- finish("NO_REPLY");
1583
- return;
1584
- }
1585
- if (!latestFinalText) {
1586
- const failureText = buildFailureText(ev);
1587
- const state = ev.data?.livenessState || "";
1588
- const reason = ev.data?.stopReason || "";
1589
- // replayInvalid, cancelled/rpc, and abandoned are often
1590
- // transient runtime states — the real reply may still arrive
1591
- // shortly after via session.message or a subsequent run.
1592
- // Defer the failure instead of finishing immediately.
1593
- const isTransient = ev.data?.replayInvalid
1594
- || state === "cancelled"
1595
- || state === "abandoned"
1596
- || reason === "rpc";
1597
- if (isTransient) {
1598
- pendingRuntimeFailureText = failureText;
1599
- console.warn(`[OpenClaw] transient lifecycle end (${state}, reason=${reason}) for runId=${evRunId || runId}; waiting for real text or idle timeout`);
1600
- return;
1601
- }
1602
- if (state !== "working") {
1603
- if (isWaitTimeout(failureText)) {
1604
- if (!decidingTimeout) {
1605
- decidingTimeout = true;
1606
- void this.classifyWaitTimeout(failureText, targetSessionKey || sessionKey).then(handleTimeoutResult);
1607
- }
1608
- }
1609
- else
1610
- finish(failureText);
1611
- return;
1612
- }
1613
- console.warn(`[OpenClaw] empty lifecycle end ignored for runId=${evRunId || runId}; waiting for real text or idle timeout`);
1614
- return;
1615
- }
1616
- finish(latestFinalText);
1617
- };
1618
- // If lifecycle end beats chat final, a short delta like "N" can be a truncated
1619
- // final reply. Wait for chatFinal before resolving; otherwise suppress lone "N".
1620
- if ((!options?.emptyFinalAsNoReply && ev.data?.livenessState === "working" && !chatFinalText && text.length <= 1)
1621
- || (options?.emptyFinalAsNoReply && !chatFinalSeen && !finalText)) {
1622
- // A lifecycle-only empty discussion end also needs this small
1623
- // window for a confirming yielded chat final, not instant NO_REPLY.
1624
- if (lifecycleEndTimer)
1625
- clearTimeout(lifecycleEndTimer);
1626
- lifecycleEndTimer = setTimeout(finishFromLifecycle, 5000);
1627
- }
1628
- else {
1629
- finishFromLifecycle();
1702
+ if (chatFinalText.trim()) {
1703
+ finish(chatFinalText);
1704
+ return;
1630
1705
  }
1631
- return;
1706
+ // Lifecycle can precede chat final and can describe a yielded or
1707
+ // abandoned turn. It is only a read-only reconciliation hint.
1708
+ void checkRunState(() => true, true);
1709
+ continue;
1632
1710
  }
1633
1711
  if (ev.stream === "lifecycle" && ev.data?.phase === "error") {
1634
1712
  const errText = String(ev.data?.error || "unknown");
@@ -1652,7 +1730,8 @@ export class OpenClawClient {
1652
1730
  // (a real chatFinal following a timeout error was swallowed).
1653
1731
  if (!decidingTimeout) {
1654
1732
  decidingTimeout = true;
1655
- void this.classifyWaitTimeout(errText, targetSessionKey || sessionKey).then(handleTimeoutResult);
1733
+ const decisionGeneration = idleGeneration;
1734
+ void this.classifyWaitTimeout(errText, targetSessionKey || sessionKey).then(error => handleTimeoutResult(error, () => idleGeneration === decisionGeneration));
1656
1735
  }
1657
1736
  continue;
1658
1737
  }
@@ -1681,8 +1760,10 @@ export class OpenClawClient {
1681
1760
  if (!genericChatErrorTimer) {
1682
1761
  genericChatErrorTimer = setTimeout(() => {
1683
1762
  genericChatErrorTimer = null;
1763
+ const decisionGeneration = idleGeneration;
1684
1764
  void this.resolveTerminalChatError(evRunId || runId, targetSessionKey || sessionKey).then((detail) => {
1685
- if (observationClosed)
1765
+ collectEvents();
1766
+ if (observationClosed || idleGeneration !== decisionGeneration)
1686
1767
  return;
1687
1768
  if (chatFinalText.trim()) {
1688
1769
  finish(chatFinalText);
@@ -1695,7 +1776,7 @@ export class OpenClawClient {
1695
1776
  if (lifecycleEndTimer)
1696
1777
  clearTimeout(lifecycleEndTimer);
1697
1778
  reject(new Error(`Agent error: ${detail}`));
1698
- }, error => { void handleTimeoutResult(error); });
1779
+ }, error => { void handleTimeoutResult(error, () => idleGeneration === decisionGeneration); });
1699
1780
  }, 350);
1700
1781
  }
1701
1782
  continue;
@@ -1712,7 +1793,8 @@ export class OpenClawClient {
1712
1793
  // (a real chatFinal following a timeout error was swallowed).
1713
1794
  if (!decidingTimeout) {
1714
1795
  decidingTimeout = true;
1715
- void this.classifyWaitTimeout(errText, targetSessionKey || sessionKey).then(handleTimeoutResult);
1796
+ const decisionGeneration = idleGeneration;
1797
+ void this.classifyWaitTimeout(errText, targetSessionKey || sessionKey).then(error => handleTimeoutResult(error, () => idleGeneration === decisionGeneration));
1716
1798
  }
1717
1799
  continue;
1718
1800
  }
@@ -1726,12 +1808,6 @@ export class OpenClawClient {
1726
1808
  clearTimeout(genericChatErrorTimer);
1727
1809
  if (terminatingReconcileTimer)
1728
1810
  clearTimeout(terminatingReconcileTimer);
1729
- // Abort the (possibly still-running) backgrounded run before we
1730
- // reject, so an auto-retry probe does not race a zombie run on the
1731
- // same session. Idempotent/best-effort.
1732
- this.abortChat(targetSessionKey || sessionKey, evRunId || runId).catch((abortErr) => {
1733
- console.warn(`[OpenClaw] abort after chat error failed:`, safeErrorSummary(abortErr));
1734
- });
1735
1811
  reject(new Error(`Agent error: ${errText}`));
1736
1812
  return;
1737
1813
  }
@@ -1753,15 +1829,17 @@ export class OpenClawClient {
1753
1829
  this.eventCollectors.add(wakeCollector);
1754
1830
  const observationKey = targetSessionKey ? this.canonicalSessionKey(targetSessionKey) : "";
1755
1831
  const checkAfterNoActiveRun = async () => {
1832
+ const decisionGeneration = idleGeneration;
1756
1833
  await checkRunState();
1757
- if (observationClosed || !observationKey)
1834
+ if (observationClosed || !observationKey || decisionGeneration !== idleGeneration)
1758
1835
  return;
1759
1836
  try {
1760
1837
  // Used only after the plugin reports no active run. Missing terminal
1761
1838
  // history is not failure, but an explicit idle session must not keep
1762
1839
  // a phantom queue owner blocking this newly-arrived user message.
1763
- const history = await this.rpc("chat.history", { sessionKey: observationKey, limit: 1 }, 2500);
1764
- if (!observationClosed && history?.sessionKey === observationKey && history?.sessionInfo?.hasActiveRun === false) {
1840
+ const dispatch = await this.getSessionDispatchStatus(observationKey);
1841
+ collectEvents();
1842
+ if (!observationClosed && decisionGeneration === idleGeneration && dispatch.idleConfirmed && !dispatch.archived && (!options?.expectedSessionId || dispatch.sessionId === options.expectedSessionId)) {
1765
1843
  reject(new InactiveRunObservation());
1766
1844
  }
1767
1845
  }
@@ -1771,6 +1849,8 @@ export class OpenClawClient {
1771
1849
  this.runStateChecks.set(observationKey, checkAfterNoActiveRun);
1772
1850
  cleanupObservation = () => {
1773
1851
  observationClosed = true;
1852
+ releaseEventReader();
1853
+ clearIdleGuard();
1774
1854
  clearTimeout(idleTimer);
1775
1855
  this.eventCollectors.delete(wakeCollector);
1776
1856
  clearInterval(poller);
@@ -1835,18 +1915,18 @@ export class OpenClawClient {
1835
1915
  async resolveTerminalChatError(runId, sessionKey) {
1836
1916
  try {
1837
1917
  const snapshot = await this.rpc("agent.wait", { runId, timeoutMs: 1500 }, 2500);
1838
- if (sessionKey && (snapshot?.status === "timeout" || isWaitTimeout(String(snapshot?.error || "")))) {
1839
- const status = await this.getSessionRuntimeStatus(sessionKey);
1840
- if (status.running)
1841
- throw new SessionWaitPaused(status, true);
1918
+ if (snapshot?.runId !== runId || !Number.isFinite(snapshot.endedAt) || snapshot.endedAt <= 0 || snapshot.yielded || snapshot.pendingError
1919
+ || ['queue', 'gateway_draining'].includes(snapshot.timeoutPhase) || typeof snapshot.startedAt === 'number' && snapshot.endedAt < snapshot.startedAt
1920
+ || !['error', 'timeout'].includes(snapshot.status)) {
1921
+ throw new SessionWaitPaused(await this.getSessionRuntimeStatus(sessionKey || ''), true);
1842
1922
  }
1843
1923
  return this.formatTerminalRunError(snapshot);
1844
1924
  }
1845
1925
  catch (err) {
1846
1926
  if (err instanceof SessionWaitPaused)
1847
1927
  throw err;
1848
- const detail = err instanceof Error ? err.message : String(err);
1849
- return `OpenClaw 运行失败,但未返回具体原因${detail ? `(${detail})` : ""}`;
1928
+ // Failed read-only reconciliation is not the execution's failure.
1929
+ throw new SessionWaitPaused(normalizeSessionRuntimeStatus(undefined), true);
1850
1930
  }
1851
1931
  }
1852
1932
  formatTerminalRunError(snapshot) {
@@ -1863,7 +1943,7 @@ export class OpenClawClient {
1863
1943
  : 0;
1864
1944
  const elapsed = elapsedSeconds > 0 ? `(运行约 ${this.formatDurationSeconds(elapsedSeconds)})` : "";
1865
1945
  const phase = timeoutPhase ? `,阶段:${timeoutPhase}` : "";
1866
- return `等待 Agent 运行结果超时${elapsed}${phase};底层运行可能仍在继续`;
1946
+ return `等待 Agent 运行结果超时${elapsed}${phase};底层运行可能仍在继续${rawError ? `;${rawError}` : ""}`;
1867
1947
  }
1868
1948
  if (rawError)
1869
1949
  return rawError;
@@ -1887,17 +1967,6 @@ export class OpenClawClient {
1887
1967
  || t.includes("too many tokens")
1888
1968
  || t.includes("maximum context");
1889
1969
  }
1890
- pickBestCollectedText(chatFinalText, assistantText, chatDeltaText, transcriptAssistantText) {
1891
- if (chatFinalText)
1892
- return chatFinalText;
1893
- const candidates = [assistantText, chatDeltaText, transcriptAssistantText].filter((value) => value && value.trim());
1894
- if (candidates.length === 0)
1895
- return "";
1896
- // transcriptAssistant is a full transcript mirror. Keep it separate from
1897
- // streaming deltas to avoid concatenating the same response twice; choose
1898
- // the richest available non-final text as fallback.
1899
- return candidates.sort((a, b) => b.length - a.length)[0];
1900
- }
1901
1970
  // --- Session management ---
1902
1971
  async createSession(params) {
1903
1972
  return this.rpc("sessions.create", { ...params, key: this.canonicalSessionKey(params.key) });
@@ -2088,7 +2157,7 @@ export class OpenClawClient {
2088
2157
  * order, showing the ORIGINAL text) and the steer-consumed callback, then
2089
2158
  * clear that pending entry. Returns true if it matched a steered message.
2090
2159
  */
2091
- handleSteerConsumption(sessionKey, text) {
2160
+ handleSteerConsumption(sessionKey, text, runId) {
2092
2161
  const key = this.shortKey(sessionKey);
2093
2162
  const queue = this.pendingSteerTexts.get(key);
2094
2163
  if (!queue || queue.length === 0)
@@ -2114,7 +2183,7 @@ export class OpenClawClient {
2114
2183
  const [matched] = queue.splice(matchedIndex, 1);
2115
2184
  if (queue.length === 0)
2116
2185
  this.pendingSteerTexts.delete(key);
2117
- const progressCb = this.progressCallbacks.get(key) || this.progressCallbacks.get(`agent:main:${key}`);
2186
+ const progressCb = this.progressForRun(sessionKey, runId);
2118
2187
  if (progressCb)
2119
2188
  void progressCb({ kind: "steer", text: matched.displayText });
2120
2189
  const list = this.steerConsumedCallbacks.get(key);
@@ -2286,8 +2355,11 @@ export class OpenClawClient {
2286
2355
  return unknown();
2287
2356
  };
2288
2357
  try {
2358
+ // The outer race must honor the SAME total budget as the scan itself
2359
+ // (audit P1 2026-10-17: an 8000ms scan raced against a 1500ms timer was
2360
+ // still killed at 1500ms — three real 650ms pages never got to finish).
2289
2361
  return await Promise.race([scan(), new Promise((_, reject) => {
2290
- timer = setTimeout(() => reject(new Error('activity lookup timeout')), 1500);
2362
+ timer = setTimeout(() => reject(new Error('activity lookup timeout')), 8000);
2291
2363
  })]);
2292
2364
  }
2293
2365
  catch {
@@ -2300,7 +2372,8 @@ export class OpenClawClient {
2300
2372
  }
2301
2373
  async classifyWaitTimeout(text, sessionKey) {
2302
2374
  const status = await this.getSessionRuntimeStatus(sessionKey).catch(() => normalizeSessionRuntimeStatus(undefined));
2303
- return status.running ? new SessionWaitPaused(status, true) : new Error(`Agent error: ${text}`);
2375
+ // A wait timeout plus session idle/unknown is still not exact terminal proof.
2376
+ return new SessionWaitPaused(status, true);
2304
2377
  }
2305
2378
  async getSessionInfo(sessionKey, timeoutMs = 120000) {
2306
2379
  return this.rpc("sessions.describe", { key: this.canonicalSessionKey(sessionKey) }, timeoutMs);
@@ -2965,9 +3038,17 @@ export class OpenClawClient {
2965
3038
  const isCurrent = () => this.observationEpoch.get(fullSessionKey) === generation && (this.stopGenerations.get(fullSessionKey) || 0) === stopGeneration;
2966
3039
  const assertCurrent = () => { if (!isCurrent())
2967
3040
  throw new SupersededObservation('admission generation revoked'); };
2968
- this.clearVerboseAssistantState(sk);
2969
3041
  const submissionId = randomUUID();
2970
3042
  let ownedRunId = "";
3043
+ let eventReservationRunId = '';
3044
+ let releaseEventReservation = () => { };
3045
+ const reserveEvents = (runId) => {
3046
+ if (eventReservationRunId === runId)
3047
+ return;
3048
+ releaseEventReservation();
3049
+ eventReservationRunId = runId;
3050
+ releaseEventReservation = this.retainRunEventCustody('reservation', fullSessionKey, runId);
3051
+ };
2971
3052
  let waitPaused = false;
2972
3053
  try {
2973
3054
  // New user inputs arriving after /stop are admitted only AFTER that
@@ -2989,10 +3070,23 @@ export class OpenClawClient {
2989
3070
  assertCurrent();
2990
3071
  this.recoveryStore?.saveAdmission(fullSessionKey, idempotencyKey, 'dispatching', { generation, stopGeneration, payloadDigest: createHash('sha256').update(params.message).digest('hex') });
2991
3072
  this.recoveryStore?.saveRunObservation(fullSessionKey, submissionId, { admissionOnly: true }, Date.now() + 30_000);
3073
+ // Claim exact output custody synchronously in the WS ACK handler.
3074
+ // ws.Receiver can emit ACK + final before any await continuation runs.
3075
+ // Failed admission never runs this hook and cannot revoke the old owner.
3076
+ this.admissionResponseHooks.set(idempotencyKey, receipt => {
3077
+ if (isCurrent() && typeof receipt?.runId === 'string' && receipt.runId && !this.isExplicitlyStoppedRun(fullSessionKey, receipt.runId)) {
3078
+ ownedRunId = receipt.runId;
3079
+ reserveEvents(ownedRunId);
3080
+ this.ownDeliveryRun(ownedRunId);
3081
+ }
3082
+ });
2992
3083
  return this.rpc("chat.send", {
2993
3084
  sessionKey: this.canonicalSessionKey(sk),
2994
3085
  message: params.message,
2995
3086
  attachments: params.attachments,
3087
+ // Ordinary admission is a separate serialized turn, not a request to
3088
+ // inherit stored interrupt/steer policy or its commit-wait finalizer.
3089
+ queueMode: "followup",
2996
3090
  deliver: params.deliver ?? false,
2997
3091
  idempotencyKey,
2998
3092
  });
@@ -3042,6 +3136,7 @@ export class OpenClawClient {
3042
3136
  if (previous)
3043
3137
  for (const fence of [...previous])
3044
3138
  fence();
3139
+ reserveEvents(ownedRunId); // mocked/legacy transports may not use the WS ACK hook
3045
3140
  this.ownDeliveryRun(ownedRunId);
3046
3141
  console.log(`[OpenClaw] chat.send runId: ${result.runId} (rpc=${Date.now() - sendStartedAt}ms, attachments=${params.attachments?.length || 0})`);
3047
3142
  await params.onRunIdentified?.(result.runId, { sessionId: result.sessionId });
@@ -3052,26 +3147,43 @@ export class OpenClawClient {
3052
3147
  await params.onSubmitted?.(result.runId);
3053
3148
  assertCurrent();
3054
3149
  if (params.onProgress) {
3150
+ this.progressRuns.set(fullSessionKey, ownedRunId);
3055
3151
  this.progressCallbacks.set(sk, params.onProgress);
3056
3152
  this.progressCallbacks.set(fullSessionKey, params.onProgress);
3057
3153
  this.progressGenerations.set(sk, generation);
3058
3154
  this.progressGenerations.set(fullSessionKey, generation);
3059
3155
  void params.onProgress({ kind: "lifecycle", text: "等待 OpenClaw 回复" });
3060
3156
  }
3061
- const collecting = this.collectReply(result.runId, params.timeoutMs || FOREGROUND_WAIT_MS, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply, expectedUserText: params.message, onWaitPaused: params.onWaitPaused, onYieldHandoff: params.onYieldHandoff });
3157
+ const collecting = this.collectReply(result.runId, params.timeoutMs || FOREGROUND_WAIT_MS, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply, expectedUserText: params.message, expectedSessionId: result.sessionId, onWaitPaused: params.onWaitPaused, onYieldHandoff: params.onYieldHandoff });
3158
+ // Reader registration is synchronous inside collectReply. Only now may
3159
+ // the receiver's ACK reservation release its same-run terminal custody.
3160
+ releaseEventReservation();
3062
3161
  params.onCollectionStarted?.(result.runId);
3063
3162
  const reply = await collecting;
3064
3163
  if (reply.trim().toUpperCase() === 'NO_REPLY')
3065
3164
  this.recoveryStore?.finishRunObservation(fullSessionKey, ownedRunId);
3066
3165
  else
3067
3166
  this.recoveryStore?.saveRunObservation(fullSessionKey, ownedRunId, { sessionId: result.sessionId, terminalText: reply, terminalObserved: true }, Date.now() + 30_000);
3167
+ // Do not retire on lifecycle/timeout/cleanup alone. Successful full result
3168
+ // custody (and synchronous durable save when bound) precedes retirement.
3169
+ const durable = this.recoveryStore?.getRunObservation(fullSessionKey, ownedRunId);
3170
+ const durableResult = !this.recoveryStore || (reply.trim().toUpperCase() === 'NO_REPLY'
3171
+ ? durable?.state === 'settled'
3172
+ : durable?.terminalObserved === true && durable?.terminalText === reply);
3173
+ if (durableResult) {
3174
+ const completedKey = this.runEventKey(fullSessionKey, ownedRunId);
3175
+ this.completedRunEventCustody.add(completedKey);
3176
+ this.retireCompletedRunEvents(completedKey);
3177
+ }
3068
3178
  return reply;
3069
3179
  }
3070
3180
  catch (error) {
3071
- waitPaused = error instanceof SessionWaitPaused || error instanceof SupersededObservation;
3181
+ waitPaused = error instanceof SessionWaitPaused || error instanceof SupersededObservation || error instanceof CollectorStalledError;
3072
3182
  throw error;
3073
3183
  }
3074
3184
  finally {
3185
+ this.admissionResponseHooks.delete(submissionId);
3186
+ releaseEventReservation();
3075
3187
  // OpenClaw can emit the final assistant session.message a moment after
3076
3188
  // collectReply returns. Keep a short grace window so normal chat replies
3077
3189
  // are not delivered twice via the proactive-message path. Cron/LMA runs
@@ -3087,6 +3199,7 @@ export class OpenClawClient {
3087
3199
  if (this.progressGenerations.get(fullSessionKey) === generation && this.progressCallbacks.get(fullSessionKey) === params.onProgress) {
3088
3200
  this.progressCallbacks.delete(fullSessionKey);
3089
3201
  this.progressGenerations.delete(fullSessionKey);
3202
+ this.progressRuns.delete(fullSessionKey);
3090
3203
  }
3091
3204
  }
3092
3205
  if (waitPaused)