ocuclaw 1.3.3 → 1.3.4

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 (83) hide show
  1. package/README.md +29 -1
  2. package/dist/config/runtime-config-session-title-model.test.js +0 -3
  3. package/dist/config/runtime-config.js +22 -33
  4. package/dist/domain/activity-status-adapter.js +0 -7
  5. package/dist/domain/activity-status-arbiter.js +3 -27
  6. package/dist/domain/activity-status-labels.js +8 -38
  7. package/dist/domain/code-span-regions.js +4 -24
  8. package/dist/domain/constant-time-equal.js +9 -0
  9. package/dist/domain/constant-time-equal.test.js +28 -0
  10. package/dist/domain/conversation-state.js +27 -138
  11. package/dist/domain/debug-bundle-cache.js +52 -0
  12. package/dist/domain/debug-bundle-format.js +60 -0
  13. package/dist/domain/debug-bundle-preview.js +123 -0
  14. package/dist/domain/debug-bundle-redaction.js +182 -0
  15. package/dist/domain/debug-bundle-save.js +11 -0
  16. package/dist/domain/debug-bundle-zip.js +15 -0
  17. package/dist/domain/debug-bundle.js +97 -0
  18. package/dist/domain/debug-store.js +6 -17
  19. package/dist/domain/debug-upload-preset.js +27 -0
  20. package/dist/domain/glasses-display-system-prompt.js +0 -5
  21. package/dist/domain/glasses-display-system-prompt.test.js +1 -1
  22. package/dist/domain/glasses-ui-content-summary.js +0 -6
  23. package/dist/domain/glasses-ui-system-prompt.test.js +1 -2
  24. package/dist/domain/message-emoji-allowlist.js +0 -7
  25. package/dist/domain/message-emoji-filter.js +3 -9
  26. package/dist/domain/neural-emoji-reactor-tag-config.js +3 -3
  27. package/dist/domain/prompt-channel-fragments.js +1 -10
  28. package/dist/domain/tagged-span-parser.js +3 -26
  29. package/dist/domain/tagged-span-strip.js +0 -7
  30. package/dist/even-ai/even-ai-endpoint.js +77 -24
  31. package/dist/even-ai/even-ai-run-waiter.js +0 -1
  32. package/dist/even-ai/even-ai-settings-store.js +11 -0
  33. package/dist/gateway/gateway-bridge.js +8 -9
  34. package/dist/gateway/gateway-timing-ledger.js +8 -6
  35. package/dist/gateway/openclaw-client.js +97 -297
  36. package/dist/gateway/sanitize-connect-reason.js +10 -0
  37. package/dist/gateway/sanitize-connect-reason.test.js +34 -0
  38. package/dist/index.js +3 -3
  39. package/dist/runtime/channel-two-hook.js +1 -6
  40. package/dist/runtime/container-env.js +1 -5
  41. package/dist/runtime/debug-bundle-handler.js +159 -0
  42. package/dist/runtime/display-toggle-states.js +6 -17
  43. package/dist/runtime/downstream-handler.js +682 -508
  44. package/dist/runtime/glasses-backpressure-latch.js +2 -24
  45. package/dist/runtime/ocuclaw-settings-store.js +10 -1
  46. package/dist/runtime/openclaw-host-version.js +5 -0
  47. package/dist/runtime/plugin-version-service.js +13 -6
  48. package/dist/runtime/provider-usage-select.js +0 -6
  49. package/dist/runtime/register-session-title-distiller.js +14 -16
  50. package/dist/runtime/relay-core.js +601 -290
  51. package/dist/runtime/relay-service.js +19 -47
  52. package/dist/runtime/relay-worker-approval-replay-cache.js +1 -1
  53. package/dist/runtime/relay-worker-entry.js +1 -2
  54. package/dist/runtime/relay-worker-health.js +2 -10
  55. package/dist/runtime/relay-worker-protocol.js +6 -1
  56. package/dist/runtime/relay-worker-supervisor.js +103 -41
  57. package/dist/runtime/relay-worker-transport.js +150 -17
  58. package/dist/runtime/session-context-service.js +5 -45
  59. package/dist/runtime/session-service.js +157 -175
  60. package/dist/runtime/session-title-distiller-budget.js +1 -5
  61. package/dist/runtime/session-title-distiller-helpers.js +14 -24
  62. package/dist/runtime/session-title-distiller.js +109 -122
  63. package/dist/runtime/session-title-record.js +0 -6
  64. package/dist/runtime/stable-prompt-snapshot.js +3 -14
  65. package/dist/runtime/upstream-runtime.js +600 -103
  66. package/dist/tools/device-info-tool.js +4 -21
  67. package/dist/tools/glasses-ui-cron.js +22 -77
  68. package/dist/tools/glasses-ui-descriptors.js +4 -33
  69. package/dist/tools/glasses-ui-limits.js +0 -13
  70. package/dist/tools/glasses-ui-paint-floor.js +5 -39
  71. package/dist/tools/glasses-ui-recipes.js +92 -101
  72. package/dist/tools/glasses-ui-surfaces.js +31 -163
  73. package/dist/tools/glasses-ui-template.js +7 -22
  74. package/dist/tools/glasses-ui-tool-description.test.js +2 -2
  75. package/dist/tools/glasses-ui-tool.js +87 -451
  76. package/dist/tools/glasses-ui-voicemail.js +6 -63
  77. package/dist/tools/glasses-ui-wake.js +9 -76
  78. package/dist/tools/session-title-tool.js +2 -7
  79. package/dist/tools/session-title-tool.test.js +1 -1
  80. package/dist/version.js +3 -2
  81. package/openclaw.plugin.json +60 -13
  82. package/package.json +3 -2
  83. package/dist/runtime/protocol-adapter.js +0 -387
@@ -8,6 +8,7 @@ import { parseTaggedSpans } from "../domain/tagged-span-parser.js";
8
8
  import { EMOJI_TAG_FAMILY_CONFIG } from "../domain/neural-emoji-reactor-tag-config.js";
9
9
  import { PACE_TAG_FAMILY_CONFIG } from "../domain/neural-pace-modulator-tag-config.js";
10
10
  import { createSessionContextService } from "./session-context-service.js";
11
+ import { DISTILLER_SESSION_PREFIX } from "./session-title-distiller-helpers.js";
11
12
 
