clawconnect-agent 0.2.6 → 0.2.7

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 (118) hide show
  1. package/README.md +7 -3
  2. package/README.zh-CN.md +7 -3
  3. package/dist/commands/hermes-file-transfer-outcome.d.ts +18 -0
  4. package/dist/commands/hermes-file-transfer-outcome.js +40 -0
  5. package/dist/commands/hermes-file-transfer-outcome.js.map +1 -0
  6. package/dist/commands/send-file.js +39 -33
  7. package/dist/commands/send-file.js.map +1 -1
  8. package/dist/core/command-types.d.ts +5 -0
  9. package/dist/core/relay/chat-payload.js +32 -22
  10. package/dist/core/relay/chat-payload.js.map +1 -1
  11. package/dist/core/relay/file-upload.d.ts +1 -0
  12. package/dist/core/relay/file-upload.js +3 -0
  13. package/dist/core/relay/file-upload.js.map +1 -1
  14. package/dist/core/relay/mobile-chat-run-bridge.d.ts +53 -8
  15. package/dist/core/relay/mobile-chat-run-bridge.js +45 -0
  16. package/dist/core/relay/mobile-chat-run-bridge.js.map +1 -1
  17. package/dist/core/relay/office-payload.js +54 -22
  18. package/dist/core/relay/office-payload.js.map +1 -1
  19. package/dist/core/relay/source-commit.d.ts +41 -0
  20. package/dist/core/relay/source-commit.js +118 -0
  21. package/dist/core/relay/source-commit.js.map +1 -0
  22. package/dist/core/relay/timeline-event-builder.d.ts +6 -0
  23. package/dist/core/relay/timeline-event-builder.js +3 -0
  24. package/dist/core/relay/timeline-event-builder.js.map +1 -1
  25. package/dist/core/relay/timeline-event-log.d.ts +29 -0
  26. package/dist/core/relay/timeline-event-log.js +37 -0
  27. package/dist/core/relay/timeline-event-log.js.map +1 -1
  28. package/dist/core/relay/timeline-projection-v3.d.ts +63 -0
  29. package/dist/core/relay/timeline-projection-v3.js +87 -0
  30. package/dist/core/relay/timeline-projection-v3.js.map +1 -0
  31. package/dist/hermes/hermes-relay-manager.js +3 -0
  32. package/dist/hermes/hermes-relay-manager.js.map +1 -1
  33. package/dist/hermes/hermes-runtime-test-support.d.ts +3 -3
  34. package/dist/hermes/hermes-runtime-test-support.js +12 -6
  35. package/dist/hermes/hermes-runtime-test-support.js.map +1 -1
  36. package/dist/hermes/runtime/hermes-file-transfer-state.d.ts +33 -0
  37. package/dist/hermes/runtime/hermes-file-transfer-state.js +139 -0
  38. package/dist/hermes/runtime/hermes-file-transfer-state.js.map +1 -0
  39. package/dist/hermes/runtime/hermes-mobile-file-route-store.d.ts +17 -0
  40. package/dist/hermes/runtime/hermes-mobile-file-route-store.js +103 -0
  41. package/dist/hermes/runtime/hermes-mobile-file-route-store.js.map +1 -0
  42. package/dist/hermes/runtime/hermes-runtime-api-client.d.ts +1 -0
  43. package/dist/hermes/runtime/hermes-runtime-api-client.js +16 -0
  44. package/dist/hermes/runtime/hermes-runtime-api-client.js.map +1 -1
  45. package/dist/hermes/runtime/hermes-runtime-chat.d.ts +35 -4
  46. package/dist/hermes/runtime/hermes-runtime-chat.js +426 -51
  47. package/dist/hermes/runtime/hermes-runtime-chat.js.map +1 -1
  48. package/dist/hermes/runtime/hermes-runtime-command-router.js +9 -1
  49. package/dist/hermes/runtime/hermes-runtime-command-router.js.map +1 -1
  50. package/dist/hermes/runtime/hermes-runtime-cron.d.ts +1 -0
  51. package/dist/hermes/runtime/hermes-runtime-cron.js +34 -66
  52. package/dist/hermes/runtime/hermes-runtime-cron.js.map +1 -1
  53. package/dist/hermes/runtime/hermes-runtime-history-completion.d.ts +8 -1
  54. package/dist/hermes/runtime/hermes-runtime-history-completion.js +112 -41
  55. package/dist/hermes/runtime/hermes-runtime-history-completion.js.map +1 -1
  56. package/dist/hermes/runtime/hermes-runtime-history.js +92 -17
  57. package/dist/hermes/runtime/hermes-runtime-history.js.map +1 -1
  58. package/dist/hermes/runtime/hermes-runtime-models.js +4 -1
  59. package/dist/hermes/runtime/hermes-runtime-models.js.map +1 -1
  60. package/dist/hermes/runtime/hermes-runtime-process.js +4 -3
  61. package/dist/hermes/runtime/hermes-runtime-process.js.map +1 -1
  62. package/dist/hermes/runtime/hermes-runtime-state-db.d.ts +6 -0
  63. package/dist/hermes/runtime/hermes-runtime-state-db.js +20 -1
  64. package/dist/hermes/runtime/hermes-runtime-state-db.js.map +1 -1
  65. package/dist/hermes/runtime/hermes-runtime-tool-log-watcher.js +12 -20
  66. package/dist/hermes/runtime/hermes-runtime-tool-log-watcher.js.map +1 -1
  67. package/dist/index.js +16 -2
  68. package/dist/index.js.map +1 -1
  69. package/dist/openclaw/config/provider-config.js +4 -1
  70. package/dist/openclaw/config/provider-config.js.map +1 -1
  71. package/dist/openclaw/relay/agent-identity-helpers.d.ts +9 -0
  72. package/dist/openclaw/relay/agent-identity-helpers.js +118 -0
  73. package/dist/openclaw/relay/agent-identity-helpers.js.map +1 -0
  74. package/dist/openclaw/relay/assistant-media-sidecar.d.ts +19 -2
  75. package/dist/openclaw/relay/assistant-media-sidecar.js +131 -15
  76. package/dist/openclaw/relay/assistant-media-sidecar.js.map +1 -1
  77. package/dist/openclaw/relay/chat-history.d.ts +37 -2
  78. package/dist/openclaw/relay/chat-history.js +565 -51
  79. package/dist/openclaw/relay/chat-history.js.map +1 -1
  80. package/dist/openclaw/relay/chat-run-identity.js +3 -1
  81. package/dist/openclaw/relay/chat-run-identity.js.map +1 -1
  82. package/dist/openclaw/relay/gateway-history-projection.d.ts +4 -0
  83. package/dist/openclaw/relay/gateway-history-projection.js +67 -0
  84. package/dist/openclaw/relay/gateway-history-projection.js.map +1 -0
  85. package/dist/openclaw/relay/openclaw-active-run-state.d.ts +67 -0
  86. package/dist/openclaw/relay/openclaw-active-run-state.js +283 -0
  87. package/dist/openclaw/relay/openclaw-active-run-state.js.map +1 -0
  88. package/dist/openclaw/relay/openclaw-message-tool-real-shape.fixture.d.ts +12 -0
  89. package/dist/openclaw/relay/openclaw-message-tool-real-shape.fixture.js +48 -0
  90. package/dist/openclaw/relay/openclaw-message-tool-real-shape.fixture.js.map +1 -0
  91. package/dist/openclaw/relay/openclaw-source-commit-watcher.d.ts +22 -0
  92. package/dist/openclaw/relay/openclaw-source-commit-watcher.js +64 -0
  93. package/dist/openclaw/relay/openclaw-source-commit-watcher.js.map +1 -0
  94. package/dist/openclaw/relay/outgoing-media-relay.d.ts +30 -3
  95. package/dist/openclaw/relay/outgoing-media-relay.js +407 -118
  96. package/dist/openclaw/relay/outgoing-media-relay.js.map +1 -1
  97. package/dist/openclaw/relay/relay-manager-history-timing.d.ts +1 -4
  98. package/dist/openclaw/relay/relay-manager-history-timing.js +4 -4
  99. package/dist/openclaw/relay/relay-manager-history-timing.js.map +1 -1
  100. package/dist/openclaw/relay/relay-manager-payload-helpers.d.ts +28 -1
  101. package/dist/openclaw/relay/relay-manager-payload-helpers.js +219 -11
  102. package/dist/openclaw/relay/relay-manager-payload-helpers.js.map +1 -1
  103. package/dist/openclaw/relay/session-context.d.ts +15 -0
  104. package/dist/openclaw/relay/session-context.js +48 -4
  105. package/dist/openclaw/relay/session-context.js.map +1 -1
  106. package/dist/openclaw/relay/source-commit-projection.d.ts +25 -0
  107. package/dist/openclaw/relay/source-commit-projection.js +140 -0
  108. package/dist/openclaw/relay/source-commit-projection.js.map +1 -0
  109. package/dist/openclaw/relay/transcript-dedupe.js +11 -42
  110. package/dist/openclaw/relay/transcript-dedupe.js.map +1 -1
  111. package/dist/openclaw/relay-manager.js +323 -135
  112. package/dist/openclaw/relay-manager.js.map +1 -1
  113. package/dist/openclaw/runtime/local-runtime.js +4 -2
  114. package/dist/openclaw/runtime/local-runtime.js.map +1 -1
  115. package/package.json +1 -1
  116. package/dist/openclaw/session-store.d.ts +0 -6
  117. package/dist/openclaw/session-store.js +0 -231
  118. package/dist/openclaw/session-store.js.map +0 -1
