ocuclaw 1.2.4 → 1.3.0

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.
Files changed (59) hide show
  1. package/README.md +18 -5
  2. package/dist/config/runtime-config.js +81 -3
  3. package/dist/domain/activity-status-adapter.js +138 -605
  4. package/dist/domain/activity-status-arbiter.js +109 -0
  5. package/dist/domain/activity-status-labels.js +906 -0
  6. package/dist/domain/code-span-regions.js +103 -0
  7. package/dist/domain/conversation-state.js +14 -1
  8. package/dist/domain/debug-store.js +38 -182
  9. package/dist/domain/glasses-ui-content-summary.js +62 -0
  10. package/dist/domain/glasses-ui-system-prompt.js +28 -0
  11. package/dist/domain/message-emoji-allowlist.js +16 -0
  12. package/dist/domain/message-emoji-filter.js +33 -55
  13. package/dist/domain/neural-emoji-reactor-system-prompt.js +43 -0
  14. package/dist/domain/neural-emoji-reactor-tag-config.js +56 -0
  15. package/dist/domain/neural-pace-modulator-system-prompt.js +32 -0
  16. package/dist/domain/neural-pace-modulator-tag-config.js +51 -0
  17. package/dist/domain/tagged-span-parser.js +121 -0
  18. package/dist/domain/tagged-span-strip.js +38 -0
  19. package/dist/even-ai/even-ai-endpoint.js +91 -0
  20. package/dist/even-ai/even-ai-run-waiter.js +14 -0
  21. package/dist/even-ai/even-ai-settings-store.js +14 -0
  22. package/dist/gateway/gateway-bridge.js +14 -2
  23. package/dist/gateway/gateway-timing-ledger.js +457 -0
  24. package/dist/gateway/openclaw-client.js +462 -38
  25. package/dist/index.js +28 -1
  26. package/dist/runtime/downstream-handler.js +754 -83
  27. package/dist/runtime/downstream-server.js +700 -534
  28. package/dist/runtime/ocuclaw-settings-store.js +74 -31
  29. package/dist/runtime/plugin-update-service.js +216 -0
  30. package/dist/runtime/protocol-adapter.js +9 -0
  31. package/dist/runtime/provider-usage-select.js +168 -0
  32. package/dist/runtime/relay-client-nudge-controller.js +553 -0
  33. package/dist/runtime/relay-core.js +1209 -204
  34. package/dist/runtime/relay-health-monitor.js +172 -0
  35. package/dist/runtime/relay-operation-registry.js +263 -0
  36. package/dist/runtime/relay-service.js +201 -1
  37. package/dist/runtime/relay-worker-approval-replay-cache.js +68 -0
  38. package/dist/runtime/relay-worker-entry.js +32 -0
  39. package/dist/runtime/relay-worker-health.js +272 -0
  40. package/dist/runtime/relay-worker-protocol.js +285 -0
  41. package/dist/runtime/relay-worker-queue.js +202 -0
  42. package/dist/runtime/relay-worker-supervisor.js +1081 -0
  43. package/dist/runtime/relay-worker-transport.js +1051 -0
  44. package/dist/runtime/session-context-service.js +189 -0
  45. package/dist/runtime/session-service.js +615 -24
  46. package/dist/runtime/upstream-runtime.js +1167 -60
  47. package/dist/tools/device-info-tool.js +242 -0
  48. package/dist/tools/glasses-ui-cron.js +427 -0
  49. package/dist/tools/glasses-ui-descriptors.js +261 -0
  50. package/dist/tools/glasses-ui-limits.js +21 -0
  51. package/dist/tools/glasses-ui-paint-floor.js +99 -0
  52. package/dist/tools/glasses-ui-recipes.js +746 -0
  53. package/dist/tools/glasses-ui-surfaces.js +278 -0
  54. package/dist/tools/glasses-ui-template.js +182 -0
  55. package/dist/tools/glasses-ui-tool.js +1147 -0
  56. package/dist/tools/session-title-tool.js +209 -0
  57. package/dist/version.js +2 -0
  58. package/openclaw.plugin.json +163 -15
  59. package/package.json +12 -4
@@ -3,6 +3,7 @@ import * as crypto from "node:crypto";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import WebSocket from "ws";
6
+ import { createGatewayTimingLedger } from "./gateway-timing-ledger.js";
6
7
 
7
8
  // --- Constants ---
8
9
 
@@ -19,9 +20,14 @@ const SCOPES = [
19
20
  "operator.approvals",
20
21
  "operator.admin",
21
22
  ];
22
- const PROTOCOL_VERSION = 3;
23
+ const MIN_PROTOCOL_VERSION = 3;
24
+ const MAX_PROTOCOL_VERSION = 4;
23
25
  const HISTORY_ACTIVITY_POLL_INTERVAL_MS = 500;
24
26
  const HISTORY_ACTIVITY_POLL_LIMIT = 40;