12
13
  function normalizeLogger(logger) {
13
14
  if (!logger || typeof logger !== "object") {
@@ -25,13 +26,26 @@ function normalizeLogger(logger) {
25
26
  const DEFAULT_MODEL_PROVIDER = "anthropic";
26
27
  const DEFAULT_MODEL_ID = "claude-opus-4-6";
27
28
  const POOL_OUTCOME_FRESHNESS_MS = 10 * 60 * 1000;
29
+ const TITLE_DISTILLER_RUN_ID_PREFIX = "ocuclaw-title-";
30
+ const TITLE_DISTILLER_SESSION_MARKER = ":title-distiller:";
28
31
 
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
32
  export const STREAMING_REBROADCAST_THROTTLE_MS = 33;
34
33
 
34
+ function normalizeStreamingToken(raw) {
35
+ return typeof raw === "string" && raw.trim() ? raw.trim() : null;
36
+ }
37
+
38
+ function isTitleDistillerStreamingEvent(data) {
39
+ const runId = normalizeStreamingToken(data && data.runId);
40
+ if (runId && runId.startsWith(TITLE_DISTILLER_RUN_ID_PREFIX)) return true;
41
+ const sessionKey = normalizeStreamingToken(data && data.sessionKey);
42
+ return Boolean(
43
+ sessionKey &&
44
+ (sessionKey.startsWith(DISTILLER_SESSION_PREFIX) ||
45
+ sessionKey.includes(TITLE_DISTILLER_SESSION_MARKER)),
46
+ );
47
+ }
48
+
35
49
  function fullMessageText(content) {
36
50
  if (typeof content === "string") return content;
37
51
  if (Array.isArray(content)) {
@@ -307,6 +321,232 @@ function normalizeSkillsCatalogRows(rows) {
307
321
  return out;
308
322
  }
309
323
 
324
+ function isMethodNotFoundError(err, message) {
325
+ if (err && typeof err === "object") {
326
+ const code = err.code ?? (err.error && err.error.code);
327
+ if (code === -32601) return true;
328
+ }
329
+ const text = typeof message === "string" ? message.toLowerCase() : "";
330
+ return (
331
+ text.includes("method not found") ||
332
+ text.includes("unknown method") ||
333
+ text.includes("no such method") ||
334
+ text.includes("not supported")
335
+ );
336
+ }
337
+
338
+ function normalizeAgentsCatalogRows(rows) {
339
+ if (!Array.isArray(rows)) return [];
340
+ const out = [];
341
+ const seen = new Set();
342
+ for (const row of rows) {
343
+ if (!row || typeof row !== "object") continue;
344
+ const id = typeof row.id === "string" ? row.id.trim() : "";
345
+ if (!id) continue;
346
+ if (seen.has(id)) continue;
347
+ seen.add(id);
348
+ const identity =
349
+ row.identity && typeof row.identity === "object" ? row.identity : {};
350
+ const identityName =
351
+ typeof identity.name === "string" && identity.name.trim()
352
+ ? identity.name.trim()
353
+ : "";
354
+ const baseName =
355
+ typeof row.name === "string" && row.name.trim() ? row.name.trim() : "";
356
+ const name = identityName || baseName || id;
357
+ const emoji =
358
+ typeof identity.emoji === "string" && identity.emoji.trim()
359
+ ? identity.emoji.trim()
360
+ : null;
361
+ const model = row.model && typeof row.model === "object" ? row.model : {};
362
+ const primaryModel =
363
+ typeof model.primary === "string" && model.primary.trim()
364
+ ? model.primary.trim()
365
+ : null;
366
+ out.push({ id, name, emoji, primaryModel });
367
+ }
368
+ return out;
369
+ }
370
+
371
+ const WORKSPACE_IDENTITY_FILENAME = "IDENTITY.md";
372
+
373
+ const UPSTREAM_DEFAULT_IDENTITY_NAME = "Assistant";
374
+
375
+ const IDENTITY_PLACEHOLDER_VALUES = new Set([
376
+ "pick something you like",
377
+ "ai? robot? familiar? ghost in the machine? something weirder?",
378
+ "how do you come across? sharp? warm? chaotic? calm?",
379
+ "your signature - pick one that feels right",
380
+ "workspace-relative path, http(s) url, or data uri",
381
+ ]);
382
+
383
+ const IDENTITY_LABELS = new Set([
384
+ "name",
385
+ "emoji",
386
+ "creature",
387
+ "vibe",
388
+ "theme",
389
+ "avatar",
390
+ ]);
391
+
392
+ function stripIdentityMarkup(value) {
393
+ let s = String(value == null ? "" : value).trim();
394
+ s = s.replace(/^[*_`\s]+|[*_`\s]+$/g, "").trim();
395
+ if (s.startsWith("(") && s.endsWith(")")) s = s.slice(1, -1).trim();
396
+ return s;
397
+ }
398
+
399
+ function normalizeIdentityLabel(raw) {
400
+ return raw.replace(/[*_`]/g, "").trim().toLowerCase();
401
+ }
402
+
403
+ function isIdentityPlaceholder(value) {
404
+ const normalized = String(value == null ? "" : value)
405
+ .replace(/[*_`()]/g, " ")
406
+ .replace(/[–—]/g, "-")
407
+ .replace(/\s+/g, " ")
408
+ .trim()
409
+ .toLowerCase();
410
+ return IDENTITY_PLACEHOLDER_VALUES.has(normalized);
411
+ }
412
+
413
+ function looksLikeIdentityLabelLine(line) {
414
+ const cleaned = String(line).trim().replace(/^[-*]\s*/, "");
415
+ const colon = cleaned.indexOf(":");
416
+ if (colon === -1) return false;
417
+ return IDENTITY_LABELS.has(normalizeIdentityLabel(cleaned.slice(0, colon)));
418
+ }
419
+
420
+ const MAX_IDENTITY_NAME = 50;
421
+ const MAX_IDENTITY_EMOJI = 16;
422
+
423
+ function hasMeaningfulIdentityChars(value) {
424
+ return /[A-Za-z]/.test(value) || /[^\x00-\x7F]/.test(value);
425
+ }
426
+
427
+ function normalizeFallbackEmoji(value) {
428
+ const trimmed = stripIdentityMarkup(value);
429
+ if (!trimmed || trimmed.length > MAX_IDENTITY_EMOJI) return null;
430
+ if (trimmed.includes("/") || trimmed.includes("://")) return null;
431
+ let hasNonAscii = false;
432
+ for (let i = 0; i < trimmed.length; i += 1) {
433
+ if (trimmed.charCodeAt(i) > 127) {
434
+ hasNonAscii = true;
435
+ break;
436
+ }
437
+ }
438
+ return hasNonAscii ? trimmed : null;
439
+ }
440
+
441
+ function parseWorkspaceIdentityFallback(content) {
442
+ const result = {};
443
+ if (typeof content !== "string" || !content) return result;
444
+ const lines = content.split(/\r?\n/);
445
+ for (let i = 0; i < lines.length; i += 1) {
446
+ const cleaned = lines[i].trim().replace(/^[-*]\s*/, "");
447
+ const colon = cleaned.indexOf(":");
448
+ if (colon === -1) continue;
449
+ const label = normalizeIdentityLabel(cleaned.slice(0, colon));
450
+ if (label !== "name" && label !== "emoji") continue;
451
+ let value = stripIdentityMarkup(cleaned.slice(colon + 1));
452
+ if (!value) {
453
+
454
+ for (let j = i + 1; j < lines.length; j += 1) {
455
+ const peek = lines[j].trim();
456
+ if (!peek) continue;
457
+ if (peek.startsWith("#")) break;
458
+ if (/^(-{3,}|\*{3,}|_{3,})$/.test(peek)) break;
459
+ if (looksLikeIdentityLabelLine(peek)) break;
460
+ value = stripIdentityMarkup(peek);
461
+ break;
462
+ }
463
+ }
464
+ if (!value || isIdentityPlaceholder(value)) continue;
465
+ if (label === "name") {
466
+ if (!hasMeaningfulIdentityChars(value)) continue;
467
+ if (!result.name) {
468
+ result.name =
469
+ value.length > MAX_IDENTITY_NAME ? value.slice(0, MAX_IDENTITY_NAME) : value;
470
+ }
471
+ } else if (label === "emoji") {
472
+ const normalized = normalizeFallbackEmoji(value);
473
+ if (normalized && !result.emoji) result.emoji = normalized;
474
+ }
475
+ }
476
+ return result;
477
+ }
478
+
479
+ function applyIdentityFallback(identity, fallback) {
480
+ const name = identity && identity.name ? identity.name : null;
481
+ const emoji = identity && identity.emoji ? identity.emoji : null;
482
+ if (!fallback || (!fallback.name && !fallback.emoji)) return { name, emoji };
483
+
484
+ const needName = !name || (name === UPSTREAM_DEFAULT_IDENTITY_NAME && !emoji);
485
+ const needEmoji = !emoji;
486
+ return {
487
+ name: needName && fallback.name ? fallback.name : name,
488
+ emoji: needEmoji && fallback.emoji ? fallback.emoji : emoji,
489
+ };
490
+ }
491
+
492
+ function rawRowNameUnresolved(row) {
493
+ const identity =
494
+ row.identity && typeof row.identity === "object" ? row.identity : null;
495
+ const hasIdentityName =
496
+ identity && typeof identity.name === "string" && identity.name.trim();
497
+ const hasBaseName = typeof row.name === "string" && row.name.trim();
498
+ return !hasIdentityName && !hasBaseName;
499
+ }
500
+
501
+ function rawRowEmojiUnresolved(row) {
502
+ const identity =
503
+ row.identity && typeof row.identity === "object" ? row.identity : null;
504
+ return !(
505
+ identity &&
506
+ typeof identity.emoji === "string" &&
507
+ identity.emoji.trim()
508
+ );
509
+ }
510
+
511
+ function rawRowNeedsIdentityFallback(row) {
512
+ if (!row || typeof row !== "object") return false;
513
+ const id = typeof row.id === "string" ? row.id.trim() : "";
514
+ if (!id) return false;
515
+ return rawRowNameUnresolved(row) || rawRowEmojiUnresolved(row);
516
+ }
517
+
518
+ function lookupIdentityFallback(fallbackByAgentId, id) {
519
+ if (!fallbackByAgentId) return null;
520
+ if (typeof fallbackByAgentId.get === "function") {
521
+ return fallbackByAgentId.get(id) || null;
522
+ }
523
+ return fallbackByAgentId[id] || null;
524
+ }
525
+
526
+ function overlayRawAgentRowsWithFallback(rows, fallbackByAgentId) {
527
+ if (!Array.isArray(rows)) return [];
528
+ return rows.map((row) => {
529
+ if (!row || typeof row !== "object") return row;
530
+ const id = typeof row.id === "string" ? row.id.trim() : "";
531
+ if (!id) return row;
532
+ const fallback = lookupIdentityFallback(fallbackByAgentId, id);
533
+ if (!fallback || (!fallback.name && !fallback.emoji)) return row;
534
+ const fillName = rawRowNameUnresolved(row) && fallback.name;
535
+ const fillEmoji = rawRowEmojiUnresolved(row) && fallback.emoji;
536
+ if (!fillName && !fillEmoji) return row;
537
+ const identity =
538
+ row.identity && typeof row.identity === "object" ? row.identity : {};
539
+ return {
540
+ ...row,
541
+ identity: {
542
+ ...identity,
543
+ ...(fillName ? { name: fallback.name } : {}),
544
+ ...(fillEmoji ? { emoji: fallback.emoji } : {}),
545
+ },
546
+ };
547
+ });
548
+ }
549
+
310
550
  export function createUpstreamRuntime(opts = {}) {
311
551
  const logger = normalizeLogger(opts.logger);
312
552
  const gatewayBridge = opts.gatewayBridge;
@@ -328,6 +568,10 @@ export function createUpstreamRuntime(opts = {}) {
328
568
  typeof opts.broadcastProviderUsageSnapshot === "function"
329
569
  ? opts.broadcastProviderUsageSnapshot
330
570
  : () => {};
571
+ const broadcastAgentsCatalog =
572
+ typeof opts.broadcastAgentsCatalog === "function"
573
+ ? opts.broadcastAgentsCatalog
574
+ : () => {};
331
575
  const getCurrentSessionModelConfigSnapshot =
332
576
  typeof opts.getCurrentSessionModelConfigSnapshot === "function"
333
577
  ? opts.getCurrentSessionModelConfigSnapshot
@@ -343,11 +587,7 @@ export function createUpstreamRuntime(opts = {}) {
343
587
 
344
588
  const gatewayUrl = typeof opts.gatewayUrl === "string" ? opts.gatewayUrl : null;
345
589
  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.
590
+
351
591
  const MAX_AVATAR_DATA_URI_BYTES = 4 * 1024 * 1024;
352
592
 
353
593
  function gatewayHttpOriginFromWsUrl(wsUrl) {
@@ -374,18 +614,15 @@ export function createUpstreamRuntime(opts = {}) {
374
614
  const fetchAgentAvatar =
375
615
  typeof opts.fetchAgentAvatar === "function" ? opts.fetchAgentAvatar : defaultFetchAgentAvatar;
376
616
 
377
- /** @type {Map<string, {dataUri: string, hash: string}>} */
378
617
  const avatarCache = new Map();
379
- /** @type {Map<string, Promise<{dataUri: string, hash: string}|null>>} */
618
+
380
619
  const inFlightAvatarFetches = new Map();
381
- /** @type {Map<string, string>} */ // hash → cacheKey
620
+
382
621
  const avatarHashIndex = new Map();
383
622
 
384
623
  async function resolveAgentAvatar(agentId, avatarSource) {
385
624
  if (!agentId || typeof avatarSource !== "string" || !avatarSource) return null;
386
625
 
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
626
  if (avatarSource.startsWith("data:")) {
390
627
  const cacheKey = `${agentId}|${avatarSource}`;
391
628
  const cached = avatarCache.get(cacheKey);
@@ -495,36 +732,56 @@ export function createUpstreamRuntime(opts = {}) {
495
732
  : 60000;
496
733
 
497
734
  let openclawConnected = false;
498
- /** @type {{name: string|null, emoji: string|null, avatarDataUri: string|null, avatarHash: string|null}} */
735
+
499
736
  let agentIdentity = { name: null, emoji: null, avatarDataUri: null, avatarHash: null };
500
- /** @type {Array<{provider: string, id: string, name: string, contextWindow?: number, reasoning?: boolean}>|null} */
737
+
501
738
  let cachedModelsCatalog = null;
502
739
  let cachedModelsCatalogFetchedAt = 0;
503
740
  let cachedModelsCatalogStale = true;
504
- /** @type {Promise<{models: Array, fetchedAtMs: number, stale: boolean}>|null} */
741
+
505
742
  let inFlightModelsCatalogFetch = null;
506
- /** @type {Array<{name: string, description: string}>|null} */
743
+
507
744
  let cachedSkillsCatalog = null;
508
745
  let cachedSkillsCatalogFetchedAt = 0;
509
746
  let cachedSkillsCatalogStale = true;
510
- /** @type {Promise<{skills: Array, fetchedAtMs: number, stale: boolean}>|null} */
747
+
511
748
  let inFlightSkillsCatalogFetch = null;
749
+
750
+ let cachedAgentsCatalog = null;
751
+ let cachedAgentsCatalogFetchedAt = 0;
752
+ let cachedAgentsCatalogStale = true;
753
+
754
+ let cachedAgentsEnvelope = { defaultId: null, mainKey: null, scope: null };
755
+
756
+ let agentsListUnsupported = false;
757
+
758
+ let inFlightAgentsCatalogFetch = null;
512
759
  let cachedProviderUsageSummary = null;
513
760
  let cachedProviderUsageFetchedAt = 0;
514
761
  let cachedProviderUsageObservedAt = 0;
515
762
  let cachedProviderUsageStale = true;
516
- /** @type {Promise<object>|null} */
763
+
517
764
  let inFlightProviderUsageFetch = null;
518
765
  const providerOutcomeState = new Map();
519
766
  const cachedAuthProfileCounts = new Map();
520
767
  const upstreamRunPipeline = new Map();
521
768
  let streamingThrottleTimer = null;
522
769
  let pendingStreaming = null;
523
- /** @type {{runId: string, sessionKey: string}|null} */
770
+
524
771
  let activeTyping = null;
525
772
  let bootstrapRefreshTimer = null;
526
773
  let bootstrapRefreshNonce = 0;
527
774
 
775
+ const workspaceIdentityFallbackCache = new Map();
776
+
777
+ const inFlightWorkspaceIdentityFetches = new Map();
778
+
779
+ let lastGatewayIdentity = null;
780
+
781
+ let connectionGeneration = 0;
782
+
783
+ let workspaceIdentityFilesUnsupported = false;
784
+
528
785
  function getAgentName() {
529
786
  return agentIdentity.name;
530
787
  }
@@ -605,6 +862,20 @@ export function createUpstreamRuntime(opts = {}) {
605
862
  }),
606
863
  );
607
864
  });
865
+ refreshAgentsCatalog(true).then((snapshot) => {
866
+ emitDebug(
867
+ "relay.session",
868
+ "agents_catalog_prefetched",
869
+ "info",
870
+ { sessionKey: sessionService.ensureSessionKey() },
871
+ () => ({
872
+ count: Array.isArray(snapshot.agents) ? snapshot.agents.length : 0,
873
+ stale: !!snapshot.stale,
874
+ unsupported: !!snapshot.unsupported,
875
+ trigger,
876
+ }),
877
+ );
878
+ });
608
879
  sessionService.getCurrentSessionModelConfig().then((config) => {
609
880
  emitDebug(
610
881
  "relay.session",
@@ -627,12 +898,21 @@ export function createUpstreamRuntime(opts = {}) {
627
898
  clearTyping("upstream_disconnected");
628
899
  inFlightModelsCatalogFetch = null;
629
900
  inFlightSkillsCatalogFetch = null;
901
+ inFlightAgentsCatalogFetch = null;
630
902
  inFlightProviderUsageFetch = null;
903
+
904
+ connectionGeneration += 1;
905
+ lastGatewayIdentity = null;
906
+ workspaceIdentityFilesUnsupported = false;
907
+ workspaceIdentityFallbackCache.clear();
908
+ inFlightWorkspaceIdentityFetches.clear();
631
909
  cachedSkillsCatalogStale = true;
910
+ cachedAgentsCatalogStale = true;
911
+
912
+ agentsListUnsupported = false;
632
913
  cachedProviderUsageStale = true;
633
914
  resetActivityStatusAdapter();
634
- // Drop the identity-tier overlays so the WebUI brand slot reverts to the
635
- // OcuClaw mark while disconnected (spec 2026-04-27).
915
+
636
916
  if (
637
917
  agentIdentity.emoji != null ||
638
918
  agentIdentity.avatarDataUri != null ||
@@ -663,16 +943,67 @@ export function createUpstreamRuntime(opts = {}) {
663
943
  broadcastStatus();
664
944
  }
665
945
 
946
+ async function ensureWorkspaceIdentityFallback(agentId) {
947
+ const id = typeof agentId === "string" ? agentId.trim() : "";
948
+ if (!id) return null;
949
+ if (workspaceIdentityFilesUnsupported) return null;
950
+ if (workspaceIdentityFallbackCache.has(id)) {
951
+ return workspaceIdentityFallbackCache.get(id);
952
+ }
953
+ if (inFlightWorkspaceIdentityFetches.has(id)) {
954
+ return inFlightWorkspaceIdentityFetches.get(id);
955
+ }
956
+ const gen = connectionGeneration;
957
+ const promise = gatewayBridge
958
+ .request("agents.files.get", {
959
+ agentId: id,
960
+ name: WORKSPACE_IDENTITY_FILENAME,
961
+ })
962
+ .then((result) => {
963
+ const file = result && result.file;
964
+ const content =
965
+ file && typeof file.content === "string" ? file.content : "";
966
+ const parsed = parseWorkspaceIdentityFallback(content);
967
+ const value = parsed.name || parsed.emoji ? parsed : null;
968
+
969
+ if (gen === connectionGeneration) {
970
+ workspaceIdentityFallbackCache.set(id, value);
971
+ }
972
+ return value;
973
+ })
974
+ .catch((err) => {
975
+ const message = err && err.message ? err.message : String(err);
976
+
977
+ if (isMethodNotFoundError(err, message)) {
978
+ workspaceIdentityFilesUnsupported = true;
979
+ }
980
+
981
+ if (gen === connectionGeneration) {
982
+ workspaceIdentityFallbackCache.set(id, null);
983
+ }
984
+ emitDebug(
985
+ "relay.session",
986
+ "workspace_identity_fallback_failed",
987
+ "debug",
988
+ { sessionKey: sessionService.ensureSessionKey() },
989
+ () => ({ agentId: id, message }),
990
+ );
991
+ return null;
992
+ })
993
+ .finally(() => {
994
+ inFlightWorkspaceIdentityFetches.delete(id);
995
+ });
996
+ inFlightWorkspaceIdentityFetches.set(id, promise);
997
+ return promise;
998
+ }
999
+
666
1000
  function applyAgentIdentity(identity, source) {
1001
+ lastGatewayIdentity = identity || null;
667
1002
  const agentId =
668
1003
  identity && typeof identity.agentId === "string" && identity.agentId ? identity.agentId : null;
669
1004
  const avatarSource =
670
1005
  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.
1006
+
676
1007
  const inlineAvatarDataUri =
677
1008
  avatarSource && avatarSource.startsWith("data:") ? avatarSource : null;
678
1009
  let inlineAvatarHash = null;
@@ -698,6 +1029,17 @@ export function createUpstreamRuntime(opts = {}) {
698
1029
  avatarDataUri: inlineAvatarDataUri,
699
1030
  avatarHash: inlineAvatarHash,
700
1031
  };
1032
+ const fallback = agentId
1033
+ ? workspaceIdentityFallbackCache.get(agentId)
1034
+ : null;
1035
+ if (fallback) {
1036
+ const merged = applyIdentityFallback(
1037
+ { name: next.name, emoji: next.emoji },
1038
+ fallback,
1039
+ );
1040
+ next.name = merged.name;
1041
+ next.emoji = merged.emoji;
1042
+ }
701
1043
  agentIdentity = next;
702
1044
  conversationState.setAgentName(next.name || "Agent");
703
1045
  emitDebug(
@@ -711,15 +1053,32 @@ export function createUpstreamRuntime(opts = {}) {
711
1053
  hasEmoji: !!next.emoji,
712
1054
  hasAvatarSource: !!avatarSource,
713
1055
  avatarInline: !!inlineAvatarDataUri,
1056
+ fallbackApplied: !!fallback,
714
1057
  }),
715
1058
  );
716
1059
  broadcastStatus();
717
1060
 
1061
+ if (
1062
+ agentId &&
1063
+ !workspaceIdentityFallbackCache.has(agentId) &&
1064
+ (!next.name || !next.emoji)
1065
+ ) {
1066
+ ensureWorkspaceIdentityFallback(agentId)
1067
+ .then((value) => {
1068
+ if (!value) return;
1069
+
1070
+ if (!openclawConnected) return;
1071
+ if (lastGatewayIdentity !== identity) return;
1072
+ applyAgentIdentity(identity, `${source}_workspace_fallback`);
1073
+ })
1074
+ .catch(() => {});
1075
+ }
1076
+
718
1077
  if (!agentId || !avatarSource) return;
719
1078
  const generationName = next.name;
720
1079
  const generationEmoji = next.emoji;
721
1080
  resolveAgentAvatar(agentId, avatarSource).then((resolved) => {
722
- // Drop if the identity changed underneath us.
1081
+
723
1082
  if (
724
1083
  agentIdentity.name !== generationName ||
725
1084
  agentIdentity.emoji !== generationEmoji
@@ -808,8 +1167,7 @@ export function createUpstreamRuntime(opts = {}) {
808
1167
  () => ({ runId }),
809
1168
  );
810
1169
  }
811
- // Parse + markdown the latest cumulative text here, not per chunk: only
812
- // the surviving (non-superseded) buffer reaches the parser.
1170
+
813
1171
  const parsedSpans = parseTaggedSpans(queuedStreaming.rawText, [
814
1172
  EMOJI_TAG_FAMILY_CONFIG,
815
1173
  PACE_TAG_FAMILY_CONFIG,
@@ -916,6 +1274,54 @@ export function createUpstreamRuntime(opts = {}) {
916
1274
  return skillsCatalogSnapshot(cachedSkillsCatalogFetchedAt);
917
1275
  }
918
1276
 
1277
+ function agentsCatalogSnapshot(nowMs) {
1278
+ const currentNow = Number.isFinite(nowMs) ? nowMs : now();
1279
+ const hasCache = Array.isArray(cachedAgentsCatalog);
1280
+ return {
1281
+ agents: hasCache ? cachedAgentsCatalog : [],
1282
+ defaultId: cachedAgentsEnvelope.defaultId,
1283
+ mainKey: cachedAgentsEnvelope.mainKey,
1284
+ scope: cachedAgentsEnvelope.scope,
1285
+ fetchedAtMs: hasCache ? cachedAgentsCatalogFetchedAt : currentNow,
1286
+ stale: !hasCache || cachedAgentsCatalogStale,
1287
+ unsupported: agentsListUnsupported,
1288
+ };
1289
+ }
1290
+
1291
+ function cacheAgentsCatalog(agents, envelope, fetchedAtMs, stale) {
1292
+ cachedAgentsCatalog = Array.isArray(agents) ? agents : [];
1293
+ cachedAgentsEnvelope = {
1294
+ defaultId:
1295
+ envelope && typeof envelope.defaultId === "string" && envelope.defaultId
1296
+ ? envelope.defaultId
1297
+ : null,
1298
+ mainKey:
1299
+ envelope && typeof envelope.mainKey === "string" && envelope.mainKey
1300
+ ? envelope.mainKey
1301
+ : null,
1302
+ scope:
1303
+ envelope && typeof envelope.scope === "string" && envelope.scope
1304
+ ? envelope.scope
1305
+ : null,
1306
+ };
1307
+ cachedAgentsCatalogFetchedAt = Number.isFinite(fetchedAtMs)
1308
+ ? Math.floor(fetchedAtMs)
1309
+ : now();
1310
+ cachedAgentsCatalogStale = !!stale;
1311
+ const snapshot = agentsCatalogSnapshot(cachedAgentsCatalogFetchedAt);
1312
+
1313
+ broadcastAgentsCatalog(snapshot);
1314
+ return snapshot;
1315
+ }
1316
+
1317
+ function getAgentDisplayName(agentId) {
1318
+ if (typeof agentId !== "string" || !agentId.trim()) return null;
1319
+ const id = agentId.trim();
1320
+ if (!Array.isArray(cachedAgentsCatalog)) return null;
1321
+ const match = cachedAgentsCatalog.find((entry) => entry && entry.id === id);
1322
+ return match ? match.name : null;
1323
+ }
1324
+
919
1325
  function providerUsageCacheState(nowMs) {
920
1326
  const currentNow = Number.isFinite(nowMs) ? nowMs : now();
921
1327
  const hasCache =
@@ -1042,10 +1448,7 @@ export function createUpstreamRuntime(opts = {}) {
1042
1448
  cachedAuthProfileCounts.set(provider, count);
1043
1449
  }
1044
1450
  } 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).
1451
+
1049
1452
  emitDebug(
1050
1453
  "relay.session",
1051
1454
  "models_auth_status_refresh_failed",
@@ -1185,6 +1588,76 @@ export function createUpstreamRuntime(opts = {}) {
1185
1588
  });
1186
1589
  }
1187
1590
 
1591
+ async function refreshAgentsCatalog(force) {
1592
+ const snapshot = agentsCatalogSnapshot();
1593
+ if (agentsListUnsupported) {
1594
+ return snapshot;
1595
+ }
1596
+ if (!force && !snapshot.stale) {
1597
+ return snapshot;
1598
+ }
1599
+ if (inFlightAgentsCatalogFetch) {
1600
+ return inFlightAgentsCatalogFetch;
1601
+ }
1602
+ if (!openclawConnected) {
1603
+ return snapshot;
1604
+ }
1605
+
1606
+ inFlightAgentsCatalogFetch = gatewayBridge
1607
+ .request("agents.list", {})
1608
+ .then(async (result) => {
1609
+ const rawRows =
1610
+ result && Array.isArray(result.agents) ? result.agents : [];
1611
+
1612
+ const needIds = rawRows
1613
+ .filter(rawRowNeedsIdentityFallback)
1614
+ .map((r) => r.id.trim());
1615
+ if (needIds.length) {
1616
+ await Promise.all(
1617
+ needIds.map((id) => ensureWorkspaceIdentityFallback(id)),
1618
+ );
1619
+ }
1620
+ const overlaid = overlayRawAgentRowsWithFallback(
1621
+ rawRows,
1622
+ workspaceIdentityFallbackCache,
1623
+ );
1624
+ const agents = normalizeAgentsCatalogRows(overlaid);
1625
+ const envelope = {
1626
+ defaultId: result && result.defaultId,
1627
+ mainKey: result && result.mainKey,
1628
+ scope: result && result.scope,
1629
+ };
1630
+ return cacheAgentsCatalog(agents, envelope, Date.now(), false);
1631
+ })
1632
+ .catch((err) => {
1633
+ const message = err && err.message ? err.message : String(err);
1634
+
1635
+ if (isMethodNotFoundError(err, message)) {
1636
+ agentsListUnsupported = true;
1637
+ }
1638
+ emitDebug(
1639
+ "relay.session",
1640
+ "agents_catalog_refresh_failed",
1641
+ "warn",
1642
+ { sessionKey: sessionService.ensureSessionKey() },
1643
+ () => ({
1644
+ message,
1645
+ unsupported: agentsListUnsupported,
1646
+ hadCache: Array.isArray(cachedAgentsCatalog),
1647
+ }),
1648
+ );
1649
+ if (Array.isArray(cachedAgentsCatalog)) {
1650
+ cachedAgentsCatalogStale = true;
1651
+ return agentsCatalogSnapshot();
1652
+ }
1653
+ return cacheAgentsCatalog([], cachedAgentsEnvelope, now(), true);
1654
+ });
1655
+
1656
+ return inFlightAgentsCatalogFetch.finally(() => {
1657
+ inFlightAgentsCatalogFetch = null;
1658
+ });
1659
+ }
1660
+
1188
1661
  function trackAcceptedRun(entry) {
1189
1662
  if (!entry || !entry.runId) return;
1190
1663
  upstreamRunPipeline.set(entry.runId, {
@@ -1218,6 +1691,16 @@ export function createUpstreamRuntime(opts = {}) {
1218
1691
  return snapshot;
1219
1692
  }
1220
1693
 
1694
+ async function getAgentsCatalogSnapshot() {
1695
+ const snapshot = agentsCatalogSnapshot();
1696
+ if (snapshot.stale && !agentsListUnsupported && openclawConnected) {
1697
+ return refreshAgentsCatalog(true);
1698
+ }
1699
+
1700
+ broadcastAgentsCatalog(snapshot);
1701
+ return snapshot;
1702
+ }
1703
+
1221
1704
  async function getProviderUsageSnapshot() {
1222
1705
  const snapshot = projectProviderUsageSnapshot();
1223
1706
  if (snapshot.stale && openclawConnected) {
@@ -1227,15 +1710,7 @@ export function createUpstreamRuntime(opts = {}) {
1227
1710
  }
1228
1711
 
1229
1712
  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.
1713
+
1239
1714
  sessionContextService.refreshActiveSessionContext().catch(() => {});
1240
1715
 
1241
1716
  const snapshot = projectProviderUsageSnapshot();
@@ -1303,15 +1778,8 @@ export function createUpstreamRuntime(opts = {}) {
1303
1778
  };
1304
1779
  }
1305
1780
 
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
1781
  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.
1782
+
1315
1783
  const sessionContextService = createSessionContextService({
1316
1784
  gatewayBridge,
1317
1785
  stateDir: opts.stateDir,
@@ -1407,8 +1875,6 @@ export function createUpstreamRuntime(opts = {}) {
1407
1875
  });
1408
1876
  }
1409
1877
 
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
1878
  const SPAN_START_MARK = "\x01";
1413
1879
  const SPAN_END_MARK = "\x02";
1414
1880
 
@@ -1427,8 +1893,7 @@ export function createUpstreamRuntime(opts = {}) {
1427
1893
  for (const name of familyNames) empty[name] = [];
1428
1894
  return { text: `${prefix}${text}`, spansByFamily: empty };
1429
1895
  }
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.
1896
+
1432
1897
  const events = [];
1433
1898
  for (const family of familyNames) {
1434
1899
  const spans = spansByFamily[family];
@@ -1439,7 +1904,6 @@ export function createUpstreamRuntime(opts = {}) {
1439
1904
  }
1440
1905
  events.sort((a, b) => a.offset - b.offset || (a.isEnd ? 1 : -1));
1441
1906
 
1442
- // Build marked text, inserting one boundary marker per event.
1443
1907
  let markedText = "";
1444
1908
  let cursor = 0;
1445
1909
  for (const ev of events) {
@@ -1449,13 +1913,10 @@ export function createUpstreamRuntime(opts = {}) {
1449
1913
  }
1450
1914
  markedText += cleanText.slice(cursor);
1451
1915
 
1452
- // Run the full markdown pass on the marked text.
1453
1916
  const { text: rawPost } = conversationState._markdownToPlainText(markedText, {
1454
1917
  stripReplyTags: true,
1455
1918
  });
1456
1919
 
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
1920
  const eventPostPositions = [];
1460
1921
  let stripped = "";
1461
1922
  for (let j = 0; j < rawPost.length; j++) {
@@ -1489,32 +1950,10 @@ export function createUpstreamRuntime(opts = {}) {
1489
1950
  const runId = data.runId || null;
1490
1951
  if (runId) {
1491
1952
  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.
1953
+
1504
1954
  clearStreamingThrottleTimer();
1505
1955
  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.
1956
+
1518
1957
  broadcastActivity({
1519
1958
  state: "idle",
1520
1959
  runId,
@@ -1567,8 +2006,6 @@ export function createUpstreamRuntime(opts = {}) {
1567
2006
  }),
1568
2007
  );
1569
2008
 
1570
- // (Moved above: flushPendingStreamingText now runs before broadcastActivity
1571
- // so the client never sees a chunk arrive after stream_pipeline_turn_idle.)
1572
2009
  const sanitizedContent =
1573
2010
  data.role === "assistant"
1574
2011
  ? sanitizeAssistantContentBlocks(data.content)
@@ -1584,8 +2021,7 @@ export function createUpstreamRuntime(opts = {}) {
1584
2021
  );
1585
2022
  }
1586
2023
  broadcastPages();
1587
- // Refresh session context (token count) after each committed assistant
1588
- // message. Runs async so it doesn't block the message handler.
2024
+
1589
2025
  sessionContextService.refreshActiveSessionContext().catch(() => {});
1590
2026
 
1591
2027
  const voiceRuntime = getVoiceRuntime();
@@ -1625,19 +2061,26 @@ export function createUpstreamRuntime(opts = {}) {
1625
2061
  ackToRunStartMs: runPipeline && runPipeline.ackAt ? (now - runPipeline.ackAt) : null,
1626
2062
  }),
1627
2063
  );
1628
- // Signal run-start to session-context service so WebUI can show the
1629
- // spinner / suppress stale token counts during an active run.
2064
+
1630
2065
  cachedRunActiveSessionKey = data.sessionKey || sessionService.ensureSessionKey();
1631
2066
  sessionContextService.broadcastRunActive(true);
1632
2067
  }
1633
2068
  if (runId && isTerminalActivityBoundary(data.state, phase, origin)) {
1634
2069
  stopTypingForRun(runId, "terminal_activity_boundary");
1635
- // Run ended normally — clear run-active state and refresh token count.
2070
+
1636
2071
  if (cachedRunActiveSessionKey) {
1637
2072
  cachedRunActiveSessionKey = null;
1638
2073
  sessionContextService.broadcastRunActive(false);
1639
2074
  sessionContextService.refreshActiveSessionContext().catch(() => {});
1640
2075
  }
2076
+
2077
+ if (!activeProviderContext().provider) {
2078
+ sessionService.getCurrentSessionModelConfig().catch((err) => {
2079
+ logger.warn(
2080
+ `[relay] Provider re-resolve after run-end failed: ${err && err.message ? err.message : err}`,
2081
+ );
2082
+ });
2083
+ }
1641
2084
  }
1642
2085
  let activity = data;
1643
2086
  let shouldRefreshProviderUsageInBackground = false;
@@ -1669,7 +2112,7 @@ export function createUpstreamRuntime(opts = {}) {
1669
2112
  }
1670
2113
  if (runId && data.state === "idle" && data.isError === true && phase === "error") {
1671
2114
  upstreamRunPipeline.delete(runId);
1672
- // Run ended with an error — clear run-active state and refresh token count.
2115
+
1673
2116
  if (cachedRunActiveSessionKey) {
1674
2117
  cachedRunActiveSessionKey = null;
1675
2118
  sessionContextService.broadcastRunActive(false);
@@ -1679,8 +2122,50 @@ export function createUpstreamRuntime(opts = {}) {
1679
2122
  });
1680
2123
 
1681
2124
  gatewayBridge.on("streaming", (data) => {
1682
- if (!sessionService.isCurrentSession(data.sessionKey)) return;
1683
- const sessionKey = data.sessionKey || sessionService.ensureSessionKey();
2125
+ if (isTitleDistillerStreamingEvent(data)) {
2126
+ const runId = data && data.runId ? data.runId : null;
2127
+ const sessionKey = data && data.sessionKey ? data.sessionKey : sessionService.ensureSessionKey();
2128
+ emitDebug(
2129
+ "openclaw.run",
2130
+ "streaming_ignored",
2131
+ "debug",
2132
+ { sessionKey, runId },
2133
+ () => ({
2134
+ reason: "title_distiller",
2135
+ textChars: typeof data.text === "string" ? data.text.length : 0,
2136
+ }),
2137
+ );
2138
+ return;
2139
+ }
2140
+ const runId = data.runId || null;
2141
+ const runPipeline = runId ? upstreamRunPipeline.get(runId) : null;
2142
+ const explicitSessionKey =
2143
+ data && typeof data.sessionKey === "string" && data.sessionKey.trim()
2144
+ ? data.sessionKey.trim()
2145
+ : null;
2146
+ const pipelineSessionKey =
2147
+ runPipeline &&
2148
+ typeof runPipeline.sessionKey === "string" &&
2149
+ runPipeline.sessionKey.trim()
2150
+ ? runPipeline.sessionKey.trim()
2151
+ : null;
2152
+ const sessionKey =
2153
+ explicitSessionKey ||
2154
+ pipelineSessionKey;
2155
+ if (!sessionKey) {
2156
+ emitDebug(
2157
+ "openclaw.run",
2158
+ "streaming_ignored",
2159
+ "debug",
2160
+ { sessionKey: null, runId },
2161
+ () => ({
2162
+ reason: "unknown_sessionless_run",
2163
+ textChars: typeof data.text === "string" ? data.text.length : 0,
2164
+ }),
2165
+ );
2166
+ return;
2167
+ }
2168
+ if (!sessionService.isCurrentSession(sessionKey)) return;
1684
2169
  const { provider: outcomeProvider } = activeProviderContext();
1685
2170
  if (outcomeProvider) {
1686
2171
  providerOutcomeState.set(outcomeProvider, {
@@ -1688,7 +2173,6 @@ export function createUpstreamRuntime(opts = {}) {
1688
2173
  lastOutcomeAtMs: now(),
1689
2174
  });
1690
2175
  }
1691
- const runId = data.runId || null;
1692
2176
  const nowMs = now();
1693
2177
  const gatewayReceivedAtMs = Number.isFinite(data.gatewayReceivedAtMs)
1694
2178
  ? Math.floor(data.gatewayReceivedAtMs)
@@ -1702,7 +2186,6 @@ export function createUpstreamRuntime(opts = {}) {
1702
2186
  const firstGatewayChunk =
1703
2187
  typeof data.firstGatewayChunk === "boolean" ? data.firstGatewayChunk : null;
1704
2188
  if (runId) {
1705
- const runPipeline = upstreamRunPipeline.get(runId);
1706
2189
  if (runPipeline && !runPipeline.firstStreamingAt) {
1707
2190
  runPipeline.firstStreamingAt = nowMs;
1708
2191
  runPipeline.firstGatewayReceivedAt = gatewayReceivedAtMs;
@@ -1730,9 +2213,7 @@ export function createUpstreamRuntime(opts = {}) {
1730
2213
  }
1731
2214
  }
1732
2215
  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.
2216
+
1736
2217
  pendingStreaming = {
1737
2218
  rawText: data.text,
1738
2219
  prefix,
@@ -1753,8 +2234,7 @@ export function createUpstreamRuntime(opts = {}) {
1753
2234
  runId,
1754
2235
  },
1755
2236
  () => ({
1756
- // Raw cumulative chars: the parsed/markdown length is no longer
1757
- // computed here (parse-on-flush moves it to flushPendingStreamingText).
2237
+
1758
2238
  textChars: pendingStreaming ? pendingStreaming.rawText.length : 0,
1759
2239
  rawAssistantChars,
1760
2240
  assistantDeltaChars,
@@ -1789,8 +2269,7 @@ export function createUpstreamRuntime(opts = {}) {
1789
2269
  refreshUpstreamBootstrap("connected_event").catch((err) => {
1790
2270
  logger.warn(`[relay] Upstream connected bootstrap failed: ${err.message}`);
1791
2271
  });
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.
2272
+
1794
2273
  sessionContextService.refreshActiveSessionContext().catch(() => {});
1795
2274
  });
1796
2275
 
@@ -1890,6 +2369,21 @@ export function createUpstreamRuntime(opts = {}) {
1890
2369
  );
1891
2370
  });
1892
2371
 
2372
+ gatewayBridge.on("connectFailed", (info) => {
2373
+
2374
+ emitDebug(
2375
+ "relay.transport",
2376
+ "connect_failed",
2377
+ "warn",
2378
+ { sessionKey: sessionService.ensureSessionKey() },
2379
+ () => ({
2380
+ reason: info && info.reason ? info.reason : null,
2381
+ minProtocol: info && info.minProtocol != null ? info.minProtocol : null,
2382
+ maxProtocol: info && info.maxProtocol != null ? info.maxProtocol : null,
2383
+ }),
2384
+ );
2385
+ });
2386
+
1893
2387
  return {
1894
2388
  clearTyping,
1895
2389
  compactActiveSession: (sessionKey) =>
@@ -1900,6 +2394,8 @@ export function createUpstreamRuntime(opts = {}) {
1900
2394
  getAgentAvatarHash,
1901
2395
  getAgentAvatarDataUriByHash,
1902
2396
  getModelsCatalogSnapshot,
2397
+ getAgentsCatalogSnapshot,
2398
+ getAgentDisplayName,
1903
2399
  getProviderUsageSnapshot,
1904
2400
  getSkillsCatalogSnapshot,
1905
2401
  handleCurrentSessionModelConfigChanged,
@@ -1917,6 +2413,7 @@ export function createUpstreamRuntime(opts = {}) {
1917
2413
  activeTyping = null;
1918
2414
  inFlightModelsCatalogFetch = null;
1919
2415
  inFlightSkillsCatalogFetch = null;
2416
+ inFlightAgentsCatalogFetch = null;
1920
2417
  upstreamRunPipeline.clear();
1921
2418
  },
1922
2419
  trackAcceptedRun,