@@ -4,25 +4,36 @@ import { handleLocalCommand } from "./handlers/local-handlers.js";
4
4
  import { handleProviderCommand } from "./handlers/provider-handlers.js";
5
5
  import { DEFAULT_GATEWAY_SESSION_DEFAULTS, buildContextUsageFingerprint, contextUsageSnapshotFromSessionsList, readContextUsageSnapshot, canonicalizeRelayParams, canonicalizeSessionKey, extractGatewaySessionDefaults, } from "./relay/session-context.js";
6
6
  import { appendUniqueSuffix, extractChatRole, extractChatText, normalizeChatEventPayload, normalizeChatState, withMessageText, } from "../core/relay/chat-payload.js";
7
- import { canonicalizeOpenClawGatewayHistoryResponse, extractHistoryOutcome, readOpenClawTranscriptChatHistory, withTimeout, } from "./relay/chat-history.js";
7
+ import { canonicalizeOpenClawGatewayHistoryResponse, extractHistoryMediaContent, extractExplicitParentMediaContent, extractHistoryOutcome, readOpenClawSourceCommitCursor, readOpenClawTranscriptChatHistory, withTimeout, } from "./relay/chat-history.js";
8
+ import { watchOpenClawSourceCommit } from "./relay/openclaw-source-commit-watcher.js";
9
+ import { projectSourceCommitHistoryPages } from "./relay/source-commit-projection.js";
10
+ import { join } from "node:path";
11
+ import { resolveOpenClawStateDir } from "./runtime/openclaw-paths.js";
12
+ import { openClawAgentIdFromSessionKey } from "../core/relay/timeline-projection-v3.js";
8
13
  import { OpenClawChatSendDedupeCoordinator, } from "./relay/chat-send-dedupe-coordinator.js";
9
- import { buildMobileAssistantDeltaPayload, buildMobileAssistantErrorPayload, buildMobileAssistantFinalPayload, } from "../core/relay/mobile-chat-run-bridge.js";
14
+ import { buildMobileAssistantDeltaPayload, buildMobileAssistantCompletedPayload, buildMobileAssistantErrorPayload, buildMobileAssistantFinalPayload, } from "../core/relay/mobile-chat-run-bridge.js";
10
15
  import { LatestSnapshotCoalescer, buildAssistantStreamSnapshotKey, } from "../core/relay/assistant-stream-coalescer.js";
11
16
  import { reliableRelayOutboxForGateway } from "../core/relay/reliable-relay-outbox-registry.js";
12
17
  import { isValidRelayHello, reliableDeliveryModeFromRelayHello, RELAY_HELLO_NEGOTIATION_TIMEOUT_MS, } from "../core/relay/reliable-delivery-protocol.js";
13
18
  import { buildChatSendIdempotencyRequest, ChatSendIdempotencyGuard, } from "../core/relay/chat-send-idempotency.js";
14
19
  import { prepareChatSendParams } from "./relay/chat-send-attachments.js";
15
- import { canonicalizeOpenClawAssistantMediaSidecarPayload } from "./relay/assistant-media-sidecar.js";
20
+ import { canonicalizeOpenClawAssistantMediaSidecarPayload, materializeOpenClawDisplayContentPayload, } from "./relay/assistant-media-sidecar.js";
16
21
  import { relayOutgoingMediaInHistoryResponse, relayOutgoingMediaInPayload, } from "./relay/outgoing-media-relay.js";
17
22
  import { prepareOpenClawVoiceInputCommand } from "./relay/openclaw-voice-input.js";
18
23
  import { voiceInputSetupMessage } from "../core/relay/voice-input.js";
19
24
  import { buildRelayHelloMessage } from "./relay/relay-manager-hello.js";
20
25
  import { buildOpenClawHostRuntimeMetadata } from "../runtime-metadata.js";