27
+ // Per-request ACK timeout: fires only while waiting for the *initial* ack, not
28
+ // during a mid-turn run. Comfortably above normal ack latency but below the
29
+ // ~60s coarse tick-watch run backstop. Disarmed when an accepted ack arrives.
30
+ const RPC_ACK_TIMEOUT_MS = 15000;
25
31
 
26
32
  const THINKING_SUMMARY_KEYS = [
27
33
  "summary",
@@ -411,6 +417,231 @@ function normalizeThinkingSummarySource(rawSource) {
411
417
  return null;
412
418
  }
413
419
 
420
+ const FAILURE_LABEL_MAX_CHARS = 120;
421
+ const FAILURE_DETAIL_MAX_CHARS = 240;
422
+ const FAILOVER_REASON_ACTIVITY_CODE_MAP = Object.freeze({
423
+ rate_limit: "provider_rate_limited",
424
+ billing: "provider_quota_exhausted",
425
+ auth: "provider_auth_invalid",
426
+ auth_permanent: "provider_auth_invalid",
427
+ overloaded: "provider_unavailable",
428
+ timeout: "provider_timeout",
429
+ format: "provider_request_invalid",
430
+ });
431
+
432
+ function shortText(text, maxChars) {
433
+ if (!text) return "";
434
+ if (text.length <= maxChars) return text;
435
+ if (maxChars <= 3) return ".".repeat(Math.max(maxChars, 0));
436
+ return `${text.slice(0, maxChars - 3)}...`;
437
+ }
438
+
439
+ function sanitizeFailureText(rawText, maxChars) {
440
+ if (rawText === undefined || rawText === null) return null;
441
+ let text = String(rawText);
442
+
443
+ text = text.replace(
444
+ new RegExp(`([?&](?:token|access_token|api_key|key|password|secret)=)[^&#\\s]+`, "gi"),
445
+ "$1[redacted]",
446
+ );
447
+ text = text.replace(
448
+ /((?:api[_-]?key|token|password|secret)\s*[=:]\s*)([^,\s"'`]+)/gi,
449
+ "$1[redacted]",
450
+ );
451
+ text = text.replace(/(authorization\s*:\s*bearer\s+)[^\s"'`]+/gi, "$1[redacted]");
452
+ text = text.replace(/\bBearer\s+[A-Za-z0-9._-]{8,}\b/g, "Bearer [redacted]");
453
+ text = text.replace(
454
+ /\b(sk-[A-Za-z0-9]{16,}|ghp_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,})\b/g,
455
+ "[redacted]",
456
+ );
457
+ text = text.replace(/\s+/g, " ").trim();
458
+ if (!text) return null;
459
+ return shortText(text, maxChars);
460
+ }
461
+
462
+ function normalizeFailureHint(rawHint) {
463
+ if (typeof rawHint !== "string") return "";
464
+ const trimmed = rawHint.trim().toLowerCase();
465
+ if (!trimmed) return "";
466
+ return trimmed.replace(/[\s-]+/g, "_");
467
+ }
468
+
469
+ function mapFailureHintToActivityCode(rawHint) {
470
+ const normalizedHint = normalizeFailureHint(rawHint);
471
+ if (!normalizedHint) return null;
472
+ if (Object.hasOwn(FAILOVER_REASON_ACTIVITY_CODE_MAP, normalizedHint)) {
473
+ return FAILOVER_REASON_ACTIVITY_CODE_MAP[normalizedHint];
474
+ }
475
+ if (
476
+ normalizedHint === "auth_scope" ||
477
+ normalizedHint === "auth_refresh" ||
478
+ normalizedHint === "auth_html_403"
479
+ ) {
480
+ return "provider_auth_invalid";
481
+ }
482
+ if (normalizedHint === "proxy") {
483
+ return "provider_unavailable";
484
+ }
485
+ return null;
486
+ }
487
+
488
+ function inferFailureHintFromText(rawText) {
489
+ if (typeof rawText !== "string") return null;
490
+ const text = rawText.trim().toLowerCase();
491
+ if (!text) return null;
492
+
493
+ if (
494
+ text.includes("rate_limit") ||
495
+ text.includes("rate limit") ||
496
+ text.includes("rate limited") ||
497
+ text.includes("too many requests") ||
498
+ text.includes("usage limit") ||
499
+ text.includes("organization usage limit")
500
+ ) {
501
+ return "rate_limit";
502
+ }
503
+
504
+ if (
505
+ text.includes("out of credits") ||
506
+ text.includes("insufficient credits") ||
507
+ text.includes("insufficient quota") ||
508
+ text.includes("quota exhausted") ||
509
+ text.includes("quota balance") ||
510
+ text.includes("payment required") ||
511
+ text.includes("billing hard limit") ||
512
+ text.includes("credit balance")
513
+ ) {
514
+ return "billing";
515
+ }
516
+
517
+ if (
518
+ text.includes("invalid api key") ||
519
+ text.includes("api key invalid") ||
520
+ text.includes("authentication failed") ||
521
+ text.includes("missing scopes") ||
522
+ text.includes("missing scope") ||
523
+ text.includes("invalid_api_key") ||
524
+ text.includes("permission_error") ||
525
+ text.includes("oauth token refresh failed")
526
+ ) {
527
+ return "auth";
528
+ }
529
+
530
+ if (text.includes("timed out") || text.includes("timeout")) {
531
+ return "timeout";
532
+ }
533
+
534
+ if (
535
+ text.includes("invalid request") ||
536
+ text.includes("bad request") ||
537
+ text.includes("provider_request_invalid")
538
+ ) {
539
+ return "format";
540
+ }
541
+
542
+ if (
543
+ text.includes("service unavailable") ||
544
+ text.includes("provider unavailable") ||
545
+ text.includes("temporarily unavailable") ||
546
+ text.includes("overloaded")
547
+ ) {
548
+ return "overloaded";
549
+ }
550
+
551
+ return null;
552
+ }
553
+
554
+ function resolveTerminalErrorCode(source, fallbackCode) {
555
+ const explicitCode = pickTrimmedString(source.code, source.errorCode);
556
+ if (explicitCode) {
557
+ return explicitCode;
558
+ }
559
+
560
+ const structuredHintCode = mapFailureHintToActivityCode(
561
+ pickTrimmedString(source.errorKind, source.failoverReason, source.providerRuntimeFailureKind),
562
+ );
563
+ if (structuredHintCode) {
564
+ return structuredHintCode;
565
+ }
566
+
567
+ const inferredHintCode = mapFailureHintToActivityCode(
568
+ inferFailureHintFromText(
569
+ pickTrimmedString(
570
+ source.detail,
571
+ source.message,
572
+ source.error,
573
+ source.label,
574
+ source.reason,
575
+ ),
576
+ ),
577
+ );
578
+ return inferredHintCode || fallbackCode || "agent_error";
579
+ }
580
+
581
+ function buildTerminalErrorActivity(data, fallbackRunId, fallbackSessionKey, fallbackCode) {
582
+ const source = isObject(data) ? data : {};
583
+ const code = resolveTerminalErrorCode(source, fallbackCode);
584
+ const labelSource = pickTrimmedString(
585
+ source.label,
586
+ source.title,
587
+ source.summary,
588
+ source.message,
589
+ source.error,
590
+ code,
591
+ "Run failed",
592
+ );
593
+ const detailSource = pickTrimmedString(
594
+ source.detail,
595
+ source.message,
596
+ source.error,
597
+ source.reason,
598
+ source.label,
599
+ code,
600
+ );
601
+ const runId = normalizeRunId(
602
+ pickTrimmedString(source.runId, fallbackRunId),
603
+ );
604
+ const sessionKey = normalizeSessionKey(
605
+ pickTrimmedString(source.sessionKey, fallbackSessionKey),
606
+ );
607
+ const activity = {
608
+ state: "idle",
609
+ sessionKey,
610
+ runId,
611
+ origin: "lifecycle",
612
+ phase: "error",
613
+ isError: true,
614
+ code,
615
+ };
616
+ const label = sanitizeFailureText(labelSource, FAILURE_LABEL_MAX_CHARS);
617
+ const detail = sanitizeFailureText(detailSource, FAILURE_DETAIL_MAX_CHARS);
618
+ if (label) activity.label = label;
619
+ if (detail) activity.detail = detail;
620
+ else if (label) activity.detail = label;
621
+ // failoverReason is set by the runtime when this terminal error is about to
622
+ // trigger a profile rotation or fallback retry. Surface it as a hint so the
623
+ // WebUI can suppress sticky failure feedback for runs that will retry on
624
+ // another auth profile or model rather than ending the user-visible turn.
625
+ if (pickTrimmedString(source.failoverReason)) {
626
+ activity.failoverPending = true;
627
+ }
628
+ return activity;
629
+ }
630
+
631
+ function buildStructuredError(data, fallbackMessage, fallbackCode) {
632
+ const source = isObject(data) ? data : {};
633
+ const error = new Error(
634
+ pickTrimmedString(source.message, source.error, fallbackMessage) || fallbackMessage,
635
+ );
636
+ const code = pickTrimmedString(source.code, source.errorCode, fallbackCode) || fallbackCode;
637
+ const requestId = pickTrimmedString(source.requestId);
638
+ const op = pickTrimmedString(source.op);
639
+ if (code) error.code = code;
640
+ if (requestId) error.requestId = requestId;
641
+ if (op) error.op = op;
642
+ return error;
643
+ }
644
+
414
645
  function selectThinkingDisplayLabel({
415
646
  summaryText,
416
647
  boldLabelCandidate,
@@ -477,6 +708,7 @@ function parseThinkingSignatureId(rawSignature) {
477
708
 
478
709
  function extractThinkingPayload(raw) {
479
710
  if (!isObject(raw)) return null;
711
+ if (raw.redacted === true || raw.type === "redacted_thinking") return null;
480
712
  const summaryEntry = pickFirstStringEntry(raw, THINKING_SUMMARY_KEYS);
481
713
  const detailEntry = pickFirstStringEntry(raw, THINKING_DETAIL_KEYS);
482
714
  const summaryText = normalizeThinkingText(summaryEntry ? summaryEntry.value : null);
@@ -544,6 +776,11 @@ class OpenClawClient extends EventEmitter {
544
776
  constructor(opts = {}) {
545
777
  super();
546
778
  this._logger = normalizeLogger(opts.logger);
779
+ this._timingLedger = createGatewayTimingLedger({
780
+ logger: this._logger,
781
+ now: () => Date.now(),
782
+ emitTiming: (event) => this.emit("timing", event),
783
+ });
547
784
  this._gatewayUrl = pickTrimmedString(opts.gatewayUrl);
548
785
  this._gatewayToken = pickTrimmedString(opts.gatewayToken);
549
786
  this._persistencePaths = resolvePersistencePaths(opts.stateDir);
@@ -570,7 +807,7 @@ class OpenClawClient extends EventEmitter {
570
807
  this._activeRunSessionKey = null;
571
808
  this._activeRunStartedAtMs = null;
572
809
  this._activeRunGeneration = 0;
573
- this._runTextBuffer = ""; // buffered assistant deltas for current run
810
+ this._runTextBuffer = ""; // accumulated assistant text for current run
574
811
 
575
812
  // --- Agent identity (step 6) ---
576
813
  this._agentIdentity = null;
@@ -589,6 +826,7 @@ class OpenClawClient extends EventEmitter {
589
826
 
590
827
  setLogger(logger) {
591
828
  this._logger = normalizeLogger(logger);
829
+ this._timingLedger.setLogger(this._logger);
592
830
  }
593
831
 
594
832
  /**
@@ -630,6 +868,7 @@ class OpenClawClient extends EventEmitter {
630
868
  clearTimeout(this._reconnectTimer);
631
869
  this._reconnectTimer = null;
632
870
  }
871
+ this._timingLedger.clear("stop");
633
872
  this._invalidateActiveRun();
634
873
  this._stopTickWatch();
635
874
  if (this._ws) {
@@ -654,10 +893,37 @@ class OpenClawClient extends EventEmitter {
654
893
  const id = crypto.randomUUID();
655
894
  const frame = { type: "req", id, method, params };
656
895
  const expectFinal = opts && opts.expectFinal === true;
896
+ const diagnostic = opts && opts.diagnostic;
657
897
  const promise = new Promise((resolve, reject) => {
658
- this._pending.set(id, { resolve, reject, expectFinal });
898
+ this._pending.set(id, { resolve, reject, expectFinal, method, diagnostic });
659
899
  });
900
+ // Per-request ACK timeout: reject if the initial ack never arrives. Disarmed
901
+ // when an accepted ack arrives (keepPending branch) so a legitimately
902
+ // long-running mid-turn run is NOT killed by this timeout.
903
+ const timer = setTimeout(() => {
904
+ const pendingEntry = this._pending.get(id);
905
+ if (!pendingEntry) return;
906
+ this._pending.delete(id);
907
+ const err = new Error("rpc ack timeout");
908
+ err.code = "rpc_timeout";
909
+ err.retryable = true;
910
+ pendingEntry.reject(err);
911
+ }, RPC_ACK_TIMEOUT_MS);
912
+ if (timer.unref) {
913
+ timer.unref();
914
+ }
915
+ const pendingForTimer = this._pending.get(id);
916
+ if (pendingForTimer) {
917
+ pendingForTimer.timer = timer;
918
+ }
660
919
  const raw = JSON.stringify(frame);
920
+ this._timingLedger.recordRequestSent({
921
+ requestId: id,
922
+ method,
923
+ params,
924
+ expectFinal,
925
+ diagnostic,
926
+ });
661
927
  this.emit("protocol", { direction: "out", frame });
662
928
  this._ws.send(raw);
663
929
  return promise;
@@ -728,13 +994,17 @@ class OpenClawClient extends EventEmitter {
728
994
  }
729
995
 
730
996
  /**
731
- * Resolve an exec approval request.
997
+ * Resolve an approval request.
732
998
  * @param {string} id - Approval request ID
733
999
  * @param {string} decision - "allow-once", "allow-always", or "deny"
734
1000
  * @returns {Promise}
735
1001
  */
736
1002
  resolveApproval(id, decision) {
737
- return this.request("exec.approval.resolve", { id, decision });
1003
+ const method =
1004
+ typeof id === "string" && id.startsWith("plugin:")
1005
+ ? "plugin.approval.resolve"
1006
+ : "exec.approval.resolve";
1007
+ return this.request(method, { id, decision });
738
1008
  }
739
1009
 
740
1010
  _beginActiveRun(runId, sessionKey) {
@@ -791,6 +1061,7 @@ class OpenClawClient extends EventEmitter {
791
1061
  this._connectSent = false;
792
1062
 
793
1063
  // Reset per-connection state
1064
+ this._timingLedger.clear("connect_reset");
794
1065
  this._lastSeq = null;
795
1066
  this._lastTick = null;
796
1067
  this._historyResolved = false;
@@ -819,6 +1090,7 @@ class OpenClawClient extends EventEmitter {
819
1090
  this._ws = null;
820
1091
  this._stopTickWatch();
821
1092
  this._stopHistoryActivityPolling();
1093
+ this._timingLedger.clear("disconnect");
822
1094
  this._flushPendingErrors(new Error(`gateway closed (${code}): ${reasonText}`));
823
1095
  this.emit("disconnected", { code, reason: reasonText });
824
1096
  this.emit("status", "disconnected");
@@ -865,7 +1137,23 @@ class OpenClawClient extends EventEmitter {
865
1137
  // If expectFinal, skip intermediate acks (status: "accepted") (step 4)
866
1138
  const payload = parsed.payload;
867
1139
  const status = payload && payload.status;
868
- if (pending.expectFinal && status === "accepted") {
1140
+ const keepPending =
1141
+ pending.expectFinal && parsed.ok === true && status === "accepted";
1142
+ this._timingLedger.recordResponseReceived({
1143
+ requestId: parsed.id,
1144
+ ok: parsed.ok === true,
1145
+ payload: parsed.payload,
1146
+ response: parsed.payload,
1147
+ error: parsed.error,
1148
+ keepPending,
1149
+ });
1150
+ if (keepPending) {
1151
+ // The accepted ack arrived: the run is legitimately long-running, so
1152
+ // disarm the ACK timeout (the coarse tick-watch remains the run backstop).
1153
+ if (pending.timer) {
1154
+ clearTimeout(pending.timer);
1155
+ pending.timer = null;
1156
+ }
869
1157
  // Track the runId from the ack
870
1158
  if (payload.runId) {
871
1159
  this._activeRunId = payload.runId;
@@ -874,6 +1162,11 @@ class OpenClawClient extends EventEmitter {
874
1162
  return; // Keep the pending entry, wait for final response
875
1163
  }
876
1164
 
1165
+ // Final settle: clear the ACK timeout before resolving/rejecting.
1166
+ if (pending.timer) {
1167
+ clearTimeout(pending.timer);
1168
+ pending.timer = null;
1169
+ }
877
1170
  this._pending.delete(parsed.id);
878
1171
  if (parsed.ok) {
879
1172
  pending.resolve(parsed.payload);
@@ -951,7 +1244,7 @@ class OpenClawClient extends EventEmitter {
951
1244
  return;
952
1245
  }
953
1246
 
954
- // --- Exec approval events ---
1247
+ // --- Approval events ---
955
1248
  if (evt.event === "exec.approval.requested") {
956
1249
  this.emit("approval", evt.payload);
957
1250
  return;
@@ -962,11 +1255,57 @@ class OpenClawClient extends EventEmitter {
962
1255
  return;
963
1256
  }
964
1257
 
1258
+ if (evt.event === "plugin.approval.requested") {
1259
+ this.emit("approval", { ...(evt.payload || {}), approvalKind: "plugin" });
1260
+ return;
1261
+ }
1262
+
1263
+ if (evt.event === "plugin.approval.resolved") {
1264
+ this.emit("approvalResolved", { ...(evt.payload || {}), approvalKind: "plugin" });
1265
+ return;
1266
+ }
1267
+
965
1268
  // --- Agent events (step 5) ---
966
1269
  if (evt.event === "agent") {
967
- // If history hasn't resolved yet, queue the event (step 9)
968
- if (!this._historyResolved) {
969
- this._eventQueue.push(evt);
1270
+ const payload = evt.payload || {};
1271
+ const data = payload.data || {};
1272
+ this._timingLedger.recordGatewayEventReceived({
1273
+ eventName: evt.event,
1274
+ payload: evt.payload,
1275
+ kind: evt.event,
1276
+ runId: payload.runId,
1277
+ stream: payload.stream,
1278
+ phase: data.phase,
1279
+ data,
1280
+ });
1281
+ // History-gate decouple (F18, history half): only the run-end COMMIT
1282
+ // mutates the persistent message list downstream (lifecycle:end ->
1283
+ // emit("message") -> conversationState.addMessage, a blind push), and the
1284
+ // history hydrate is a DESTRUCTIVE replace-all (conversation-state.ts
1285
+ // hydrate()). To avoid the commit being clobbered/duplicated by a later
1286
+ // hydrate, the commit must still run AFTER history resolves. Every other
1287
+ // agent event (lifecycle:start, assistant/streaming, tool, error) emits
1288
+ // only transient/live effects (activity/streaming/error) that hydrate
1289
+ // never touches, so processing them immediately is safe and removes the
1290
+ // reconnect responsiveness delay. The intact commit event is queued and
1291
+ // replayed via _drainEventQueue, preserving the de-dup contract exactly.
1292
+ const isCommitEvent = data.phase === "end" && payload.stream === "lifecycle";
1293
+ if (isCommitEvent && !this._historyResolved) {
1294
+ // Snapshot the commit-relevant LIVE instance state at enqueue time. The
1295
+ // lifecycle:end branch reads _runTextBuffer / _activeRunId / _gapDuringRun
1296
+ // (and _activeRunSessionKey as the session-key fallback) — all of which a
1297
+ // LATER run's lifecycle:start (_beginActiveRun) or an _invalidateActiveRun
1298
+ // mutates before the queue drains. Capturing now keeps the deferred commit
1299
+ // contemporaneous with its own run, restoring pre-F18 replay semantics.
1300
+ this._eventQueue.push({
1301
+ payload: evt.payload,
1302
+ capturedCommit: {
1303
+ fullText: this._runTextBuffer,
1304
+ activeRunId: this._activeRunId,
1305
+ activeRunSessionKey: this._activeRunSessionKey,
1306
+ gapDuringRun: this._gapDuringRun,
1307
+ },
1308
+ });
970
1309
  return;
971
1310
  }
972
1311
  this._handleAgentEvent(evt.payload);
@@ -980,7 +1319,7 @@ class OpenClawClient extends EventEmitter {
980
1319
  * Handle an agent event payload.
981
1320
  * Buffers assistant text deltas, emits activity/message events.
982
1321
  */
983
- _handleAgentEvent(payload) {
1322
+ _handleAgentEvent(payload, capturedCommit) {
984
1323
  if (!payload) return;
985
1324
 
986
1325
  const { runId, stream, data } = payload;
@@ -988,7 +1327,7 @@ class OpenClawClient extends EventEmitter {
988
1327
 
989
1328
  switch (stream) {
990
1329
  case "lifecycle":
991
- this._handleLifecycleEvent(runId, data, payload.sessionKey);
1330
+ this._handleLifecycleEvent(runId, data, payload.sessionKey, capturedCommit);
992
1331
  break;
993
1332
  case "assistant":
994
1333
  this._handleAssistantEvent(runId, data);
@@ -998,14 +1337,32 @@ class OpenClawClient extends EventEmitter {
998
1337
  break;
999
1338
  case "error":
1000
1339
  this._logger.error(`[openclaw] Agent error: ${JSON.stringify(data)}`);
1001
- this.emit("error", new Error(data.message || "agent error"));
1340
+ {
1341
+ const terminalActivity = buildTerminalErrorActivity(
1342
+ data,
1343
+ runId || this._activeRunId,
1344
+ payload.sessionKey || this._activeRunSessionKey,
1345
+ "agent_error",
1346
+ );
1347
+ if (terminalActivity.runId && terminalActivity.sessionKey) {
1348
+ this.emit("activity", terminalActivity);
1349
+ this._timingLedger.recordRunTerminal({
1350
+ runId: terminalActivity.runId,
1351
+ });
1352
+ this._invalidateActiveRun();
1353
+ }
1354
+ this.emit(
1355
+ "error",
1356
+ buildStructuredError(data, "agent error", terminalActivity.code || "agent_error"),
1357
+ );
1358
+ }
1002
1359
  break;
1003
1360
  default:
1004
1361
  break;
1005
1362
  }
1006
1363
  }
1007
1364
 
1008
- _handleLifecycleEvent(runId, data, sessionKey) {
1365
+ _handleLifecycleEvent(runId, data, sessionKey, capturedCommit) {
1009
1366
  switch (data.phase) {
1010
1367
  case "start":
1011
1368
  this._beginActiveRun(runId, sessionKey);
@@ -1021,18 +1378,48 @@ class OpenClawClient extends EventEmitter {
1021
1378
  break;
1022
1379
 
1023
1380
  case "end": {
1381
+ // Prefer the snapshot captured at history-gate enqueue time (F18 commit
1382
+ // defer). When the end event is processed IMMEDIATELY (history already
1383
+ // resolved), capturedCommit is undefined and live instance state is read
1384
+ // exactly as before — byte-for-byte unchanged. When DEFERRED, a later
1385
+ // run's start (or an invalidate) may have already clobbered these shared
1386
+ // fields, so the snapshot keeps the commit contemporaneous with its run.
1387
+ const committedActiveRunId = capturedCommit
1388
+ ? capturedCommit.activeRunId
1389
+ : this._activeRunId;
1390
+ const committedActiveRunSessionKey = capturedCommit
1391
+ ? capturedCommit.activeRunSessionKey
1392
+ : this._activeRunSessionKey;
1024
1393
  // Assemble full response from buffered text
1025
- const fullText = this._runTextBuffer;
1026
- const completedRunId = normalizeRunId(this._activeRunId) || normalizeRunId(runId);
1394
+ const fullText = capturedCommit ? capturedCommit.fullText : this._runTextBuffer;
1395
+ const completedRunId = normalizeRunId(committedActiveRunId) || normalizeRunId(runId);
1027
1396
  const completedSessionKey =
1028
1397
  normalizeSessionKey(sessionKey) ||
1029
- normalizeSessionKey(this._activeRunSessionKey) ||
1398
+ normalizeSessionKey(committedActiveRunSessionKey) ||
1030
1399
  null;
1031
- const gapDuringRun = this._gapDuringRun;
1400
+ const gapDuringRun = capturedCommit ? capturedCommit.gapDuringRun : this._gapDuringRun;
1032
1401
 
1033
1402
  // Invalidate run state before emitting terminal idle so late history polls
1034
1403
  // cannot reopen thinking for a completed run.
1035
- this._invalidateActiveRun();
1404
+ this._timingLedger.recordRunTerminal({
1405
+ runId: completedRunId,
1406
+ });
1407
+ // Reconnect-window guard (F18 successor-run protection): a DEFERRED
1408
+ // commit (capturedCommit present) drains AFTER its run ended, and a
1409
+ // LATER run's lifecycle:start may already have become the live active
1410
+ // run (set _activeRunId, armed history polling, started buffering its
1411
+ // own text) before drain. An unconditional invalidate here would tear
1412
+ // down that live successor — stop its thinking-summary polling, null
1413
+ // its _activeRunId, and clear its _runTextBuffer — wiping state that
1414
+ // belongs to a run that has NOT ended. Only invalidate when this commit
1415
+ // IS the live active run (its own run, or a non-deferred/live commit
1416
+ // where the snapshot is absent). When _activeRunId is already null there
1417
+ // is nothing live to protect, so skipping the invalidate is a no-op.
1418
+ const commitIsLiveActiveRun =
1419
+ normalizeRunId(this._activeRunId) === normalizeRunId(committedActiveRunId);
1420
+ if (!capturedCommit || commitIsLiveActiveRun) {
1421
+ this._invalidateActiveRun();
1422
+ }
1036
1423
 
1037
1424
  this.emit("message", {
1038
1425
  runId: completedRunId,
@@ -1066,20 +1453,27 @@ class OpenClawClient extends EventEmitter {
1066
1453
  case "error":
1067
1454
  this._logger.error(`[openclaw] Agent lifecycle error: ${JSON.stringify(data)}`);
1068
1455
  {
1069
- const completedRunId = normalizeRunId(runId) || normalizeRunId(this._activeRunId);
1070
- const completedSessionKey =
1071
- normalizeSessionKey(sessionKey) ||
1072
- normalizeSessionKey(this._activeRunSessionKey) ||
1073
- null;
1074
- this._invalidateActiveRun();
1075
- this.emit("error", new Error(data.message || "agent lifecycle error"));
1076
- this.emit("activity", {
1077
- state: "idle",
1078
- sessionKey: completedSessionKey,
1079
- runId: completedRunId || null,
1080
- origin: "lifecycle",
1081
- phase: "error",
1456
+ const terminalActivity = buildTerminalErrorActivity(
1457
+ data,
1458
+ runId || this._activeRunId,
1459
+ sessionKey || this._activeRunSessionKey,
1460
+ "agent_lifecycle_error",
1461
+ );
1462
+ if (terminalActivity.runId && terminalActivity.sessionKey) {
1463
+ this.emit("activity", terminalActivity);
1464
+ }
1465
+ this._timingLedger.recordRunTerminal({
1466
+ runId: terminalActivity.runId,
1082
1467
  });
1468
+ this._invalidateActiveRun();
1469
+ this.emit(
1470
+ "error",
1471
+ buildStructuredError(
1472
+ data,
1473
+ "agent lifecycle error",
1474
+ terminalActivity.code || "agent_lifecycle_error",
1475
+ ),
1476
+ );
1083
1477
  }
1084
1478
  break;
1085
1479
 
@@ -1098,11 +1492,20 @@ class OpenClawClient extends EventEmitter {
1098
1492
 
1099
1493
  // Gateway sends accumulated text (full text so far), not deltas
1100
1494
  if (typeof data.text === "string") {
1495
+ const previousTextLength = this._runTextBuffer.length;
1496
+ const gatewayReceivedAtMs = Date.now();
1497
+ const rawAssistantChars = data.text.length;
1498
+ const assistantDeltaChars = Math.max(0, rawAssistantChars - previousTextLength);
1499
+ const firstGatewayChunk = previousTextLength <= 0;
1101
1500
  this._runTextBuffer = data.text;
1102
1501
  this.emit("streaming", {
1103
1502
  text: data.text,
1104
1503
  sessionKey: this._activeRunSessionKey,
1105
1504
  runId: runId || this._activeRunId || null,
1505
+ gatewayReceivedAtMs,
1506
+ rawAssistantChars,
1507
+ assistantDeltaChars,
1508
+ firstGatewayChunk,
1106
1509
  });
1107
1510
  }
1108
1511
  }
@@ -1130,7 +1533,9 @@ class OpenClawClient extends EventEmitter {
1130
1533
  if (args) activity.args = args;
1131
1534
  if (path) activity.path = path;
1132
1535
  if (typeof data.toolCallId === "string" && data.toolCallId.trim()) {
1133
- activity.activityId = data.toolCallId.trim();
1536
+ const trimmedToolCallId = data.toolCallId.trim();
1537
+ activity.activityId = trimmedToolCallId;
1538
+ activity.toolCallId = trimmedToolCallId;
1134
1539
  }
1135
1540
  if (Number.isFinite(data.seq)) {
1136
1541
  activity.seq = Math.floor(data.seq);
@@ -1155,7 +1560,8 @@ class OpenClawClient extends EventEmitter {
1155
1560
  });
1156
1561
  };
1157
1562
 
1158
- poll();
1563
+ // Let setInterval fire the first poll one interval out so the initial
1564
+ // chat.history fetch doesn't contend with the first streaming chunk landing.
1159
1565
  this._historyActivityPollTimer = setInterval(
1160
1566
  poll,
1161
1567
  HISTORY_ACTIVITY_POLL_INTERVAL_MS,
@@ -1300,6 +1706,7 @@ class OpenClawClient extends EventEmitter {
1300
1706
  summary: extracted.label,
1301
1707
  thinking: extracted.detail,
1302
1708
  thinkingSummarySource: extracted.thinkingSummarySource,
1709
+ thinkingSignatureId: extracted.signatureId || null,
1303
1710
  });
1304
1711
  }
1305
1712
 
@@ -1344,8 +1751,8 @@ class OpenClawClient extends EventEmitter {
1344
1751
 
1345
1752
  // Build connect request params
1346
1753
  const params = {
1347
- minProtocol: PROTOCOL_VERSION,
1348
- maxProtocol: PROTOCOL_VERSION,
1754
+ minProtocol: MIN_PROTOCOL_VERSION,
1755
+ maxProtocol: MAX_PROTOCOL_VERSION,
1349
1756
  client: {
1350
1757
  id: CLIENT_ID,
1351
1758
  version: CLIENT_VERSION,
@@ -1480,7 +1887,7 @@ class OpenClawClient extends EventEmitter {
1480
1887
  const queue = this._eventQueue;
1481
1888
  this._eventQueue = [];
1482
1889
  for (const evt of queue) {
1483
- this._handleAgentEvent(evt.payload);
1890
+ this._handleAgentEvent(evt.payload, evt.capturedCommit);
1484
1891
  }
1485
1892
  }
1486
1893
 
@@ -1493,13 +1900,24 @@ class OpenClawClient extends EventEmitter {
1493
1900
  _scheduleReconnect(delayOverride) {
1494
1901
  if (this._stopped) return;
1495
1902
 
1496
- const delay = typeof delayOverride === "number" ? delayOverride : this._backoffMs;
1903
+ // Capture the current base before advancing so jitter is computed from the
1904
+ // same epoch value that would previously have been used as the raw delay.
1905
+ const base = this._backoffMs;
1497
1906
 
1498
1907
  // Advance backoff for next time (unless overridden)
1499
1908
  if (typeof delayOverride !== "number") {
1500
1909
  this._backoffMs = Math.min(this._backoffMs * 2, 30000);
1501
1910
  }
1502
1911
 
1912
+ // Apply equal jitter to the scheduled delay so concurrent reconnect storms
1913
+ // don't all fire at the same instant. The stored _backoffMs base is NOT
1914
+ // mutated — doubling/cap/reset invariants are preserved.
1915
+ // delayOverride bypasses jitter (used for e.g. shutdown-driven reconnects).
1916
+ const delay =
1917
+ typeof delayOverride === "number"
1918
+ ? delayOverride
1919
+ : Math.floor(base / 2 + Math.random() * (base / 2));
1920
+
1503
1921
  this._logger.info(
1504
1922
  `[openclaw] Reconnecting in ${delay}ms (backoff: ${this._backoffMs}ms)`
1505
1923
  );
@@ -1559,6 +1977,12 @@ class OpenClawClient extends EventEmitter {
1559
1977
 
1560
1978
  _flushPendingErrors(err) {
1561
1979
  for (const [, pending] of this._pending) {
1980
+ // Clear the per-request ACK timeout so a flushed entry cannot fire a
1981
+ // late reject after the map is cleared.
1982
+ if (pending.timer) {
1983
+ clearTimeout(pending.timer);
1984
+ pending.timer = null;
1985
+ }
1562
1986
  pending.reject(err);
1563
1987
  }
1564
1988
  this._pending.clear();