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
@@ -1,3 +1,14 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ buildRateLimitInfoFromSnapshot,
4
+ selectProviderUsageSnapshot,
5
+ } from "./provider-usage-select.js";
6
+ import { stripAllTaggedSpans } from "../domain/tagged-span-strip.js";
7
+ import { parseTaggedSpans } from "../domain/tagged-span-parser.js";
8
+ import { EMOJI_TAG_FAMILY_CONFIG } from "../domain/neural-emoji-reactor-tag-config.js";
9
+ import { PACE_TAG_FAMILY_CONFIG } from "../domain/neural-pace-modulator-tag-config.js";
10
+ import { createSessionContextService } from "./session-context-service.js";
11
+
1
12
  function normalizeLogger(logger) {
2
13
  if (!logger || typeof logger !== "object") {
3
14
  return console;
@@ -13,6 +24,24 @@ function normalizeLogger(logger) {
13
24
 
14
25
  const DEFAULT_MODEL_PROVIDER = "anthropic";
15
26
  const DEFAULT_MODEL_ID = "claude-opus-4-6";
27
+ const POOL_OUTCOME_FRESHNESS_MS = 10 * 60 * 1000;
28
+
29
+ // Dropped from 150 ms to 33 ms after Session 3 memoization (StreamingBuffer.displayedText
30
+ // + MessageScreenPageSliceCache) reduced per-frame client-side cost. Session 1's earlier
31
+ // 33 ms run produced ~15 s of long-tasks per ~10 s of streaming and a 10.8 s probe gap;
32
+ // the Session-3 re-validation must confirm those signals stay near the 150 ms baseline.
33
+ export const STREAMING_REBROADCAST_THROTTLE_MS = 33;
34
+
35
+ function fullMessageText(content) {
36
+ if (typeof content === "string") return content;
37
+ if (Array.isArray(content)) {
38
+ return content
39
+ .filter((b) => b && b.type === "text" && typeof b.text === "string")
40
+ .map((b) => b.text)
41
+ .join("");
42
+ }
43
+ return "";
44
+ }
16
45
 
17
46
  function modelRefKey(provider, model) {
18
47
  return `${provider}/${model}`;
@@ -285,6 +314,8 @@ export function createUpstreamRuntime(opts = {}) {
285
314
  const sessionService = opts.sessionService;
286
315
  const handler = opts.handler;
287
316
  const emitDebug = typeof opts.emitDebug === "function" ? opts.emitDebug : () => {};
317
+ const operationRegistry = opts.operationRegistry || null;
318
+ const now = typeof opts.now === "function" ? opts.now : () => Date.now();
288
319
  const broadcastPages =
289
320
  typeof opts.broadcastPages === "function" ? opts.broadcastPages : () => {};
290
321
  const broadcastStatus =
@@ -293,6 +324,14 @@ export function createUpstreamRuntime(opts = {}) {
293
324
  typeof opts.broadcastActivity === "function"
294
325
  ? opts.broadcastActivity
295
326
  : (activity) => activity;
327
+ const broadcastProviderUsageSnapshot =
328
+ typeof opts.broadcastProviderUsageSnapshot === "function"
329
+ ? opts.broadcastProviderUsageSnapshot
330
+ : () => {};
331
+ const getCurrentSessionModelConfigSnapshot =
332
+ typeof opts.getCurrentSessionModelConfigSnapshot === "function"
333
+ ? opts.getCurrentSessionModelConfigSnapshot
334
+ : () => null;
296
335
  const resetActivityStatusAdapter =
297
336
  typeof opts.resetActivityStatusAdapter === "function"
298
337
  ? opts.resetActivityStatusAdapter
@@ -302,13 +341,162 @@ export function createUpstreamRuntime(opts = {}) {
302
341
  const getVoiceRuntime =
303
342
  typeof opts.getVoiceRuntime === "function" ? opts.getVoiceRuntime : () => null;
304
343
 
344
+ const gatewayUrl = typeof opts.gatewayUrl === "string" ? opts.gatewayUrl : null;
345
+ const gatewayToken = typeof opts.gatewayToken === "string" ? opts.gatewayToken : null;
346
+ // Cap on the encoded data URI length. Real-world OpenClaw avatars from the
347
+ // gateway can be >1 MB PNGs; the original 256 KB cap silently dropped them.
348
+ // 4 MB is generous for a one-shot identity broadcast over loopback/tailnet
349
+ // and is still tight enough to keep status frames from ballooning if a
350
+ // pathologically large image is configured.
351
+ const MAX_AVATAR_DATA_URI_BYTES = 4 * 1024 * 1024;
352
+
353
+ function gatewayHttpOriginFromWsUrl(wsUrl) {
354
+ if (typeof wsUrl !== "string") return null;
355
+ if (wsUrl.startsWith("wss://")) return "https://" + wsUrl.slice("wss://".length);
356
+ if (wsUrl.startsWith("ws://")) return "http://" + wsUrl.slice("ws://".length);
357
+ return null;
358
+ }
359
+
360
+ async function defaultFetchAgentAvatar(agentId, _source) {
361
+ if (!gatewayUrl || !gatewayToken || !agentId) return null;
362
+ const origin = gatewayHttpOriginFromWsUrl(gatewayUrl);
363
+ if (!origin) return null;
364
+ const url = `${origin}/avatar/${encodeURIComponent(agentId)}`;
365
+ const res = await fetch(url, {
366
+ headers: { Authorization: `Bearer ${gatewayToken}` },
367
+ });
368
+ if (!res.ok) return null;
369
+ const contentType = res.headers.get("content-type") || "";
370
+ const buffer = Buffer.from(await res.arrayBuffer());
371
+ return { contentType, body: buffer };
372
+ }
373
+
374
+ const fetchAgentAvatar =
375
+ typeof opts.fetchAgentAvatar === "function" ? opts.fetchAgentAvatar : defaultFetchAgentAvatar;
376
+
377
+ /** @type {Map<string, {dataUri: string, hash: string}>} */
378
+ const avatarCache = new Map();
379
+ /** @type {Map<string, Promise<{dataUri: string, hash: string}|null>>} */
380
+ const inFlightAvatarFetches = new Map();
381
+ /** @type {Map<string, string>} */ // hash → cacheKey
382
+ const avatarHashIndex = new Map();
383
+
384
+ async function resolveAgentAvatar(agentId, avatarSource) {
385
+ if (!agentId || typeof avatarSource !== "string" || !avatarSource) return null;
386
+
387
+ // For data: URIs, decode the base64 payload to compute a hash, but skip the
388
+ // gateway fetch entirely. Same dedupe semantics as remote sources.
389
+ if (avatarSource.startsWith("data:")) {
390
+ const cacheKey = `${agentId}|${avatarSource}`;
391
+ const cached = avatarCache.get(cacheKey);
392
+ if (cached) return cached;
393
+ const commaIndex = avatarSource.indexOf(",");
394
+ if (commaIndex < 0) return null;
395
+ if (avatarSource.length > MAX_AVATAR_DATA_URI_BYTES) {
396
+ emitDebug(
397
+ "relay.session",
398
+ "agent_avatar_resolve_dropped",
399
+ "warn",
400
+ { sessionKey: sessionService.ensureSessionKey() },
401
+ () => ({
402
+ reason: "oversize",
403
+ agentId,
404
+ dataUriBytes: avatarSource.length,
405
+ capBytes: MAX_AVATAR_DATA_URI_BYTES,
406
+ }),
407
+ );
408
+ return null;
409
+ }
410
+ const base64 = avatarSource.slice(commaIndex + 1);
411
+ const buffer = Buffer.from(base64, "base64");
412
+ const hash = createHash("sha256").update(buffer).digest("hex");
413
+ const entry = { dataUri: avatarSource, hash };
414
+ avatarCache.set(cacheKey, entry);
415
+ avatarHashIndex.set(hash, cacheKey);
416
+ return entry;
417
+ }
418
+
419
+ const cacheKey = `${agentId}|${avatarSource}`;
420
+ if (avatarCache.has(cacheKey)) return avatarCache.get(cacheKey);
421
+ if (inFlightAvatarFetches.has(cacheKey)) return inFlightAvatarFetches.get(cacheKey);
422
+
423
+ const promise = (async () => {
424
+ try {
425
+ const result = await fetchAgentAvatar(agentId, avatarSource);
426
+ if (!result || !result.body) {
427
+ emitDebug(
428
+ "relay.session",
429
+ "agent_avatar_resolve_dropped",
430
+ "warn",
431
+ { sessionKey: sessionService.ensureSessionKey() },
432
+ () => ({ reason: "empty_response", agentId }),
433
+ );
434
+ return null;
435
+ }
436
+ const contentType = String(result.contentType || "").toLowerCase();
437
+ if (!contentType.startsWith("image/")) {
438
+ emitDebug(
439
+ "relay.session",
440
+ "agent_avatar_resolve_dropped",
441
+ "warn",
442
+ { sessionKey: sessionService.ensureSessionKey() },
443
+ () => ({ reason: "non_image_content_type", agentId, contentType }),
444
+ );
445
+ return null;
446
+ }
447
+ const buffer = Buffer.isBuffer(result.body) ? result.body : Buffer.from(result.body);
448
+ const base64 = buffer.toString("base64");
449
+ const dataUri = `data:${contentType};base64,${base64}`;
450
+ if (dataUri.length > MAX_AVATAR_DATA_URI_BYTES) {
451
+ emitDebug(
452
+ "relay.session",
453
+ "agent_avatar_resolve_dropped",
454
+ "warn",
455
+ { sessionKey: sessionService.ensureSessionKey() },
456
+ () => ({
457
+ reason: "oversize",
458
+ agentId,
459
+ dataUriBytes: dataUri.length,
460
+ capBytes: MAX_AVATAR_DATA_URI_BYTES,
461
+ }),
462
+ );
463
+ return null;
464
+ }
465
+ const hash = createHash("sha256").update(buffer).digest("hex");
466
+ const entry = { dataUri, hash };
467
+ avatarCache.set(cacheKey, entry);
468
+ avatarHashIndex.set(hash, cacheKey);
469
+ return entry;
470
+ } catch (err) {
471
+ emitDebug(
472
+ "relay.session",
473
+ "agent_avatar_resolve_failed",
474
+ "warn",
475
+ { sessionKey: sessionService.ensureSessionKey() },
476
+ () => ({ message: err && err.message ? err.message : String(err) }),
477
+ );
478
+ return null;
479
+ } finally {
480
+ inFlightAvatarFetches.delete(cacheKey);
481
+ }
482
+ })();
483
+
484
+ inFlightAvatarFetches.set(cacheKey, promise);
485
+ return promise;
486
+ }
487
+
305
488
  const modelsCacheTtlMs =
306
489
  Number.isFinite(opts.modelsCacheTtlMs) && opts.modelsCacheTtlMs > 0
307
490
  ? Math.floor(opts.modelsCacheTtlMs)
308
491
  : 300000;
492
+ const providerUsageCacheTtlMs =
493
+ Number.isFinite(opts.providerUsageCacheTtlMs) && opts.providerUsageCacheTtlMs > 0
494
+ ? Math.floor(opts.providerUsageCacheTtlMs)
495
+ : 60000;
309
496
 
310
497
  let openclawConnected = false;
311
- let agentName = null;
498
+ /** @type {{name: string|null, emoji: string|null, avatarDataUri: string|null, avatarHash: string|null}} */
499
+ let agentIdentity = { name: null, emoji: null, avatarDataUri: null, avatarHash: null };
312
500
  /** @type {Array<{provider: string, id: string, name: string, contextWindow?: number, reasoning?: boolean}>|null} */
313
501
  let cachedModelsCatalog = null;
314
502
  let cachedModelsCatalogFetchedAt = 0;
@@ -321,14 +509,44 @@ export function createUpstreamRuntime(opts = {}) {
321
509
  let cachedSkillsCatalogStale = true;
322
510
  /** @type {Promise<{skills: Array, fetchedAtMs: number, stale: boolean}>|null} */
323
511
  let inFlightSkillsCatalogFetch = null;
512
+ let cachedProviderUsageSummary = null;
513
+ let cachedProviderUsageFetchedAt = 0;
514
+ let cachedProviderUsageObservedAt = 0;
515
+ let cachedProviderUsageStale = true;
516
+ /** @type {Promise<object>|null} */
517
+ let inFlightProviderUsageFetch = null;
518
+ const providerOutcomeState = new Map();
519
+ const cachedAuthProfileCounts = new Map();
324
520
  const upstreamRunPipeline = new Map();
325
521
  let streamingThrottleTimer = null;
326
- let pendingStreamingText = null;
522
+ let pendingStreaming = null;
523
+ /** @type {{runId: string, sessionKey: string}|null} */
524
+ let activeTyping = null;
327
525
  let bootstrapRefreshTimer = null;
328
526
  let bootstrapRefreshNonce = 0;
329
527
 
330
528
  function getAgentName() {
331
- return agentName;
529
+ return agentIdentity.name;
530
+ }
531
+
532
+ function getAgentEmoji() {
533
+ return agentIdentity.emoji;
534
+ }
535
+
536
+ function getAgentAvatarDataUri() {
537
+ return agentIdentity.avatarDataUri;
538
+ }
539
+
540
+ function getAgentAvatarHash() {
541
+ return agentIdentity.avatarHash;
542
+ }
543
+
544
+ function getAgentAvatarDataUriByHash(hash) {
545
+ if (typeof hash !== "string" || !hash) return null;
546
+ const cacheKey = avatarHashIndex.get(hash);
547
+ if (!cacheKey) return null;
548
+ const entry = avatarCache.get(cacheKey);
549
+ return entry ? entry.dataUri : null;
332
550
  }
333
551
 
334
552
  function isConnected() {
@@ -374,6 +592,31 @@ export function createUpstreamRuntime(opts = {}) {
374
592
  }),
375
593
  );
376
594
  });
595
+ refreshProviderUsage(true).then((snapshot) => {
596
+ emitDebug(
597
+ "relay.session",
598
+ "provider_usage_prefetched",
599
+ "info",
600
+ { sessionKey: sessionService.ensureSessionKey() },
601
+ () => ({
602
+ hasProvider: !!(snapshot && snapshot.provider),
603
+ stale: !!(snapshot && snapshot.stale),
604
+ trigger,
605
+ }),
606
+ );
607
+ });
608
+ sessionService.getCurrentSessionModelConfig().then((config) => {
609
+ emitDebug(
610
+ "relay.session",
611
+ "session_model_config_prefetched",
612
+ "info",
613
+ { sessionKey: sessionService.ensureSessionKey() },
614
+ () => ({
615
+ hasProvider: !!(config && config.modelProvider),
616
+ trigger,
617
+ }),
618
+ );
619
+ });
377
620
  }
378
621
 
379
622
  function applyConnectedStatus(connected, trigger, emitTransportEvent = true) {
@@ -381,10 +624,27 @@ export function createUpstreamRuntime(opts = {}) {
381
624
  openclawConnected = !!connected;
382
625
  sessionService.handleUpstreamStatusChange(openclawConnected);
383
626
  if (!openclawConnected) {
627
+ clearTyping("upstream_disconnected");
384
628
  inFlightModelsCatalogFetch = null;
385
629
  inFlightSkillsCatalogFetch = null;
630
+ inFlightProviderUsageFetch = null;
386
631
  cachedSkillsCatalogStale = true;
632
+ cachedProviderUsageStale = true;
387
633
  resetActivityStatusAdapter();
634
+ // Drop the identity-tier overlays so the WebUI brand slot reverts to the
635
+ // OcuClaw mark while disconnected (spec 2026-04-27).
636
+ if (
637
+ agentIdentity.emoji != null ||
638
+ agentIdentity.avatarDataUri != null ||
639
+ agentIdentity.avatarHash != null
640
+ ) {
641
+ agentIdentity = {
642
+ ...agentIdentity,
643
+ emoji: null,
644
+ avatarDataUri: null,
645
+ avatarHash: null,
646
+ };
647
+ }
388
648
  } else if (!wasConnected) {
389
649
  onConnectedStateEstablished(trigger);
390
650
  }
@@ -403,6 +663,85 @@ export function createUpstreamRuntime(opts = {}) {
403
663
  broadcastStatus();
404
664
  }
405
665
 
666
+ function applyAgentIdentity(identity, source) {
667
+ const agentId =
668
+ identity && typeof identity.agentId === "string" && identity.agentId ? identity.agentId : null;
669
+ const avatarSource =
670
+ identity && typeof identity.avatar === "string" && identity.avatar ? identity.avatar : null;
671
+ // Data: URIs are pass-through per spec — set them on the initial broadcast.
672
+ // For data: URIs the hash is cheap and synchronous — compute it now so the
673
+ // first status broadcast carries both fields. For remote avatars the hash
674
+ // arrives via the resolveAgentAvatar(...).then(...) settle below, after the
675
+ // initial broadcast has gone out with hash:null.
676
+ const inlineAvatarDataUri =
677
+ avatarSource && avatarSource.startsWith("data:") ? avatarSource : null;
678
+ let inlineAvatarHash = null;
679
+ if (agentId && inlineAvatarDataUri) {
680
+ const cacheKey = `${agentId}|${inlineAvatarDataUri}`;
681
+ const cached = avatarCache.get(cacheKey);
682
+ if (cached) {
683
+ inlineAvatarHash = cached.hash;
684
+ } else if (inlineAvatarDataUri.length <= MAX_AVATAR_DATA_URI_BYTES) {
685
+ const commaIndex = inlineAvatarDataUri.indexOf(",");
686
+ if (commaIndex >= 0) {
687
+ const buffer = Buffer.from(inlineAvatarDataUri.slice(commaIndex + 1), "base64");
688
+ inlineAvatarHash = createHash("sha256").update(buffer).digest("hex");
689
+ const entry = { dataUri: inlineAvatarDataUri, hash: inlineAvatarHash };
690
+ avatarCache.set(cacheKey, entry);
691
+ avatarHashIndex.set(inlineAvatarHash, cacheKey);
692
+ }
693
+ }
694
+ }
695
+ const next = {
696
+ name: identity && typeof identity.name === "string" && identity.name ? identity.name : null,
697
+ emoji: identity && typeof identity.emoji === "string" && identity.emoji ? identity.emoji : null,
698
+ avatarDataUri: inlineAvatarDataUri,
699
+ avatarHash: inlineAvatarHash,
700
+ };
701
+ agentIdentity = next;
702
+ conversationState.setAgentName(next.name || "Agent");
703
+ emitDebug(
704
+ "relay.session",
705
+ "agent_identity_applied",
706
+ "info",
707
+ { sessionKey: sessionService.ensureSessionKey() },
708
+ () => ({
709
+ source,
710
+ hasName: !!next.name,
711
+ hasEmoji: !!next.emoji,
712
+ hasAvatarSource: !!avatarSource,
713
+ avatarInline: !!inlineAvatarDataUri,
714
+ }),
715
+ );
716
+ broadcastStatus();
717
+
718
+ if (!agentId || !avatarSource) return;
719
+ const generationName = next.name;
720
+ const generationEmoji = next.emoji;
721
+ resolveAgentAvatar(agentId, avatarSource).then((resolved) => {
722
+ // Drop if the identity changed underneath us.
723
+ if (
724
+ agentIdentity.name !== generationName ||
725
+ agentIdentity.emoji !== generationEmoji
726
+ ) {
727
+ return;
728
+ }
729
+ if (!resolved) return;
730
+ if (
731
+ agentIdentity.avatarDataUri === resolved.dataUri &&
732
+ agentIdentity.avatarHash === resolved.hash
733
+ ) {
734
+ return;
735
+ }
736
+ agentIdentity = {
737
+ ...agentIdentity,
738
+ avatarDataUri: resolved.dataUri,
739
+ avatarHash: resolved.hash,
740
+ };
741
+ broadcastStatus();
742
+ });
743
+ }
744
+
406
745
  async function refreshUpstreamBootstrap(trigger, attempt = 0) {
407
746
  const refreshNonce = ++bootstrapRefreshNonce;
408
747
  clearBootstrapRefreshTimer();
@@ -419,21 +758,7 @@ export function createUpstreamRuntime(opts = {}) {
419
758
  if (statusOk || identityOk) {
420
759
  applyConnectedStatus(true, `${trigger}_bootstrap`, !statusOk);
421
760
  if (identityOk) {
422
- const identity = identityResult.value;
423
- agentName = identity && identity.name ? identity.name : null;
424
- conversationState.setAgentName(agentName || "Agent");
425
- emitDebug(
426
- "relay.session",
427
- "agent_identity_bootstrap",
428
- "info",
429
- { sessionKey: sessionService.ensureSessionKey() },
430
- () => ({
431
- hasName: !!agentName,
432
- trigger,
433
- attempt,
434
- }),
435
- );
436
- broadcastStatus();
761
+ applyAgentIdentity(identityResult.value, `${trigger}_bootstrap`);
437
762
  }
438
763
  return;
439
764
  }
@@ -467,22 +792,98 @@ export function createUpstreamRuntime(opts = {}) {
467
792
  }
468
793
 
469
794
  function flushPendingStreamingText() {
470
- if (!pendingStreamingText) return;
795
+ if (!pendingStreaming) return;
796
+ const queuedStreaming = pendingStreaming;
797
+ pendingStreaming = null;
471
798
  const server = getServer();
472
799
  if (server) {
473
- server.broadcast(handler.formatStreaming(pendingStreamingText));
800
+ const runId = queuedStreaming.runId || null;
801
+ if (runId) {
802
+ stopTypingForRun(runId, "first_visible_assistant_progress");
803
+ emitDebug(
804
+ "openclaw.message",
805
+ "agent_first_chunk",
806
+ "info",
807
+ { sessionKey: queuedStreaming.sessionKey || sessionService.ensureSessionKey() },
808
+ () => ({ runId }),
809
+ );
810
+ }
811
+ // Parse + markdown the latest cumulative text here, not per chunk: only
812
+ // the surviving (non-superseded) buffer reaches the parser.
813
+ const parsedSpans = parseTaggedSpans(queuedStreaming.rawText, [
814
+ EMOJI_TAG_FAMILY_CONFIG,
815
+ PACE_TAG_FAMILY_CONFIG,
816
+ ]);
817
+ const { text, spansByFamily } = applyMarkdownWithSpans(
818
+ {
819
+ cleanText: parsedSpans.cleanText,
820
+ spansByFamily: parsedSpans.spansByFamily,
821
+ trailingPartialTag: parsedSpans.trailingPartialTag,
822
+ },
823
+ queuedStreaming.prefix,
824
+ conversationState,
825
+ );
826
+ const emojiSpans = spansByFamily.emoji || [];
827
+ const paceSpans = spansByFamily.pace || [];
828
+ server.broadcast(
829
+ handler.formatStreaming(text, emojiSpans, paceSpans),
830
+ );
831
+ const now = Date.now();
832
+ const runPipeline = runId ? upstreamRunPipeline.get(runId) : null;
833
+ const firstRelayBroadcast = runPipeline
834
+ ? !runPipeline.firstRelayBroadcastAt
835
+ : queuedStreaming.flushReason === "first_immediate";
836
+ if (runPipeline && !runPipeline.firstRelayBroadcastAt) {
837
+ runPipeline.firstRelayBroadcastAt = now;
838
+ runPipeline.firstRelayBroadcastChars = text.length;
839
+ }
840
+ emitDebug(
841
+ "relay.protocol",
842
+ "streaming_rebroadcast",
843
+ "debug",
844
+ {
845
+ sessionKey: queuedStreaming.sessionKey || sessionService.ensureSessionKey(),
846
+ runId,
847
+ },
848
+ () => ({
849
+ reason: queuedStreaming.flushReason || "throttled_flush",
850
+ rebroadcastChars: text.length,
851
+ rawAssistantChars: queuedStreaming.rawAssistantChars,
852
+ assistantDeltaChars: queuedStreaming.assistantDeltaChars,
853
+ firstGatewayChunk:
854
+ typeof queuedStreaming.firstGatewayChunk === "boolean"
855
+ ? queuedStreaming.firstGatewayChunk
856
+ : null,
857
+ firstRelayBroadcast,
858
+ gatewayReceivedAtMs: queuedStreaming.gatewayReceivedAtMs,
859
+ gatewayToRebroadcastMs:
860
+ Number.isFinite(queuedStreaming.gatewayReceivedAtMs)
861
+ ? (now - queuedStreaming.gatewayReceivedAtMs)
862
+ : null,
863
+ sendToRebroadcastMs: runPipeline ? (now - runPipeline.sendStartedAt) : null,
864
+ ackToRebroadcastMs:
865
+ runPipeline && runPipeline.ackAt ? (now - runPipeline.ackAt) : null,
866
+ runStartToRebroadcastMs:
867
+ runPipeline && runPipeline.lifecycleStartAt
868
+ ? (now - runPipeline.lifecycleStartAt)
869
+ : null,
870
+ firstStreamingToRebroadcastMs:
871
+ runPipeline && runPipeline.firstStreamingAt
872
+ ? (now - runPipeline.firstStreamingAt)
873
+ : null,
874
+ }),
875
+ );
474
876
  }
475
- pendingStreamingText = null;
476
877
  }
477
878
 
478
879
  function modelCatalogSnapshot(nowMs) {
479
- const now = Number.isFinite(nowMs) ? nowMs : Date.now();
880
+ const currentNow = Number.isFinite(nowMs) ? nowMs : now();
480
881
  const hasCache = Array.isArray(cachedModelsCatalog);
481
- const ageMs = hasCache ? now - cachedModelsCatalogFetchedAt : Infinity;
882
+ const ageMs = hasCache ? currentNow - cachedModelsCatalogFetchedAt : Infinity;
482
883
  const ttlExpired = ageMs >= modelsCacheTtlMs;
483
884
  return {
484
885
  models: hasCache ? cachedModelsCatalog : [],
485
- fetchedAtMs: hasCache ? cachedModelsCatalogFetchedAt : now,
886
+ fetchedAtMs: hasCache ? cachedModelsCatalogFetchedAt : currentNow,
486
887
  stale: !hasCache || cachedModelsCatalogStale || ttlExpired,
487
888
  };
488
889
  }
@@ -491,17 +892,17 @@ export function createUpstreamRuntime(opts = {}) {
491
892
  cachedModelsCatalog = Array.isArray(models) ? models : [];
492
893
  cachedModelsCatalogFetchedAt = Number.isFinite(fetchedAtMs)
493
894
  ? Math.floor(fetchedAtMs)
494
- : Date.now();
895
+ : now();
495
896
  cachedModelsCatalogStale = !!stale;
496
897
  return modelCatalogSnapshot(cachedModelsCatalogFetchedAt);
497
898
  }
498
899
 
499
900
  function skillsCatalogSnapshot(nowMs) {
500
- const now = Number.isFinite(nowMs) ? nowMs : Date.now();
901
+ const currentNow = Number.isFinite(nowMs) ? nowMs : now();
501
902
  const hasCache = Array.isArray(cachedSkillsCatalog);
502
903
  return {
503
904
  skills: hasCache ? cachedSkillsCatalog : [],
504
- fetchedAtMs: hasCache ? cachedSkillsCatalogFetchedAt : now,
905
+ fetchedAtMs: hasCache ? cachedSkillsCatalogFetchedAt : currentNow,
505
906
  stale: !hasCache || cachedSkillsCatalogStale,
506
907
  };
507
908
  }
@@ -510,11 +911,196 @@ export function createUpstreamRuntime(opts = {}) {
510
911
  cachedSkillsCatalog = Array.isArray(skills) ? skills : [];
511
912
  cachedSkillsCatalogFetchedAt = Number.isFinite(fetchedAtMs)
512
913
  ? Math.floor(fetchedAtMs)
513
- : Date.now();
914
+ : now();
514
915
  cachedSkillsCatalogStale = !!stale;
515
916
  return skillsCatalogSnapshot(cachedSkillsCatalogFetchedAt);
516
917
  }
517
918
 
919
+ function providerUsageCacheState(nowMs) {
920
+ const currentNow = Number.isFinite(nowMs) ? nowMs : now();
921
+ const hasCache =
922
+ cachedProviderUsageSummary &&
923
+ typeof cachedProviderUsageSummary === "object" &&
924
+ !Array.isArray(cachedProviderUsageSummary);
925
+ const ageMs = hasCache ? currentNow - cachedProviderUsageObservedAt : Infinity;
926
+ const ttlExpired = ageMs >= providerUsageCacheTtlMs;
927
+ return {
928
+ summary: hasCache ? cachedProviderUsageSummary : null,
929
+ fetchedAtMs: hasCache ? cachedProviderUsageFetchedAt : currentNow,
930
+ stale: !hasCache || cachedProviderUsageStale || ttlExpired,
931
+ };
932
+ }
933
+
934
+ function activeProviderContext() {
935
+ const sessionKey = sessionService.ensureSessionKey();
936
+ const config = getCurrentSessionModelConfigSnapshot();
937
+ const configMatchesActiveSession =
938
+ !!config &&
939
+ (
940
+ typeof sessionService.isCurrentSession === "function"
941
+ ? sessionService.isCurrentSession(config.sessionKey)
942
+ : config.sessionKey === sessionKey
943
+ );
944
+ return {
945
+ sessionKey,
946
+ provider: normalizeProviderId(
947
+ configMatchesActiveSession ? config && config.modelProvider : null,
948
+ ),
949
+ };
950
+ }
951
+
952
+ function emptyProviderUsageSnapshot(nowMs, stale = true) {
953
+ const { sessionKey, provider } = activeProviderContext();
954
+ const fallbackFetchedAtMs = Number.isFinite(nowMs) ? Math.floor(nowMs) : now();
955
+ return {
956
+ sessionKey: sessionKey || null,
957
+ provider: provider || null,
958
+ displayName: null,
959
+ limitingWindowKey: null,
960
+ windows: [],
961
+ fetchedAtMs: fallbackFetchedAtMs,
962
+ stale: !!stale,
963
+ poolStatus: computePoolStatus(provider, fallbackFetchedAtMs),
964
+ totalProfileCount: cachedAuthProfileCounts.has(provider)
965
+ ? cachedAuthProfileCounts.get(provider)
966
+ : null,
967
+ };
968
+ }
969
+
970
+ function computePoolStatus(provider, nowMs) {
971
+ if (!provider) return "unknown";
972
+ const entry = providerOutcomeState.get(provider);
973
+ if (!entry) return "unknown";
974
+ const ageMs = nowMs - entry.lastOutcomeAtMs;
975
+ if (ageMs >= POOL_OUTCOME_FRESHNESS_MS) return "unknown";
976
+ return entry.lastOutcome;
977
+ }
978
+
979
+ function projectProviderUsageSnapshot(nowMs) {
980
+ const currentNow = Number.isFinite(nowMs) ? nowMs : now();
981
+ const cacheState = providerUsageCacheState(currentNow);
982
+ if (!cacheState.summary) {
983
+ return emptyProviderUsageSnapshot(cacheState.fetchedAtMs);
984
+ }
985
+ const { sessionKey, provider } = activeProviderContext();
986
+ const projected = selectProviderUsageSnapshot(cacheState.summary, {
987
+ sessionKey,
988
+ provider,
989
+ stale: cacheState.stale,
990
+ });
991
+ if (!projected) {
992
+ return emptyProviderUsageSnapshot(cacheState.fetchedAtMs, cacheState.stale);
993
+ }
994
+ return {
995
+ ...projected,
996
+ poolStatus: computePoolStatus(provider, currentNow),
997
+ totalProfileCount: cachedAuthProfileCounts.has(provider)
998
+ ? cachedAuthProfileCounts.get(provider)
999
+ : null,
1000
+ };
1001
+ }
1002
+
1003
+ async function fetchAuthProfileCounts() {
1004
+ const result = await gatewayBridge.request("models.authStatus", {});
1005
+ const next = new Map();
1006
+ const providers = Array.isArray(result && result.providers) ? result.providers : [];
1007
+ for (const entry of providers) {
1008
+ const provider = normalizeProviderId(entry && entry.provider);
1009
+ if (!provider) continue;
1010
+ const profiles = Array.isArray(entry && entry.profiles) ? entry.profiles : [];
1011
+ let count = 0;
1012
+ for (const profile of profiles) {
1013
+ const type = typeof profile?.type === "string" ? profile.type.trim().toLowerCase() : "";
1014
+ if (type === "oauth" || type === "token") count += 1;
1015
+ }
1016
+ next.set(provider, count);
1017
+ }
1018
+ return next;
1019
+ }
1020
+
1021
+ async function refreshProviderUsage(force) {
1022
+ const snapshot = projectProviderUsageSnapshot();
1023
+ if (!force && !snapshot.stale) {
1024
+ return snapshot;
1025
+ }
1026
+ if (inFlightProviderUsageFetch) {
1027
+ return inFlightProviderUsageFetch;
1028
+ }
1029
+ if (!openclawConnected) {
1030
+ return snapshot;
1031
+ }
1032
+
1033
+ inFlightProviderUsageFetch = (async () => {
1034
+ const [usageResult, authStatusResult] = await Promise.allSettled([
1035
+ gatewayBridge.request("usage.status", {}),
1036
+ fetchAuthProfileCounts(),
1037
+ ]);
1038
+
1039
+ if (authStatusResult.status === "fulfilled") {
1040
+ cachedAuthProfileCounts.clear();
1041
+ for (const [provider, count] of authStatusResult.value) {
1042
+ cachedAuthProfileCounts.set(provider, count);
1043
+ }
1044
+ } else {
1045
+ // Intentionally do NOT clear cachedAuthProfileCounts on rejection.
1046
+ // Retain-on-failure preserves the caption count across transient blips;
1047
+ // the next successful fetch overwrites. See spec
1048
+ // 2026-05-07-rate-limit-pool-status-design.md (authStatus retain-on-failure).
1049
+ emitDebug(
1050
+ "relay.session",
1051
+ "models_auth_status_refresh_failed",
1052
+ "warn",
1053
+ { sessionKey: sessionService.ensureSessionKey() },
1054
+ () => ({
1055
+ message: authStatusResult.reason && authStatusResult.reason.message
1056
+ ? authStatusResult.reason.message
1057
+ : String(authStatusResult.reason),
1058
+ hadCount: cachedAuthProfileCounts.size > 0,
1059
+ }),
1060
+ );
1061
+ }
1062
+
1063
+ if (usageResult.status === "fulfilled") {
1064
+ const result = usageResult.value;
1065
+ cachedProviderUsageSummary =
1066
+ result && typeof result === "object" && !Array.isArray(result) ? result : {};
1067
+ cachedProviderUsageObservedAt = now();
1068
+ cachedProviderUsageFetchedAt =
1069
+ Number.isFinite(result && result.updatedAt)
1070
+ ? Math.floor(result.updatedAt)
1071
+ : cachedProviderUsageObservedAt;
1072
+ cachedProviderUsageStale = false;
1073
+ const refreshedSnapshot = projectProviderUsageSnapshot(cachedProviderUsageObservedAt);
1074
+ broadcastProviderUsageSnapshot(refreshedSnapshot);
1075
+ return refreshedSnapshot;
1076
+ }
1077
+
1078
+ const err = usageResult.reason;
1079
+ emitDebug(
1080
+ "relay.session",
1081
+ "provider_usage_refresh_failed",
1082
+ "warn",
1083
+ { sessionKey: sessionService.ensureSessionKey() },
1084
+ () => ({
1085
+ message: err && err.message ? err.message : String(err),
1086
+ hadCache: !!providerUsageCacheState().summary,
1087
+ }),
1088
+ );
1089
+ cachedProviderUsageStale = true;
1090
+ if (!providerUsageCacheState().summary) {
1091
+ cachedProviderUsageObservedAt = now();
1092
+ cachedProviderUsageFetchedAt = cachedProviderUsageObservedAt;
1093
+ }
1094
+ const refreshedSnapshot = projectProviderUsageSnapshot();
1095
+ broadcastProviderUsageSnapshot(refreshedSnapshot);
1096
+ return refreshedSnapshot;
1097
+ })();
1098
+
1099
+ return inFlightProviderUsageFetch.finally(() => {
1100
+ inFlightProviderUsageFetch = null;
1101
+ });
1102
+ }
1103
+
518
1104
  async function refreshModelCatalog(force) {
519
1105
  const snapshot = modelCatalogSnapshot();
520
1106
  if (!force && !snapshot.stale) {
@@ -550,7 +1136,7 @@ export function createUpstreamRuntime(opts = {}) {
550
1136
  cachedModelsCatalogStale = true;
551
1137
  return modelCatalogSnapshot();
552
1138
  }
553
- return cacheModelCatalog([], Date.now(), true);
1139
+ return cacheModelCatalog([], now(), true);
554
1140
  });
555
1141
 
556
1142
  return inFlightModelsCatalogFetch.finally(() => {
@@ -591,7 +1177,7 @@ export function createUpstreamRuntime(opts = {}) {
591
1177
  cachedSkillsCatalogStale = true;
592
1178
  return skillsCatalogSnapshot();
593
1179
  }
594
- return cacheSkillsCatalog([], Date.now(), true);
1180
+ return cacheSkillsCatalog([], now(), true);
595
1181
  });
596
1182
 
597
1183
  return inFlightSkillsCatalogFetch.finally(() => {
@@ -609,6 +1195,10 @@ export function createUpstreamRuntime(opts = {}) {
609
1195
  ackAt: entry.ackAt || Date.now(),
610
1196
  lifecycleStartAt: null,
611
1197
  firstStreamingAt: null,
1198
+ firstGatewayReceivedAt: null,
1199
+ firstGatewayChars: null,
1200
+ firstRelayBroadcastAt: null,
1201
+ firstRelayBroadcastChars: null,
612
1202
  });
613
1203
  }
614
1204
 
@@ -628,6 +1218,40 @@ export function createUpstreamRuntime(opts = {}) {
628
1218
  return snapshot;
629
1219
  }
630
1220
 
1221
+ async function getProviderUsageSnapshot() {
1222
+ const snapshot = projectProviderUsageSnapshot();
1223
+ if (snapshot.stale && openclawConnected) {
1224
+ return refreshProviderUsage(true);
1225
+ }
1226
+ return snapshot;
1227
+ }
1228
+
1229
+ async function handleCurrentSessionModelConfigChanged() {
1230
+ // The session-context window is keyed by the active model, which only
1231
+ // becomes known once the model config resolves. On connect that resolution
1232
+ // happens AFTER the initial connect-time context refresh has already run
1233
+ // with a null model key, so a cached/persisted window for a cold (pre-turn)
1234
+ // session would otherwise never be applied until the first turn warms the
1235
+ // gateway's window. Re-refresh now that the model key is known so the
1236
+ // cached window surfaces immediately; fire-and-forget so it doesn't block
1237
+ // the provider-usage rebroadcast. Also keeps context correct across model
1238
+ // switches.
1239
+ sessionContextService.refreshActiveSessionContext().catch(() => {});
1240
+
1241
+ const snapshot = projectProviderUsageSnapshot();
1242
+ if (snapshot.stale && openclawConnected) {
1243
+ return refreshProviderUsage(true);
1244
+ }
1245
+ broadcastProviderUsageSnapshot(snapshot);
1246
+ return snapshot;
1247
+ }
1248
+
1249
+ async function handleCurrentSessionModelConfigCleared() {
1250
+ const snapshot = emptyProviderUsageSnapshot(now(), false);
1251
+ broadcastProviderUsageSnapshot(snapshot);
1252
+ return snapshot;
1253
+ }
1254
+
631
1255
  function handleSessionChanged(trigger) {
632
1256
  if (!openclawConnected) {
633
1257
  cachedSkillsCatalogStale = true;
@@ -638,6 +1262,76 @@ export function createUpstreamRuntime(opts = {}) {
638
1262
  });
639
1263
  }
640
1264
 
1265
+ function normalizeGatewayTimingEvent(raw) {
1266
+ const source = raw && typeof raw === "object" ? raw : {};
1267
+ const category =
1268
+ source.category === "openclaw.run" || source.category === "relay.protocol"
1269
+ ? source.category
1270
+ : "relay.protocol";
1271
+ const event =
1272
+ typeof source.event === "string" && source.event.trim()
1273
+ ? source.event.trim()
1274
+ : "gateway_timing";
1275
+ const severity =
1276
+ source.severity === "debug" ||
1277
+ source.severity === "info" ||
1278
+ source.severity === "warn" ||
1279
+ source.severity === "error"
1280
+ ? source.severity
1281
+ : "debug";
1282
+ const context = source.context && typeof source.context === "object"
1283
+ ? source.context
1284
+ : {};
1285
+ const data = source.data && typeof source.data === "object"
1286
+ ? source.data
1287
+ : {};
1288
+ return {
1289
+ category,
1290
+ event,
1291
+ severity,
1292
+ context: {
1293
+ sessionKey:
1294
+ typeof context.sessionKey === "string" && context.sessionKey.trim()
1295
+ ? context.sessionKey.trim()
1296
+ : sessionService.ensureSessionKey(),
1297
+ runId:
1298
+ typeof context.runId === "string" && context.runId.trim()
1299
+ ? context.runId.trim()
1300
+ : null,
1301
+ },
1302
+ data,
1303
+ };
1304
+ }
1305
+
1306
+ // Session-context service: tracks contextTokens/contextWindow and broadcasts
1307
+ // snapshots to connected WebUI clients. session.switch.applied trigger is
1308
+ // deferred (see Task 4 plan notes — the switchSession path lives in
1309
+ // downstream-handler.ts and is out of scope for this task).
1310
+ let cachedRunActiveSessionKey = null;
1311
+ // contextWindow is NOT resolved on a fresh session (the gateway warms a
1312
+ // model's window only after the first turn). The service caches the observed
1313
+ // window per model and reuses it for cold/new-session reads, keyed by the
1314
+ // active session's resolved provider/model.
1315
+ const sessionContextService = createSessionContextService({
1316
+ gatewayBridge,
1317
+ stateDir: opts.stateDir,
1318
+ getActiveSessionKey: () => sessionService.ensureSessionKey() || null,
1319
+ getActiveModelKey: () => {
1320
+ const config = getCurrentSessionModelConfigSnapshot();
1321
+ if (!config) return null;
1322
+ const provider = normalizeProviderId(config.modelProvider);
1323
+ const model = typeof config.model === "string" ? config.model.trim() : "";
1324
+ if (!provider || !model) return null;
1325
+ return modelRefKey(provider, model);
1326
+ },
1327
+ getRunActive: () => !!cachedRunActiveSessionKey,
1328
+ nowMs: () => Date.now(),
1329
+ broadcast: (frame) => {
1330
+ const server = getServer();
1331
+ if (server) server.broadcast(JSON.stringify(frame));
1332
+ },
1333
+ });
1334
+
641
1335
  gatewayBridge.on("history", (data) => {
642
1336
  if (!sessionService.isCurrentSession(data.sessionKey)) return;
643
1337
  emitDebug(
@@ -649,7 +1343,14 @@ export function createUpstreamRuntime(opts = {}) {
649
1343
  messageCount: Array.isArray(data.messages) ? data.messages.length : 0,
650
1344
  }),
651
1345
  );
652
- conversationState.hydrate(data.messages, agentName);
1346
+ const sanitizedMessages = Array.isArray(data.messages)
1347
+ ? data.messages.map((msg) =>
1348
+ msg && msg.role === "assistant"
1349
+ ? { ...msg, content: sanitizeAssistantContentBlocks(msg.content) }
1350
+ : msg,
1351
+ )
1352
+ : data.messages;
1353
+ conversationState.hydrate(sanitizedMessages, agentIdentity.name);
653
1354
  broadcastPages();
654
1355
  });
655
1356
 
@@ -688,12 +1389,148 @@ export function createUpstreamRuntime(opts = {}) {
688
1389
  );
689
1390
  });
690
1391
 
1392
+ function sanitizeAssistantContentBlocks(content) {
1393
+ if (typeof content === "string") {
1394
+ return stripAllTaggedSpans(content);
1395
+ }
1396
+ if (!Array.isArray(content)) return content;
1397
+ return content.map((block) => {
1398
+ if (
1399
+ block &&
1400
+ typeof block === "object" &&
1401
+ block.type === "text" &&
1402
+ typeof block.text === "string"
1403
+ ) {
1404
+ return { ...block, text: stripAllTaggedSpans(block.text) };
1405
+ }
1406
+ return block;
1407
+ });
1408
+ }
1409
+
1410
+ // Unique sentinel characters used as span boundary markers through the markdown pass.
1411
+ // These are ASCII control characters that never appear in assistant text.
1412
+ const SPAN_START_MARK = "\x01";
1413
+ const SPAN_END_MARK = "\x02";
1414
+
1415
+ function applyMarkdownWithSpans(parsed, prefix, conversationState) {
1416
+ const { cleanText, spansByFamily } = parsed;
1417
+ const familyNames = Object.keys(spansByFamily);
1418
+ const totalSpans = familyNames.reduce(
1419
+ (sum, name) => sum + spansByFamily[name].length,
1420
+ 0,
1421
+ );
1422
+ if (totalSpans === 0) {
1423
+ const { text } = conversationState._markdownToPlainText(cleanText, {
1424
+ stripReplyTags: true,
1425
+ });
1426
+ const empty = {};
1427
+ for (const name of familyNames) empty[name] = [];
1428
+ return { text: `${prefix}${text}`, spansByFamily: empty };
1429
+ }
1430
+ // Flatten all spans into one event list, tagged by family for routing back.
1431
+ // Each span contributes two boundary events. Sort by offset, start-before-end on tie.
1432
+ const events = [];
1433
+ for (const family of familyNames) {
1434
+ const spans = spansByFamily[family];
1435
+ for (let i = 0; i < spans.length; i++) {
1436
+ events.push({ offset: spans[i].start, family, spanIndex: i, isEnd: false });
1437
+ events.push({ offset: spans[i].end, family, spanIndex: i, isEnd: true });
1438
+ }
1439
+ }
1440
+ events.sort((a, b) => a.offset - b.offset || (a.isEnd ? 1 : -1));
1441
+
1442
+ // Build marked text, inserting one boundary marker per event.
1443
+ let markedText = "";
1444
+ let cursor = 0;
1445
+ for (const ev of events) {
1446
+ markedText += cleanText.slice(cursor, ev.offset);
1447
+ cursor = ev.offset;
1448
+ markedText += ev.isEnd ? SPAN_END_MARK : SPAN_START_MARK;
1449
+ }
1450
+ markedText += cleanText.slice(cursor);
1451
+
1452
+ // Run the full markdown pass on the marked text.
1453
+ const { text: rawPost } = conversationState._markdownToPlainText(markedText, {
1454
+ stripReplyTags: true,
1455
+ });
1456
+
1457
+ // Strip markers from the post-markdown text and record their post-strip positions.
1458
+ // Each marker, in document order, corresponds to the i-th event from `events`.
1459
+ const eventPostPositions = [];
1460
+ let stripped = "";
1461
+ for (let j = 0; j < rawPost.length; j++) {
1462
+ const ch = rawPost[j];
1463
+ if (ch === SPAN_START_MARK || ch === SPAN_END_MARK) {
1464
+ eventPostPositions.push(stripped.length);
1465
+ } else {
1466
+ stripped += ch;
1467
+ }
1468
+ }
1469
+
1470
+ const prefixLen = prefix.length;
1471
+ const result = {};
1472
+ for (const family of familyNames) {
1473
+ result[family] = spansByFamily[family].map((s) => ({ ...s }));
1474
+ }
1475
+ for (let k = 0; k < events.length; k++) {
1476
+ const ev = events[k];
1477
+ const postPos = eventPostPositions[k] != null ? eventPostPositions[k] : 0;
1478
+ const target = result[ev.family][ev.spanIndex];
1479
+ if (!target) continue;
1480
+ if (ev.isEnd) target.end = prefixLen + postPos;
1481
+ else target.start = prefixLen + postPos;
1482
+ }
1483
+
1484
+ return { text: `${prefix}${stripped}`, spansByFamily: result };
1485
+ }
1486
+
691
1487
  gatewayBridge.on("message", (data) => {
692
1488
  if (!sessionService.isCurrentSession(data.sessionKey)) return;
693
1489
  const runId = data.runId || null;
1490
+ if (runId) {
1491
+ stopTypingForRun(runId, "assistant_message_committed");
1492
+ // Flush any pending throttled streaming text BEFORE broadcasting the
1493
+ // synthesized activity-idle. Pre-fix: activity-idle was broadcast first
1494
+ // (below), and the final flushed streaming chunk arrived at the client
1495
+ // AFTER stream_pipeline_turn_idle had already fired. The client's
1496
+ // VoiceIngressCoordinator then treated the trailing chunk as the start
1497
+ // of a new ingress turn (beginIngressStreamPipelineTurnIfNeeded returns
1498
+ // true once streamTurnIdleLogged is set), resetting
1499
+ // streaming_bleed_in_cursor_compose state and producing a visible
1500
+ // ~50-char streaming-text rewind on hardware (trace
1501
+ // 20260518-074144-Z ws 128→130). Flushing first ensures the final
1502
+ // chunk is processed during the still-active turn; the synthesized
1503
+ // activity-idle then runs after the buffer is up to date.
1504
+ clearStreamingThrottleTimer();
1505
+ flushPendingStreamingText();
1506
+ // Synthesize an activity-idle broadcast at assistant-message-commit (=
1507
+ // turn end at the gateway boundary). Upstream eventually emits its own
1508
+ // terminal activity message via gatewayBridge.on("activity",...) but
1509
+ // with ~1.5s median lag past run_complete, which leaves the
1510
+ // thinking-indicator spinner ticking visibly past the point the agent
1511
+ // has fully delivered its response. App-side ActivityGuardState dedupes
1512
+ // the later upstream terminal message by runId + activityId so the real
1513
+ // upstream activity remains authoritative for fields like detail/intent
1514
+ // — this synth only owns the state=idle transition timing. activityId
1515
+ // is namespaced so it cannot collide with a real upstream activityId.
1516
+ // origin/phase=lifecycle/end satisfies isTerminalActivityBoundary so
1517
+ // downstream consumers treat it as a real terminal boundary.
1518
+ broadcastActivity({
1519
+ state: "idle",
1520
+ runId,
1521
+ sessionKey: data.sessionKey || sessionService.ensureSessionKey(),
1522
+ origin: "lifecycle",
1523
+ phase: "end",
1524
+ category: "run_complete_synth",
1525
+ activityId: `run-complete-synth-${runId}`,
1526
+ });
1527
+ }
694
1528
  const runPipeline = runId ? upstreamRunPipeline.get(runId) : null;
695
1529
  if (runPipeline) {
696
1530
  const completedAt = Date.now();
1531
+ if (operationRegistry && typeof operationRegistry.markRunPhase === "function") {
1532
+ operationRegistry.markRunPhase(runId, "complete");
1533
+ }
697
1534
  emitDebug(
698
1535
  "relay.protocol",
699
1536
  "run_complete",
@@ -730,10 +1567,26 @@ export function createUpstreamRuntime(opts = {}) {
730
1567
  }),
731
1568
  );
732
1569
 
733
- clearStreamingThrottleTimer();
734
- flushPendingStreamingText();
735
- conversationState.addMessage(data.role, data.content);
1570
+ // (Moved above: flushPendingStreamingText now runs before broadcastActivity
1571
+ // so the client never sees a chunk arrive after stream_pipeline_turn_idle.)
1572
+ const sanitizedContent =
1573
+ data.role === "assistant"
1574
+ ? sanitizeAssistantContentBlocks(data.content)
1575
+ : data.content;
1576
+ conversationState.addMessage(data.role, sanitizedContent);
1577
+ if (data.role === "assistant") {
1578
+ emitDebug(
1579
+ "openclaw.message",
1580
+ "agent_message",
1581
+ "info",
1582
+ { sessionKey: data.sessionKey || sessionService.ensureSessionKey() },
1583
+ () => ({ text: fullMessageText(sanitizedContent), runId: data.runId || null }),
1584
+ );
1585
+ }
736
1586
  broadcastPages();
1587
+ // Refresh session context (token count) after each committed assistant
1588
+ // message. Runs async so it doesn't block the message handler.
1589
+ sessionContextService.refreshActiveSessionContext().catch(() => {});
737
1590
 
738
1591
  const voiceRuntime = getVoiceRuntime();
739
1592
  if (voiceRuntime && typeof voiceRuntime.onAgentMessage === "function") {
@@ -752,11 +1605,15 @@ export function createUpstreamRuntime(opts = {}) {
752
1605
  origin === "lifecycle" &&
753
1606
  phase === "start"
754
1607
  ) {
1608
+ startTyping(runId, data.sessionKey, "lifecycle_start");
755
1609
  const now = Date.now();
756
1610
  const runPipeline = upstreamRunPipeline.get(runId);
757
1611
  if (runPipeline && !runPipeline.lifecycleStartAt) {
758
1612
  runPipeline.lifecycleStartAt = now;
759
1613
  }
1614
+ if (operationRegistry && typeof operationRegistry.markRunPhase === "function") {
1615
+ operationRegistry.markRunPhase(runId, "lifecycle_start");
1616
+ }
760
1617
  emitDebug(
761
1618
  "relay.protocol",
762
1619
  "run_lifecycle_start",
@@ -768,62 +1625,155 @@ export function createUpstreamRuntime(opts = {}) {
768
1625
  ackToRunStartMs: runPipeline && runPipeline.ackAt ? (now - runPipeline.ackAt) : null,
769
1626
  }),
770
1627
  );
1628
+ // Signal run-start to session-context service so WebUI can show the
1629
+ // spinner / suppress stale token counts during an active run.
1630
+ cachedRunActiveSessionKey = data.sessionKey || sessionService.ensureSessionKey();
1631
+ sessionContextService.broadcastRunActive(true);
1632
+ }
1633
+ if (runId && isTerminalActivityBoundary(data.state, phase, origin)) {
1634
+ stopTypingForRun(runId, "terminal_activity_boundary");
1635
+ // Run ended normally — clear run-active state and refresh token count.
1636
+ if (cachedRunActiveSessionKey) {
1637
+ cachedRunActiveSessionKey = null;
1638
+ sessionContextService.broadcastRunActive(false);
1639
+ sessionContextService.refreshActiveSessionContext().catch(() => {});
1640
+ }
1641
+ }
1642
+ let activity = data;
1643
+ let shouldRefreshProviderUsageInBackground = false;
1644
+ if (isProviderRateLimitedLifecycleError(data)) {
1645
+ if (!data.failoverPending) {
1646
+ const { provider: outcomeProvider } = activeProviderContext();
1647
+ if (outcomeProvider) {
1648
+ providerOutcomeState.set(outcomeProvider, {
1649
+ lastOutcome: "exhausted",
1650
+ lastOutcomeAtMs: now(),
1651
+ });
1652
+ }
1653
+ }
1654
+ const rateLimitInfo = buildRateLimitInfoFromSnapshot(projectProviderUsageSnapshot());
1655
+ if (rateLimitInfo) {
1656
+ activity = {
1657
+ ...data,
1658
+ rateLimitInfo,
1659
+ };
1660
+ } else {
1661
+ shouldRefreshProviderUsageInBackground = true;
1662
+ }
1663
+ }
1664
+ broadcastActivity(activity);
1665
+ if (shouldRefreshProviderUsageInBackground) {
1666
+ refreshProviderUsage(true).catch((err) => {
1667
+ logger.warn(`[relay] Provider usage refresh failed after rate limit activity: ${err.message}`);
1668
+ });
1669
+ }
1670
+ if (runId && data.state === "idle" && data.isError === true && phase === "error") {
1671
+ upstreamRunPipeline.delete(runId);
1672
+ // Run ended with an error — clear run-active state and refresh token count.
1673
+ if (cachedRunActiveSessionKey) {
1674
+ cachedRunActiveSessionKey = null;
1675
+ sessionContextService.broadcastRunActive(false);
1676
+ sessionContextService.refreshActiveSessionContext().catch(() => {});
1677
+ }
771
1678
  }
772
- broadcastActivity(data);
773
1679
  });
774
1680
 
775
1681
  gatewayBridge.on("streaming", (data) => {
776
1682
  if (!sessionService.isCurrentSession(data.sessionKey)) return;
1683
+ const sessionKey = data.sessionKey || sessionService.ensureSessionKey();
1684
+ const { provider: outcomeProvider } = activeProviderContext();
1685
+ if (outcomeProvider) {
1686
+ providerOutcomeState.set(outcomeProvider, {
1687
+ lastOutcome: "ready",
1688
+ lastOutcomeAtMs: now(),
1689
+ });
1690
+ }
777
1691
  const runId = data.runId || null;
1692
+ const nowMs = now();
1693
+ const gatewayReceivedAtMs = Number.isFinite(data.gatewayReceivedAtMs)
1694
+ ? Math.floor(data.gatewayReceivedAtMs)
1695
+ : null;
1696
+ const rawAssistantChars = Number.isFinite(data.rawAssistantChars)
1697
+ ? Math.max(0, Math.floor(data.rawAssistantChars))
1698
+ : (typeof data.text === "string" ? data.text.length : null);
1699
+ const assistantDeltaChars = Number.isFinite(data.assistantDeltaChars)
1700
+ ? Math.max(0, Math.floor(data.assistantDeltaChars))
1701
+ : null;
1702
+ const firstGatewayChunk =
1703
+ typeof data.firstGatewayChunk === "boolean" ? data.firstGatewayChunk : null;
778
1704
  if (runId) {
779
- const now = Date.now();
780
1705
  const runPipeline = upstreamRunPipeline.get(runId);
781
1706
  if (runPipeline && !runPipeline.firstStreamingAt) {
782
- runPipeline.firstStreamingAt = now;
1707
+ runPipeline.firstStreamingAt = nowMs;
1708
+ runPipeline.firstGatewayReceivedAt = gatewayReceivedAtMs;
1709
+ runPipeline.firstGatewayChars = rawAssistantChars;
1710
+ if (operationRegistry && typeof operationRegistry.markRunPhase === "function") {
1711
+ operationRegistry.markRunPhase(runId, "first_stream");
1712
+ }
783
1713
  emitDebug(
784
1714
  "relay.protocol",
785
1715
  "run_first_streaming",
786
1716
  "debug",
787
- { sessionKey: data.sessionKey || sessionService.ensureSessionKey(), runId },
1717
+ { sessionKey, runId },
788
1718
  () => ({
789
1719
  messageId: runPipeline.messageId,
790
- sendToFirstStreamingMs: now - runPipeline.sendStartedAt,
791
- ackToFirstStreamingMs: runPipeline.ackAt ? (now - runPipeline.ackAt) : null,
1720
+ sendToFirstStreamingMs: nowMs - runPipeline.sendStartedAt,
1721
+ ackToFirstStreamingMs: runPipeline.ackAt ? (nowMs - runPipeline.ackAt) : null,
792
1722
  runStartToFirstStreamingMs: runPipeline.lifecycleStartAt
793
- ? (now - runPipeline.lifecycleStartAt)
1723
+ ? (nowMs - runPipeline.lifecycleStartAt)
794
1724
  : null,
1725
+ firstGatewayChars: rawAssistantChars,
1726
+ gatewayToRelayIngressMs:
1727
+ gatewayReceivedAtMs != null ? (nowMs - gatewayReceivedAtMs) : null,
795
1728
  }),
796
1729
  );
797
1730
  }
798
1731
  }
799
- const { text } = conversationState._markdownToPlainText(data.text, {
800
- stripReplyTags: true,
801
- });
802
- const prefix = agentName || "Agent";
803
- pendingStreamingText = `${prefix}: ${text}`;
1732
+ const prefix = `${agentIdentity.name || "Agent"}: `;
1733
+ // Parse-on-flush coalescing: store the raw cumulative text and defer the
1734
+ // parseTaggedSpans + markdown pass to flushPendingStreamingText. Chunks
1735
+ // superseded inside the throttle window never pay parser cost.
1736
+ pendingStreaming = {
1737
+ rawText: data.text,
1738
+ prefix,
1739
+ sessionKey,
1740
+ runId,
1741
+ rawAssistantChars,
1742
+ assistantDeltaChars,
1743
+ firstGatewayChunk,
1744
+ gatewayReceivedAtMs,
1745
+ flushReason: "throttled_flush",
1746
+ };
804
1747
  emitDebug(
805
1748
  "openclaw.run",
806
1749
  "streaming",
807
1750
  "debug",
808
1751
  {
809
- sessionKey: data.sessionKey || sessionService.ensureSessionKey(),
1752
+ sessionKey,
810
1753
  runId,
811
1754
  },
812
1755
  () => ({
813
- textChars: pendingStreamingText.length,
1756
+ // Raw cumulative chars: the parsed/markdown length is no longer
1757
+ // computed here (parse-on-flush moves it to flushPendingStreamingText).
1758
+ textChars: pendingStreaming ? pendingStreaming.rawText.length : 0,
1759
+ rawAssistantChars,
1760
+ assistantDeltaChars,
1761
+ firstGatewayChunk,
1762
+ gatewayReceivedAtMs,
1763
+ gatewayToRelayIngressMs:
1764
+ gatewayReceivedAtMs != null ? (nowMs - gatewayReceivedAtMs) : null,
814
1765
  }),
815
1766
  );
816
1767
 
817
1768
  if (!streamingThrottleTimer) {
818
- const server = getServer();
819
- if (server) {
820
- server.broadcast(handler.formatStreaming(pendingStreamingText));
1769
+ if (pendingStreaming) {
1770
+ pendingStreaming.flushReason = "first_immediate";
821
1771
  }
822
- pendingStreamingText = null;
1772
+ flushPendingStreamingText();
823
1773
  streamingThrottleTimer = setTimeout(() => {
824
1774
  streamingThrottleTimer = null;
825
1775
  flushPendingStreamingText();
826
- }, 150);
1776
+ }, STREAMING_REBROADCAST_THROTTLE_MS);
827
1777
  }
828
1778
  });
829
1779
 
@@ -832,15 +1782,27 @@ export function createUpstreamRuntime(opts = {}) {
832
1782
  });
833
1783
 
834
1784
  gatewayBridge.on("agentIdentity", (data) => {
835
- agentName = data && data.name ? data.name : null;
836
- conversationState.setAgentName(agentName || "Agent");
837
- broadcastStatus();
1785
+ applyAgentIdentity(data, "agent_identity_event");
838
1786
  });
839
1787
 
840
1788
  gatewayBridge.on("connected", () => {
841
1789
  refreshUpstreamBootstrap("connected_event").catch((err) => {
842
1790
  logger.warn(`[relay] Upstream connected bootstrap failed: ${err.message}`);
843
1791
  });
1792
+ // Give WebUI an initial session-context snapshot on reconnect so it has a
1793
+ // baseline token count before the first agent_end refresh fires.
1794
+ sessionContextService.refreshActiveSessionContext().catch(() => {});
1795
+ });
1796
+
1797
+ gatewayBridge.on("timing", (rawEvent) => {
1798
+ const timing = normalizeGatewayTimingEvent(rawEvent);
1799
+ emitDebug(
1800
+ timing.category,
1801
+ timing.event,
1802
+ timing.severity,
1803
+ timing.context,
1804
+ () => timing.data,
1805
+ );
844
1806
  });
845
1807
 
846
1808
  gatewayBridge.on("protocol", (data) => {
@@ -870,9 +1832,29 @@ export function createUpstreamRuntime(opts = {}) {
870
1832
  "approval_requested",
871
1833
  "info",
872
1834
  { sessionKey: sessionService.ensureSessionKey() },
873
- () => ({
874
- approvalId: data && data.id ? data.id : null,
875
- }),
1835
+ () => {
1836
+ const request = data && data.request ? data.request : {};
1837
+ const approvalKind =
1838
+ data && data.approvalKind === "plugin"
1839
+ ? "plugin"
1840
+ : data && typeof data.id === "string" && data.id.startsWith("plugin:")
1841
+ ? "plugin"
1842
+ : "exec";
1843
+ const command =
1844
+ typeof request.command === "string"
1845
+ ? request.command
1846
+ : approvalKind === "plugin" && typeof request.title === "string"
1847
+ ? request.title
1848
+ : "";
1849
+ return {
1850
+ approvalId: data && data.id ? data.id : null,
1851
+ approvalKind,
1852
+ host: typeof request.host === "string" ? request.host : null,
1853
+ commandLength: command.length,
1854
+ requestCommandIsEmpty: command.length === 0,
1855
+ hasSystemRunPlan: !!(request.systemRunPlan && typeof request.systemRunPlan === "object"),
1856
+ };
1857
+ },
876
1858
  );
877
1859
  const server = getServer();
878
1860
  if (server) {
@@ -909,9 +1891,19 @@ export function createUpstreamRuntime(opts = {}) {
909
1891
  });
910
1892
 
911
1893
  return {
1894
+ clearTyping,
1895
+ compactActiveSession: (sessionKey) =>
1896
+ sessionContextService.compactActiveSession(sessionKey),
912
1897
  getAgentName,
1898
+ getAgentEmoji,
1899
+ getAgentAvatarDataUri,
1900
+ getAgentAvatarHash,
1901
+ getAgentAvatarDataUriByHash,
913
1902
  getModelsCatalogSnapshot,
1903
+ getProviderUsageSnapshot,
914
1904
  getSkillsCatalogSnapshot,
1905
+ handleCurrentSessionModelConfigChanged,
1906
+ handleCurrentSessionModelConfigCleared,
915
1907
  handleSessionChanged,
916
1908
  isConnected,
917
1909
  start() {
@@ -921,11 +1913,126 @@ export function createUpstreamRuntime(opts = {}) {
921
1913
  clearStreamingThrottleTimer();
922
1914
  clearBootstrapRefreshTimer();
923
1915
  bootstrapRefreshNonce += 1;
924
- pendingStreamingText = null;
1916
+ pendingStreaming = null;
1917
+ activeTyping = null;
925
1918
  inFlightModelsCatalogFetch = null;
926
1919
  inFlightSkillsCatalogFetch = null;
927
1920
  upstreamRunPipeline.clear();
928
1921
  },
929
1922
  trackAcceptedRun,
930
1923
  };
1924
+
1925
+ function isTerminalActivityBoundary(state, phase, origin) {
1926
+ const normalizedOrigin = typeof origin === "string" ? origin.trim().toLowerCase() : "";
1927
+ if (normalizedOrigin !== "lifecycle") {
1928
+ return false;
1929
+ }
1930
+ const normalizedState = typeof state === "string" ? state.trim().toLowerCase() : "";
1931
+ if (normalizedState === "idle" || normalizedState === "error") {
1932
+ return true;
1933
+ }
1934
+ const normalizedPhase = typeof phase === "string" ? phase.trim().toLowerCase() : "";
1935
+ return (
1936
+ normalizedPhase === "error" ||
1937
+ normalizedPhase === "end" ||
1938
+ normalizedPhase === "complete" ||
1939
+ normalizedPhase === "completed" ||
1940
+ normalizedPhase === "done" ||
1941
+ normalizedPhase === "failed" ||
1942
+ normalizedPhase === "finish" ||
1943
+ normalizedPhase === "finished"
1944
+ );
1945
+ }
1946
+
1947
+ function isProviderRateLimitedLifecycleError(activity) {
1948
+ return !!(
1949
+ activity &&
1950
+ activity.isError === true &&
1951
+ activity.code === "provider_rate_limited" &&
1952
+ typeof activity.origin === "string" &&
1953
+ activity.origin.trim().toLowerCase() === "lifecycle" &&
1954
+ typeof activity.phase === "string" &&
1955
+ activity.phase.trim().toLowerCase() === "error"
1956
+ );
1957
+ }
1958
+
1959
+ function emitTypingUpdate(state, runId, sessionKey, reason) {
1960
+ const server = getServer();
1961
+ if (
1962
+ !server ||
1963
+ !handler ||
1964
+ typeof handler.formatTyping !== "function" ||
1965
+ typeof runId !== "string" ||
1966
+ !runId.trim()
1967
+ ) {
1968
+ return false;
1969
+ }
1970
+ const resolvedSessionKey =
1971
+ typeof sessionKey === "string" && sessionKey.trim()
1972
+ ? sessionKey.trim()
1973
+ : sessionService.ensureSessionKey();
1974
+ server.broadcast(
1975
+ handler.formatTyping({
1976
+ state,
1977
+ runId: runId.trim(),
1978
+ sessionKey: resolvedSessionKey,
1979
+ }),
1980
+ );
1981
+ emitDebug(
1982
+ "app.timeline",
1983
+ "typing",
1984
+ "debug",
1985
+ { sessionKey: resolvedSessionKey, runId: runId.trim() },
1986
+ () => ({
1987
+ state,
1988
+ reason: reason || null,
1989
+ }),
1990
+ );
1991
+ return true;
1992
+ }
1993
+
1994
+ function startTyping(runId, sessionKey, reason) {
1995
+ if (typeof runId !== "string" || !runId.trim()) {
1996
+ return false;
1997
+ }
1998
+ const normalizedRunId = runId.trim();
1999
+ const resolvedSessionKey =
2000
+ typeof sessionKey === "string" && sessionKey.trim()
2001
+ ? sessionKey.trim()
2002
+ : sessionService.ensureSessionKey();
2003
+ if (activeTyping && activeTyping.runId === normalizedRunId) {
2004
+ return false;
2005
+ }
2006
+ if (activeTyping) {
2007
+ clearTyping("superseded_by_new_run");
2008
+ }
2009
+ activeTyping = {
2010
+ runId: normalizedRunId,
2011
+ sessionKey: resolvedSessionKey,
2012
+ };
2013
+ return emitTypingUpdate("start", normalizedRunId, resolvedSessionKey, reason);
2014
+ }
2015
+
2016
+ function stopTypingForRun(runId, reason) {
2017
+ if (
2018
+ !activeTyping ||
2019
+ typeof runId !== "string" ||
2020
+ !runId.trim() ||
2021
+ activeTyping.runId !== runId.trim()
2022
+ ) {
2023
+ return false;
2024
+ }
2025
+ const { runId: activeRunId, sessionKey } = activeTyping;
2026
+ activeTyping = null;
2027
+ return emitTypingUpdate("stop", activeRunId, sessionKey, reason);
2028
+ }
2029
+
2030
+ function clearTyping(reason) {
2031
+ if (!activeTyping) {
2032
+ return false;
2033
+ }
2034
+ const { runId, sessionKey } = activeTyping;
2035
+ activeTyping = null;
2036
+ return emitTypingUpdate("stop", runId, sessionKey, reason || "clear_typing");
2037
+ }
931
2038
  }