21
- import { CHAT_HISTORY_FALLBACK_INITIAL_DELAY_MS, CHAT_HISTORY_FALLBACK_MAX_ATTEMPTS, CHAT_HISTORY_FALLBACK_RETRY_DELAY_MS, CHAT_HISTORY_FETCH_TIMEOUT_MS, CHAT_HISTORY_FINAL_RETRY_DELAY_MS, } from "./relay/relay-manager-history-timing.js";
26
+ import { CHAT_HISTORY_FETCH_TIMEOUT_MS, CHAT_HISTORY_MEDIA_ENRICHMENT_TIMEOUT_MS, } from "./relay/relay-manager-history-timing.js";
22
27
  import { canonicalizeOpenClawChatSendResult, normalizeOpenClawMobileRunId, openClawChatRunIdentities, resolveExplicitMobileRunIdFromChatPayload, restoreOpenClawProviderRunIdForCommand, } from "./relay/chat-run-identity.js";
28
+ import { markOpenClawActiveRunTerminal, recordOpenClawActiveRun, updateOpenClawActiveRunProviderId, } from "./relay/openclaw-active-run-state.js";
23
29
  import { publishOfficeSnapshot } from "./relay/relay-manager-office-events.js";
30
+ import { enrichAgentsListWithIdentities } from "./relay/agent-identity-helpers.js";
24
31
  export { buildRelayHelloMessage } from "./relay/relay-manager-hello.js";
25
- import { buildEmptyHistoryPage, buildFinalPayloadFromHistoryOutcome, buildOpenClawGatewayHistoryParams, extractChatErrorMessage, hasHistoryCursor, mergeCanonicalChatPayload, mobileAssistantUsageFromPayload, nonTextContentBlocks, nonTextContentBlocksFromHistory, resolveChatPayloadSeq, resolveChatPayloadTimestamp, } from "./relay/relay-manager-payload-helpers.js";
32
+ import { buildEmptyHistoryPage, buildFinalPayloadFromHistoryOutcome, buildOpenClawGatewayHistoryParams, assistantHistoryReplyMessageId, assistantReplyMessageId, extractChatErrorMessage, extractChatLineage, hasHistoryCursor, mergeCanonicalChatPayload, mobileAssistantUsageFromPayload, mergeNonTextContentBlocks, nonTextContentBlocks, nonTextContentBlocksFromHistory, resolveChatPayloadSeq, resolveChatPayloadTimestamp, } from "./relay/relay-manager-payload-helpers.js";
33
+ function hasCanonicalProjectionV3Snapshot(history) {
34
+ return history?.projectionVersion === 3
35
+ && (history.timelineSnapshot?.messages ?? []).some((message) => message.projectionVersion === 3);
36
+ }
26
37
  // 保留在连接实例之外,使 Relay WebSocket 断线重连后的同轮重投仍能复用终态结果。
27
38
  const openClawChatSendIdempotency = new ChatSendIdempotencyGuard();
28
39
  // ---------------------------------------------------------------------------
@@ -62,7 +73,7 @@ export async function runRelayManager(opts) {
62
73
  let sessionDefaults = { ...DEFAULT_GATEWAY_SESSION_DEFAULTS };
63
74
  const subscribedSessionMessageKeys = new Set();
64
75
  const pendingSessionMessageSubscriptions = new Map();
65
- const chatFallbacks = new Map();
76
+ const sourceCommitWatchers = new Map();
66
77
  const chatRunContexts = new Map();
67
78
  const chatSendDedupe = new OpenClawChatSendDedupeCoordinator(() => sessionDefaults);
68
79
  const outgoingMediaUploadCache = new Map();
@@ -89,11 +100,16 @@ export async function runRelayManager(opts) {
89
100
  },
90
101
  });
91
102
  };
92
- const clearChatFallback = (runId) => {
93
- const timer = chatFallbacks.get(runId);
94
- if (timer) {
95
- clearTimeout(timer);
96
- chatFallbacks.delete(runId);
103
+ const setChatRunContext = (providerRunId, context) => {
104
+ chatRunContexts.set(providerRunId, context);
105
+ // Delivery-mirror terminals can arrive after the provider terminal. Keep
106
+ // enough completed contexts for deterministic history reconciliation;
107
+ // bound the map explicitly instead of using a timer as correctness.
108
+ while (chatRunContexts.size > 2048) {
109
+ const oldest = chatRunContexts.keys().next().value;
110
+ if (!oldest)
111
+ break;
112
+ chatRunContexts.delete(oldest);
97
113
  }
98
114
  };
99
115
  const publishContextUsageSnapshot = async (sessionKey, force = false) => {
@@ -144,73 +160,19 @@ export async function runRelayManager(opts) {
144
160
  timer.unref?.();
145
161
  contextUsageRefreshes.set(key, timer);
146
162
  };
147
- const scheduleChatHistoryFallback = (providerRunId, context, attempt = 0) => {
148
- if (!providerRunId || !context.sessionKey) {
149
- return;
163
+ const explicitOpenClawSessionKey = (rawSessionKey) => {
164
+ const canonical = canonicalizeSessionKey(rawSessionKey, sessionDefaults);
165
+ const normalized = typeof canonical === "string" ? canonical.trim() : "";
166
+ if (normalized === "main") {
167
+ return `agent:${sessionDefaults.defaultAgentId ?? "main"}:${sessionDefaults.mainKey || "main"}`;
150
168
  }
151
- clearChatFallback(providerRunId);
152
- chatRunContexts.set(providerRunId, context);
153
- const timer = setTimeout(() => {
154
- if (!gatewayClient) {
155
- chatFallbacks.delete(providerRunId);
156
- return;
157
- }
158
- const fetchHistory = () => requestChatHistoryFromClawConnect({ sessionKey: context.sessionKey, limit: 10 });
159
- withTimeout(fetchHistory(), CHAT_HISTORY_FETCH_TIMEOUT_MS, "chat.history fallback")
160
- .then(async (history) => {
161
- const outcome = extractHistoryOutcome(history, context);
162
- if ((!outcome || isOpenClawMediaDisplayPlaceholder(outcome.kind === "final" ? outcome.text : ""))
163
- && attempt < CHAT_HISTORY_FALLBACK_MAX_ATTEMPTS) {
164
- scheduleChatHistoryFallback(providerRunId, context, attempt + 1);
165
- return;
166
- }
167
- if (!outcome) {
168
- chatFallbacks.delete(providerRunId);
169
- chatRunContexts.delete(providerRunId);
170
- openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
171
- return;
172
- }
173
- clearChatFallback(providerRunId);
174
- chatRunContexts.delete(providerRunId);
175
- if (outcome.kind === "final") {
176
- openClawChatRunIdentities.markTerminal(opts.gatewayId, providerRunId);
177
- const basePayload = buildMobileAssistantFinalPayload({
178
- run: { runId: context.canonicalRunId, sessionKey: context.sessionKey },
179
- text: outcome.text,
180
- includeTimelineEvents: true,
181
- });
182
- await publishAndSendGatewayEvent("chat", buildFinalPayloadFromHistoryOutcome(basePayload, outcome), true, context.promptText);
183
- openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
184
- return;
185
- }
186
- openClawChatRunIdentities.markTerminal(opts.gatewayId, providerRunId);
187
- await publishAndSendGatewayEvent("chat", buildMobileAssistantErrorPayload({
188
- run: { runId: context.canonicalRunId, sessionKey: context.sessionKey },
189
- errorMessage: outcome.errorMessage,
190
- includeTimelineEvents: true,
191
- }), true);
192
- openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
193
- })
194
- .catch((err) => {
195
- if (attempt < CHAT_HISTORY_FALLBACK_MAX_ATTEMPTS) {
196
- scheduleChatHistoryFallback(providerRunId, context, attempt + 1);
197
- return;
198
- }
199
- console.warn(`[relay] chat history fallback failed runId=${providerRunId}: ${String(err)}`);
200
- chatFallbacks.delete(providerRunId);
201
- chatRunContexts.delete(providerRunId);
202
- openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
203
- });
204
- }, attempt === 0 ? CHAT_HISTORY_FALLBACK_INITIAL_DELAY_MS : CHAT_HISTORY_FALLBACK_RETRY_DELAY_MS);
205
- timer.unref?.();
206
- chatFallbacks.set(providerRunId, timer);
169
+ return normalized || rawSessionKey.trim();
207
170
  };
208
171
  const ensureSessionMessagesSubscribed = async (sessionKey) => {
209
- const normalizedSessionKey = canonicalizeSessionKey(sessionKey, sessionDefaults);
210
- if (typeof normalizedSessionKey !== "string" || normalizedSessionKey.trim().length === 0) {
172
+ const key = explicitOpenClawSessionKey(sessionKey);
173
+ if (!key) {
211
174
  throw new Error("cannot subscribe to an empty OpenClaw session key");
212
175
  }
213
- const key = normalizedSessionKey.trim();
214
176
  if (subscribedSessionMessageKeys.has(key)) {
215
177
  return;
216
178
  }
@@ -249,6 +211,7 @@ export async function runRelayManager(opts) {
249
211
  sessionDefaults = nextDefaults;
250
212
  }
251
213
  await ensureSessionMessagesSubscribed(sessionDefaults.mainSessionKey);
214
+ ensureSourceCommitWatcher(sessionDefaults.mainSessionKey);
252
215
  const sessionsPayload = await gatewayClient.request("sessions.list", {
253
216
  limit: 100,
254
217
  includeGlobal: true,
@@ -264,6 +227,11 @@ export async function runRelayManager(opts) {
264
227
  : undefined;
265
228
  if (typeof sessionKey !== "string" || !sessionKey.trim())
266
229
  continue;
230
+ // Cron jobs can target an agent session other than the default one.
231
+ // Subscribe each known session so their finished replies travel through
232
+ // the same ClawConnect → Relay realtime path as an interactive chat.
233
+ await ensureSessionMessagesSubscribed(sessionKey);
234
+ ensureSourceCommitWatcher(sessionKey);
267
235
  const snapshot = contextUsageSnapshotFromSessionsList(sessionsPayload, sessionKey, sessionDefaults);
268
236
  if (!snapshot)
269
237
  continue;
@@ -336,6 +304,7 @@ export async function runRelayManager(opts) {
336
304
  cache: outgoingMediaUploadCache,
337
305
  userMessage,
338
306
  waitForOutgoingMediaRecord: true,
307
+ sessionDefaults,
339
308
  });
340
309
  }
341
310
  catch (error) {
@@ -371,14 +340,14 @@ export async function runRelayManager(opts) {
371
340
  console.warn(`[relay] assistant snapshot publish failed key=${key}: ${String(error)}`);
372
341
  },
373
342
  });
374
- const assistantSnapshotKey = (runId, sessionKey) => {
343
+ const assistantSnapshotKey = (runId, sessionKey, messageId) => {
375
344
  if (!runId?.trim() || !sessionKey?.trim()) {
376
345
  return undefined;
377
346
  }
378
347
  return buildAssistantStreamSnapshotKey({
379
348
  sessionKey,
380
349
  runId,
381
- messageId: `assistant-${runId}`,
350
+ ...(messageId ? { messageId } : { messageId: `assistant-${runId}` }),
382
351
  });
383
352
  };
384
353
  const runAfterAssistantSnapshot = (key, close, operation, releaseClosed = false) => {
@@ -419,7 +388,7 @@ export async function runRelayManager(opts) {
419
388
  const sessionKey = typeof record.sessionKey === "string" && record.sessionKey.trim()
420
389
  ? record.sessionKey.trim()
421
390
  : runContext?.sessionKey;
422
- return assistantSnapshotKey(runId, sessionKey);
391
+ return assistantSnapshotKey(runId, sessionKey, runId ? assistantReplyMessageId(payload, runId) : undefined);
423
392
  };
424
393
  async function relayOutgoingMediaForResponse(method, payload) {
425
394
  if (method !== "chat.history") {
@@ -431,12 +400,23 @@ export async function runRelayManager(opts) {
431
400
  gatewayId: opts.gatewayId,
432
401
  senderDisplayName: "OpenClaw",
433
402
  cache: outgoingMediaUploadCache,
403
+ sessionDefaults,
434
404
  });
435
405
  }
436
406
  async function requestChatHistoryFromClawConnect(params) {
407
+ // Relay-owned history reads opt into the v3 projection contract. The
408
+ // public legacy helper remains v2-compatible for older callers during
409
+ // rollout; every confirmed row consumed by this manager is v3.
410
+ const projectionParams = params && typeof params === "object" && !Array.isArray(params)
411
+ ? {
412
+ ...params,
413
+ projectionVersion: 3,
414
+ projectionGatewayId: opts.gatewayId,
415
+ }
416
+ : { projectionVersion: 3, projectionGatewayId: opts.gatewayId };
437
417
  try {
438
- await chatSendDedupe.dedupePendingForSession(params);
439
- const transcriptHistory = await readOpenClawTranscriptChatHistory(params, sessionDefaults);
418
+ await chatSendDedupe.dedupePendingForSession(projectionParams);
419
+ const transcriptHistory = await readOpenClawTranscriptChatHistory(projectionParams, sessionDefaults);
440
420
  if (transcriptHistory) {
441
421
  return transcriptHistory;
442
422
  }
@@ -445,7 +425,7 @@ export async function runRelayManager(opts) {
445
425
  console.warn(`[relay] transcript chat.history unavailable: ${String(err)}`);
446
426
  }
447
427
  if (hasHistoryCursor(params)) {
448
- return buildEmptyHistoryPage(params, sessionDefaults);
428
+ return buildEmptyHistoryPage(projectionParams, sessionDefaults);
449
429
  }
450
430
  if (!gatewayClient) {
451
431
  throw new Error("gateway not connected");
@@ -454,8 +434,56 @@ export async function runRelayManager(opts) {
454
434
  const history = await gatewayClient.request("chat.history", gatewayHistoryParams);
455
435
  return canonicalizeOpenClawGatewayHistoryResponse(history, {
456
436
  sessionKey: String(gatewayHistoryParams.sessionKey),
437
+ projectionVersion: 3,
438
+ projectionGatewayId: opts.gatewayId,
439
+ sessionDefaults,
457
440
  });
458
441
  }
442
+ const ensureSourceCommitWatcher = (sessionKey) => {
443
+ const normalizedSessionKey = explicitOpenClawSessionKey(sessionKey);
444
+ if (!normalizedSessionKey)
445
+ return;
446
+ const key = `${opts.gatewayId}\u0000${normalizedSessionKey}`;
447
+ if (sourceCommitWatchers.has(key))
448
+ return;
449
+ const agentId = openClawAgentIdFromSessionKey(normalizedSessionKey);
450
+ const databasePath = join(resolveOpenClawStateDir(), "agents", agentId, "agent", "openclaw-agent.sqlite");
451
+ const watcher = watchOpenClawSourceCommit({
452
+ databasePath,
453
+ readCursor: () => readOpenClawSourceCommitCursor({
454
+ sessionKey: normalizedSessionKey,
455
+ projectionVersion: 3,
456
+ projectionGatewayId: opts.gatewayId,
457
+ }, sessionDefaults),
458
+ onCommit: async (sourceCommit, previousCommittedThroughSeq) => {
459
+ await projectSourceCommitHistoryPages({
460
+ sourceCommit,
461
+ previousCommittedThroughSeq,
462
+ readPage: (cursorSeq) => withTimeout(requestChatHistoryFromClawConnect({
463
+ sessionKey: normalizedSessionKey,
464
+ limit: 200,
465
+ cursor: `seq:${cursorSeq}`,
466
+ direction: "newer",
467
+ }), CHAT_HISTORY_FETCH_TIMEOUT_MS, "source commit history reconciliation"),
468
+ publish: async ({ sourceCommit: pageSourceCommit, events }) => {
469
+ const deliveryResult = await publishAndSendGatewayEvent("chat", {
470
+ state: "source_commit",
471
+ sessionKey: normalizedSessionKey,
472
+ sourceCommit: pageSourceCommit,
473
+ timelineEvents: events,
474
+ }, true, undefined, true);
475
+ if (deliveryResult?.status === "retryable") {
476
+ throw deliveryResult.error;
477
+ }
478
+ },
479
+ });
480
+ },
481
+ onError: (error) => {
482
+ console.warn(`[relay] source commit reconciliation failed session=${normalizedSessionKey}: ${String(error)}`);
483
+ },
484
+ });
485
+ sourceCommitWatchers.set(key, watcher);
486
+ };
459
487
  relayWs.on("open", () => {
460
488
  console.log(`Connected to relay server (gatewayId=${opts.gatewayId})`);
461
489
  opts.onConnected?.();
@@ -497,8 +525,28 @@ export async function runRelayManager(opts) {
497
525
  });
498
526
  },
499
527
  onEvent: (event, payload) => {
528
+ if (event === "session.message") {
529
+ // OpenClaw emits this subscription event after the transcript
530
+ // append has committed. It is the primary wake-up for source
531
+ // reconciliation; SQLite notifications and the low-frequency
532
+ // anti-entropy scan remain recovery paths for dropped events.
533
+ const record = payload && typeof payload === "object" && !Array.isArray(payload)
534
+ ? payload
535
+ : undefined;
536
+ const rawSessionKey = typeof record?.sessionKey === "string"
537
+ ? record.sessionKey.trim()
538
+ : "";
539
+ if (rawSessionKey) {
540
+ const sessionKey = canonicalizeSessionKey(rawSessionKey, sessionDefaults);
541
+ if (typeof sessionKey === "string" && sessionKey.trim()) {
542
+ ensureSourceCommitWatcher(sessionKey);
543
+ sourceCommitWatchers.get(`${opts.gatewayId}\u0000${sessionKey.trim()}`)?.notify();
544
+ }
545
+ }
546
+ return;
547
+ }
500
548
  const normalizedPayload = event === "chat"
501
- ? canonicalizeOpenClawAssistantMediaSidecarPayload(normalizeChatEventPayload(payload))
549
+ ? materializeOpenClawDisplayContentPayload(canonicalizeOpenClawAssistantMediaSidecarPayload(normalizeChatEventPayload(payload)))
502
550
  : payload;
503
551
  const shouldPublishOffice = event === "chat" || event === "agent" || event === "context_usage";
504
552
  if (event === "chat") {
@@ -509,10 +557,16 @@ export async function runRelayManager(opts) {
509
557
  const isTerminalState = state === "final" || state === "error" || state === "failed" || state === "fail" || state === "aborted";
510
558
  const sessionKey = typeof p?.sessionKey === "string" && p.sessionKey.trim().length > 0 ? p.sessionKey.trim() : undefined;
511
559
  const explicitCanonicalRunId = resolveExplicitMobileRunIdFromChatPayload(normalizedPayload);
560
+ const currentText = extractChatText(normalizedPayload);
561
+ const role = extractChatRole(normalizedPayload);
512
562
  let runContext = providerRunId
513
563
  ? chatRunContexts.get(providerRunId) ?? openClawChatRunIdentities.resolve(opts.gatewayId, providerRunId)
514
564
  : undefined;
515
- if (!runContext && providerRunId && sessionKey) {
565
+ // Event-only registration is safe only after the provider proves
566
+ // assistant provenance. A roleless terminal may contain a user
567
+ // echo or an unrelated provider event and must not create a run
568
+ // context from its text/session fields.
569
+ if (!runContext && providerRunId && sessionKey && role === "assistant") {
516
570
  runContext = {
517
571
  gatewayId: opts.gatewayId,
518
572
  providerRunId,
@@ -520,19 +574,37 @@ export async function runRelayManager(opts) {
520
574
  sessionKey,
521
575
  };
522
576
  openClawChatRunIdentities.ensure(runContext);
523
- chatRunContexts.set(providerRunId, runContext);
524
- }
525
- if (providerRunId && isStreamingState && openClawChatRunIdentities.isTerminal(opts.gatewayId, providerRunId)) {
526
- return;
577
+ setChatRunContext(providerRunId, runContext);
527
578
  }
528
579
  if (providerRunId && isTerminalState) {
529
580
  openClawChatRunIdentities.markTerminal(opts.gatewayId, providerRunId);
530
581
  }
531
582
  const canonicalRunId = runContext?.canonicalRunId ?? explicitCanonicalRunId ?? providerRunId;
532
583
  const resolvedSessionKey = sessionKey ?? runContext?.sessionKey;
533
- const currentText = extractChatText(normalizedPayload);
534
- const role = extractChatRole(normalizedPayload);
535
- const runSnapshotKey = assistantSnapshotKey(canonicalRunId, resolvedSessionKey);
584
+ if (isTerminalState && runContext && canonicalRunId && resolvedSessionKey) {
585
+ void markOpenClawActiveRunTerminal({
586
+ gatewayId: opts.gatewayId,
587
+ sessionKey: resolvedSessionKey,
588
+ sourceRunId: canonicalRunId,
589
+ }).catch((error) => {
590
+ console.warn(`[relay] failed to mark OpenClaw run terminal: ${String(error)}`);
591
+ });
592
+ }
593
+ const replyMessageId = role === "assistant" && canonicalRunId
594
+ ? assistantReplyMessageId(normalizedPayload, canonicalRunId)
595
+ : undefined;
596
+ const chatLineage = extractChatLineage(normalizedPayload);
597
+ const runSnapshotKey = assistantSnapshotKey(canonicalRunId, resolvedSessionKey, replyMessageId);
598
+ // OpenClaw's message tool may emit several independent assistant
599
+ // replies after a provider-level terminal snapshot. Keep filtering
600
+ // late stream deltas for the run-level text reply, but let a reply
601
+ // with its own upstream message/tool/media identity through.
602
+ if (providerRunId
603
+ && isStreamingState
604
+ && openClawChatRunIdentities.isTerminal(opts.gatewayId, providerRunId)
605
+ && !replyMessageId) {
606
+ return;
607
+ }
536
608
  if (isOpenClawHeartbeatText(currentText) || (runContext?.promptText && isOpenClawHeartbeatText(runContext.promptText))) {
537
609
  if (providerRunId && (state === "final" || state === "error" || state === "failed" || state === "fail" || state === "aborted")) {
538
610
  openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
@@ -543,6 +615,51 @@ export async function runRelayManager(opts) {
543
615
  }
544
616
  return;
545
617
  }
618
+ // A roleless error/abort is still a terminal lifecycle signal when
619
+ // it belongs to a previously registered provider run. Its identity
620
+ // comes from that run context; never infer it from the payload
621
+ // text. A roleless empty final is lifecycle-only and leaves source
622
+ // rows to the transcript/source-commit watcher. Text-bearing
623
+ // roleless deltas/finals remain rejected user/source echoes.
624
+ if (role !== "assistant") {
625
+ if (!runContext || !providerRunId || !canonicalRunId || !resolvedSessionKey) {
626
+ return;
627
+ }
628
+ if (state === "error" || state === "failed" || state === "fail" || state === "aborted") {
629
+ chatSendDedupe.scheduleForRun(providerRunId, 100);
630
+ chatRunContexts.delete(providerRunId);
631
+ runAfterAssistantSnapshot(runSnapshotKey, true, async () => {
632
+ await publishAndSendGatewayEvent(event, mergeCanonicalChatPayload(normalizedPayload, buildMobileAssistantErrorPayload({
633
+ run: { runId: canonicalRunId, sessionKey: resolvedSessionKey },
634
+ errorMessage: extractChatErrorMessage(normalizedPayload),
635
+ includeTimelineEvents: true,
636
+ })), shouldPublishOffice, runContext?.promptText);
637
+ openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
638
+ }, true);
639
+ return;
640
+ }
641
+ if (state === "final" && !currentText) {
642
+ chatRunContexts.delete(providerRunId);
643
+ const lifecyclePayload = normalizedPayload && typeof normalizedPayload === "object" && !Array.isArray(normalizedPayload)
644
+ ? (() => {
645
+ const payload = { ...normalizedPayload };
646
+ delete payload.message;
647
+ delete payload.content;
648
+ delete payload.text;
649
+ delete payload.delta;
650
+ return payload;
651
+ })()
652
+ : normalizedPayload;
653
+ runAfterAssistantSnapshot(runSnapshotKey, true, async () => {
654
+ await publishAndSendGatewayEvent(event, mergeCanonicalChatPayload(lifecyclePayload, buildMobileAssistantCompletedPayload({
655
+ run: { runId: canonicalRunId, sessionKey: resolvedSessionKey },
656
+ includeTimelineEvents: true,
657
+ })), shouldPublishOffice, runContext?.promptText);
658
+ openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
659
+ }, true);
660
+ }
661
+ return;
662
+ }
546
663
  let realtimePayload = providerRunId && canonicalRunId && canonicalRunId !== providerRunId
547
664
  ? {
548
665
  ...normalizedPayload,
@@ -554,9 +671,6 @@ export async function runRelayManager(opts) {
554
671
  if (state === "final" || state === "error" || state === "failed" || state === "fail" || state === "aborted") {
555
672
  chatSendDedupe.scheduleForRun(providerRunId, 100);
556
673
  }
557
- if (role === "assistant" && (state === "delta" || state === "final" || state === "error" || state === "failed" || state === "fail")) {
558
- clearChatFallback(providerRunId);
559
- }
560
674
  if (state === "delta" || state === "streaming" || state === "in_progress") {
561
675
  const previousText = openClawChatRunIdentities.accumulatedText(opts.gatewayId, providerRunId);
562
676
  const bufferedText = appendUniqueSuffix(previousText, currentText);
@@ -568,6 +682,7 @@ export async function runRelayManager(opts) {
568
682
  seq: resolveChatPayloadSeq(normalizedPayload),
569
683
  timestampMs: resolveChatPayloadTimestamp(normalizedPayload),
570
684
  delta: bufferedText,
685
+ ...chatLineage,
571
686
  includeTimelineEvents: true,
572
687
  }))
573
688
  : withMessageText(normalizedPayload, bufferedText);
@@ -595,27 +710,79 @@ export async function runRelayManager(opts) {
595
710
  });
596
711
  return;
597
712
  }
598
- if (state === "final" && resolvedSessionKey) {
713
+ if (state === "final" && resolvedSessionKey && role === "assistant") {
599
714
  scheduleContextUsageRefresh(resolvedSessionKey, 450);
600
715
  const bufferedText = providerRunId
601
716
  ? openClawChatRunIdentities.accumulatedText(opts.gatewayId, providerRunId)
602
717
  : "";
603
718
  const resolvedText = appendUniqueSuffix(bufferedText, currentText);
604
719
  if (resolvedText.trim() && !isOpenClawMediaDisplayPlaceholder(resolvedText)) {
605
- const outgoingPayload = providerRunId
606
- ? mergeCanonicalChatPayload(normalizedPayload, buildMobileAssistantFinalPayload({
607
- run: { runId: canonicalRunId, sessionKey: resolvedSessionKey },
608
- text: resolvedText,
609
- contentBlocks: nonTextContentBlocks(normalizedPayload),
610
- includeTimelineEvents: true,
611
- ...mobileAssistantUsageFromPayload(normalizedPayload),
612
- }))
613
- : withMessageText(normalizedPayload, resolvedText);
614
- runAfterAssistantSnapshot(runSnapshotKey, true, async () => {
720
+ // A provider-level terminal without a reply identity stays
721
+ // open for later text-only delivery terminals. Each such
722
+ // terminal re-reads the current turn history and reconciles
723
+ // the canonical media snapshot deterministically.
724
+ runAfterAssistantSnapshot(runSnapshotKey, Boolean(replyMessageId), async () => {
725
+ let outgoingPayload;
726
+ const directContentBlocks = nonTextContentBlocks(normalizedPayload);
727
+ if (providerRunId && runContext) {
728
+ try {
729
+ const history = await withTimeout(requestChatHistoryFromClawConnect({ sessionKey: resolvedSessionKey, limit: 10 }), CHAT_HISTORY_MEDIA_ENRICHMENT_TIMEOUT_MS, "chat.history media enrichment");
730
+ let outcome = extractHistoryOutcome(history, runContext, currentText);
731
+ // A message-tool delivery can reach the gateway as a
732
+ // text-only terminal before OpenClaw commits its
733
+ // display-media projection. Always inspect the whole
734
+ // matched turn, not just the selected outcome row, so
735
+ // either a direct image or a later delivery row can
736
+ // keep the terminal media-complete.
737
+ let historyContentBlocks = outcome?.kind === "final"
738
+ ? hasCanonicalProjectionV3Snapshot(history)
739
+ ? mergeNonTextContentBlocks(nonTextContentBlocksFromHistory(outcome.message), extractExplicitParentMediaContent(history, outcome.message))
740
+ : extractHistoryMediaContent(history, runContext)
741
+ : [];
742
+ if (outcome?.kind === "final" && historyContentBlocks.length > 0) {
743
+ // A text terminal is not guaranteed to carry the
744
+ // message-tool identity. Recover it from the stable
745
+ // transcript delivery row before building timeline
746
+ // events; otherwise every sidecar in the run is
747
+ // projected onto assistant-${runId}.
748
+ const historyReplyMessageId = assistantHistoryReplyMessageId(outcome.message, canonicalRunId);
749
+ const mergedContentBlocks = mergeNonTextContentBlocks(directContentBlocks, historyContentBlocks);
750
+ const basePayload = mergeCanonicalChatPayload(normalizedPayload, buildMobileAssistantFinalPayload({
751
+ run: { runId: canonicalRunId, sessionKey: resolvedSessionKey },
752
+ text: resolvedText,
753
+ contentBlocks: mergedContentBlocks,
754
+ ...chatLineage,
755
+ ...((replyMessageId ?? historyReplyMessageId)
756
+ ? { messageId: replyMessageId ?? historyReplyMessageId }
757
+ : {}),
758
+ includeTimelineEvents: true,
759
+ ...mobileAssistantUsageFromPayload(normalizedPayload),
760
+ }));
761
+ outgoingPayload = buildFinalPayloadFromHistoryOutcome(basePayload, outcome, [{ type: "text", text: outcome.text }, ...mergedContentBlocks]);
762
+ }
763
+ }
764
+ catch (error) {
765
+ // Media enrichment is opportunistic. The final text event
766
+ // must still reach the client when history is unavailable.
767
+ console.warn(`[relay] terminal media history enrichment skipped: ${String(error)}`);
768
+ }
769
+ }
770
+ if (outgoingPayload === undefined) {
771
+ outgoingPayload = providerRunId
772
+ ? mergeCanonicalChatPayload(normalizedPayload, buildMobileAssistantFinalPayload({
773
+ run: { runId: canonicalRunId, sessionKey: resolvedSessionKey },
774
+ text: resolvedText,
775
+ contentBlocks: directContentBlocks,
776
+ ...chatLineage,
777
+ ...(replyMessageId ? { messageId: replyMessageId } : {}),
778
+ includeTimelineEvents: true,
779
+ ...mobileAssistantUsageFromPayload(normalizedPayload),
780
+ }))
781
+ : withMessageText(normalizedPayload, resolvedText);
782
+ }
615
783
  await publishAndSendGatewayEvent(event, outgoingPayload, shouldPublishOffice, runContext?.promptText);
616
784
  if (providerRunId) {
617
785
  openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
618
- chatRunContexts.delete(providerRunId);
619
786
  }
620
787
  }, Boolean(providerRunId));
621
788
  return;
@@ -623,34 +790,39 @@ export async function runRelayManager(opts) {
623
790
  const fetchHistory = () => requestChatHistoryFromClawConnect({ sessionKey: resolvedSessionKey, limit: 10 });
624
791
  const publishHistoryTerminal = () => withTimeout(fetchHistory(), CHAT_HISTORY_FETCH_TIMEOUT_MS, "chat.history")
625
792
  .then(async (history) => {
626
- let outcome = runContext ? extractHistoryOutcome(history, runContext) : null;
627
- // Retry once if OpenClaw has emitted final before the transcript is committed.
628
- if (!outcome || isOpenClawMediaDisplayPlaceholder(outcome.kind === "final" ? outcome.text : "")) {
629
- await new Promise((resolve) => setTimeout(resolve, CHAT_HISTORY_FINAL_RETRY_DELAY_MS));
630
- const retryHistory = await withTimeout(fetchHistory(), CHAT_HISTORY_FETCH_TIMEOUT_MS, "chat.history retry");
631
- outcome = runContext ? extractHistoryOutcome(retryHistory, runContext) : null;
632
- }
793
+ // The terminal event is the only completion signal. Do not
794
+ // turn transcript visibility into a wall-clock race: a
795
+ // later provider terminal/history page will re-enter this
796
+ // path and reconcile the newly committed source rows.
797
+ const outcome = runContext ? extractHistoryOutcome(history, runContext, currentText) : null;
633
798
  if (providerRunId && outcome) {
634
- chatRunContexts.delete(providerRunId);
635
799
  }
636
800
  if (outcome?.kind === "final") {
637
- if (isOpenClawMediaDisplayPlaceholder(outcome.text) && providerRunId && runContext) {
638
- scheduleChatHistoryFallback(providerRunId, runContext, 0);
639
- return;
640
- }
801
+ const historyReplyMessageId = providerRunId
802
+ ? assistantHistoryReplyMessageId(outcome.message, canonicalRunId)
803
+ : undefined;
641
804
  const basePayload = providerRunId
642
805
  ? buildMobileAssistantFinalPayload({
643
806
  run: { runId: canonicalRunId, sessionKey: resolvedSessionKey },
644
807
  text: outcome.text,
645
- contentBlocks: nonTextContentBlocksFromHistory(outcome.message),
808
+ ...chatLineage,
809
+ contentBlocks: runContext
810
+ ? hasCanonicalProjectionV3Snapshot(history)
811
+ ? mergeNonTextContentBlocks(nonTextContentBlocksFromHistory(outcome.message), extractExplicitParentMediaContent(history, outcome.message))
812
+ : extractHistoryMediaContent(history, runContext)
813
+ : nonTextContentBlocksFromHistory(outcome.message),
814
+ ...((replyMessageId ?? historyReplyMessageId)
815
+ ? { messageId: replyMessageId ?? historyReplyMessageId }
816
+ : {}),
646
817
  includeTimelineEvents: true,
647
818
  ...mobileAssistantUsageFromPayload(normalizedPayload),
648
819
  })
649
820
  : normalizedPayload;
650
821
  const outgoingPayload = buildFinalPayloadFromHistoryOutcome(basePayload, outcome);
651
822
  await publishAndSendGatewayEvent(event, outgoingPayload, shouldPublishOffice, runContext?.promptText);
652
- if (providerRunId)
823
+ if (providerRunId) {
653
824
  openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
825
+ }
654
826
  return;
655
827
  }
656
828
  if (outcome?.kind === "error") {
@@ -670,21 +842,13 @@ export async function runRelayManager(opts) {
670
842
  openClawChatRunIdentities.clearTransient(opts.gatewayId, providerRunId);
671
843
  return;
672
844
  }
673
- if (providerRunId && runContext) {
674
- scheduleChatHistoryFallback(providerRunId, runContext, 0);
675
- return;
676
- }
677
845
  await publishAndSendGatewayEvent(event, normalizedPayload, shouldPublishOffice);
678
846
  })
679
847
  .catch((err) => {
680
848
  console.error(`[relay] chat.history fetch failed: ${err}`);
681
- if (providerRunId && runContext) {
682
- scheduleChatHistoryFallback(providerRunId, runContext, 0);
683
- return;
684
- }
685
849
  void publishAndSendGatewayEvent(event, normalizedPayload, shouldPublishOffice);
686
850
  });
687
- runAfterAssistantSnapshot(runSnapshotKey, true, publishHistoryTerminal, Boolean(providerRunId));
851
+ runAfterAssistantSnapshot(runSnapshotKey, Boolean(replyMessageId), publishHistoryTerminal, Boolean(providerRunId));
688
852
  return;
689
853
  }
690
854
  if (providerRunId && (state === "error" || state === "failed" || state === "fail" || state === "aborted")) {
@@ -857,6 +1021,22 @@ export async function runRelayManager(opts) {
857
1021
  // thinking/tool/text event cannot win the race with this request.
858
1022
  await ensureSessionMessagesSubscribed(commandSessionKey);
859
1023
  }
1024
+ const sourceRunIdForState = commandMethod === "chat.send"
1025
+ ? normalizeOpenClawMobileRunId(paramsRecord?.idempotencyKey)
1026
+ ?? normalizeOpenClawMobileRunId(voiceInputRun?.runId)
1027
+ ?? requestId
1028
+ : normalizeOpenClawMobileRunId(paramsRecord?.runId)
1029
+ ?? normalizeOpenClawMobileRunId(paramsRecord?.idempotencyKey)
1030
+ ?? requestId;
1031
+ if ((commandMethod === "chat.send" || commandMethod === "agent") && sourceRunIdForState) {
1032
+ await recordOpenClawActiveRun({
1033
+ gatewayId: opts.gatewayId,
1034
+ sessionKey: typeof paramsRecord?.sessionKey === "string" && paramsRecord.sessionKey.trim()
1035
+ ? paramsRecord.sessionKey.trim()
1036
+ : sessionDefaults.mainSessionKey,
1037
+ sourceRunId: sourceRunIdForState,
1038
+ });
1039
+ }
860
1040
  const registerIdentity = (result) => {
861
1041
  const resultRecord = result && typeof result === "object" && !Array.isArray(result)
862
1042
  ? result
@@ -884,11 +1064,19 @@ export async function runRelayManager(opts) {
884
1064
  : undefined,
885
1065
  };
886
1066
  openClawChatRunIdentities.register(identity);
887
- chatRunContexts.set(identity.providerRunId, identity);
1067
+ setChatRunContext(identity.providerRunId, identity);
1068
+ void updateOpenClawActiveRunProviderId({
1069
+ gatewayId: opts.gatewayId,
1070
+ sessionKey: identity.sessionKey,
1071
+ sourceRunId: identity.canonicalRunId,
1072
+ providerRunId: identity.providerRunId,
1073
+ }).catch((error) => {
1074
+ console.warn(`[relay] failed to update OpenClaw active run provider id: ${String(error)}`);
1075
+ });
888
1076
  return identity;
889
1077
  };
890
1078
  let identity;
891
- const result = commandMethod === "chat.history"
1079
+ let result = commandMethod === "chat.history"
892
1080
  ? await requestChatHistoryFromClawConnect(params)
893
1081
  : await gatewayClient.request(commandMethod, params, {
894
1082
  // response 帧内同步登记,保证紧随其后的首个 delta 已能解析 canonical 身份。
@@ -897,6 +1085,9 @@ export async function runRelayManager(opts) {
897
1085
  },
898
1086
  });
899
1087
  identity ??= registerIdentity(result);
1088
+ if (commandMethod === "agents.list" && gatewayClient) {
1089
+ result = await enrichAgentsListWithIdentities(result, gatewayClient);
1090
+ }
900
1091
  return { params, result, identity };
901
1092
  };
902
1093
  const commandExecution = chatSendIdempotencyRequest
@@ -926,9 +1117,7 @@ export async function runRelayManager(opts) {
926
1117
  const sessionKey = typeof paramsRecord?.sessionKey === "string" && paramsRecord.sessionKey.trim().length > 0
927
1118
  ? paramsRecord.sessionKey.trim()
928
1119
  : sessionDefaults.mainSessionKey;
929
- if (providerRunId && identity) {
930
- scheduleChatHistoryFallback(providerRunId, identity);
931
- }
1120
+ ensureSourceCommitWatcher(sessionKey);
932
1121
  if (commandMethod === "chat.send" && chatSendDedupeRequest) {
933
1122
  chatSendDedupe.register(chatSendDedupeRequest, providerRunId);
934
1123
  }
@@ -980,10 +1169,9 @@ export async function runRelayManager(opts) {
980
1169
  opts.onDisconnected?.();
981
1170
  gatewayClient?.stop();
982
1171
  gatewayClient = null;
983
- for (const timer of chatFallbacks.values()) {
984
- clearTimeout(timer);
985
- }
986
- chatFallbacks.clear();
1172
+ for (const watcher of sourceCommitWatchers.values())
1173
+ watcher.close();
1174
+ sourceCommitWatchers.clear();
987
1175
  chatSendDedupe.clearAll();
988
1176
  for (const timer of contextUsageRefreshes.values()) {
989
1177
  clearTimeout(timer);