@hypercli.com/sdk 2026.6.21 → 2026.7.31

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 (87) hide show
  1. package/README.md +82 -1
  2. package/dist/agent.d.ts +19 -2
  3. package/dist/agent.d.ts.map +1 -1
  4. package/dist/agent.js +24 -0
  5. package/dist/agent.js.map +1 -1
  6. package/dist/agents.d.ts +561 -41
  7. package/dist/agents.d.ts.map +1 -1
  8. package/dist/agents.js +2016 -106
  9. package/dist/agents.js.map +1 -1
  10. package/dist/browser.d.ts +3 -1
  11. package/dist/browser.d.ts.map +1 -1
  12. package/dist/browser.js +9 -2
  13. package/dist/browser.js.map +1 -1
  14. package/dist/channels.d.ts +156 -0
  15. package/dist/channels.d.ts.map +1 -0
  16. package/dist/channels.js +110 -0
  17. package/dist/channels.js.map +1 -0
  18. package/dist/client.d.ts +2 -0
  19. package/dist/client.d.ts.map +1 -1
  20. package/dist/client.js +9 -3
  21. package/dist/client.js.map +1 -1
  22. package/dist/connectors.d.ts +79 -0
  23. package/dist/connectors.d.ts.map +1 -0
  24. package/dist/connectors.js +2 -0
  25. package/dist/connectors.js.map +1 -0
  26. package/dist/http.d.ts +10 -3
  27. package/dist/http.d.ts.map +1 -1
  28. package/dist/http.js +135 -61
  29. package/dist/http.js.map +1 -1
  30. package/dist/index.d.ts +12 -4
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +8 -2
  33. package/dist/index.js.map +1 -1
  34. package/dist/keys.d.ts +6 -1
  35. package/dist/keys.d.ts.map +1 -1
  36. package/dist/keys.js +8 -1
  37. package/dist/keys.js.map +1 -1
  38. package/dist/openclaw/channels.d.ts +86 -0
  39. package/dist/openclaw/channels.d.ts.map +1 -0
  40. package/dist/openclaw/channels.js +290 -0
  41. package/dist/openclaw/channels.js.map +1 -0
  42. package/dist/openclaw/connectors.d.ts +61 -0
  43. package/dist/openclaw/connectors.d.ts.map +1 -0
  44. package/dist/openclaw/connectors.js +174 -0
  45. package/dist/openclaw/connectors.js.map +1 -0
  46. package/dist/openclaw/gateway.d.ts +733 -15
  47. package/dist/openclaw/gateway.d.ts.map +1 -1
  48. package/dist/openclaw/gateway.js +1720 -181
  49. package/dist/openclaw/gateway.js.map +1 -1
  50. package/dist/openclaw/skills.d.ts +106 -0
  51. package/dist/openclaw/skills.d.ts.map +1 -0
  52. package/dist/openclaw/skills.js +575 -0
  53. package/dist/openclaw/skills.js.map +1 -0
  54. package/dist/openclaw/slack.d.ts +818 -0
  55. package/dist/openclaw/slack.d.ts.map +1 -0
  56. package/dist/openclaw/slack.js +536 -0
  57. package/dist/openclaw/slack.js.map +1 -0
  58. package/dist/openclaw/whatsapp.d.ts +63 -0
  59. package/dist/openclaw/whatsapp.d.ts.map +1 -0
  60. package/dist/openclaw/whatsapp.js +264 -0
  61. package/dist/openclaw/whatsapp.js.map +1 -0
  62. package/dist/renders.js +11 -11
  63. package/dist/renders.js.map +1 -1
  64. package/dist/skills.d.ts +126 -0
  65. package/dist/skills.d.ts.map +1 -0
  66. package/dist/skills.js +2 -0
  67. package/dist/skills.js.map +1 -0
  68. package/dist/user.d.ts +15 -2
  69. package/dist/user.d.ts.map +1 -1
  70. package/dist/user.js +51 -4
  71. package/dist/user.js.map +1 -1
  72. package/dist/voice-session.d.ts +74 -0
  73. package/dist/voice-session.d.ts.map +1 -0
  74. package/dist/voice-session.js +255 -0
  75. package/dist/voice-session.js.map +1 -0
  76. package/dist/voice.d.ts +27 -3
  77. package/dist/voice.d.ts.map +1 -1
  78. package/dist/voice.js +89 -6
  79. package/dist/voice.js.map +1 -1
  80. package/dist/workspaces.d.ts +181 -0
  81. package/dist/workspaces.d.ts.map +1 -0
  82. package/dist/workspaces.js +495 -0
  83. package/dist/workspaces.js.map +1 -0
  84. package/dist/x402.d.ts.map +1 -1
  85. package/dist/x402.js +1 -2
  86. package/dist/x402.js.map +1 -1
  87. package/package.json +38 -2
@@ -8,6 +8,57 @@
8
8
  */
9
9
  import { getPublicKeyAsync, signAsync, utils as edUtils } from "@noble/ed25519";
10
10
  import NodeWebSocket from "ws";
11
+ export const OPENCLAW_INTERNAL_MAIN_SESSION_KEY = "main";
12
+ export const OPENCLAW_SDK_SESSION_PREFIX = "hcli:";
13
+ export const OPENCLAW_DASHBOARD_SESSION_PREFIX = "dashboard:";
14
+ export function createOpenClawSessionKey(existingSessionKeys = [], prefix = OPENCLAW_SDK_SESSION_PREFIX) {
15
+ const resolvedPrefix = prefix.trim();
16
+ const existing = new Set(existingSessionKeys
17
+ .map((key) => key?.trim())
18
+ .filter((key) => Boolean(key)));
19
+ for (let attempt = 0; attempt < 10; attempt += 1) {
20
+ const suffix = typeof globalThis.crypto?.randomUUID === "function"
21
+ ? globalThis.crypto.randomUUID()
22
+ : `local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
23
+ const key = `${resolvedPrefix}${suffix}`;
24
+ if (!existing.has(key))
25
+ return key;
26
+ }
27
+ return `${resolvedPrefix}local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
28
+ }
29
+ export function createOpenClawSdkSessionKey(existingSessionKeys = []) {
30
+ return createOpenClawSessionKey(existingSessionKeys, OPENCLAW_SDK_SESSION_PREFIX);
31
+ }
32
+ export function isOpenClawInternalMainSessionKey(sessionKey) {
33
+ const normalized = (sessionKey ?? "").trim().toLowerCase();
34
+ if (!normalized)
35
+ return false;
36
+ const parsed = parseAgentSessionKey(normalized);
37
+ return (parsed?.rest ?? normalized) === OPENCLAW_INTERNAL_MAIN_SESSION_KEY;
38
+ }
39
+ export function isOpenClawSdkSessionKey(sessionKey) {
40
+ const normalized = (sessionKey ?? "").trim().toLowerCase();
41
+ if (!normalized)
42
+ return false;
43
+ const parsed = parseAgentSessionKey(normalized);
44
+ return (parsed?.rest ?? normalized).startsWith(OPENCLAW_SDK_SESSION_PREFIX);
45
+ }
46
+ export class GatewayChatStreamInterruptedError extends Error {
47
+ static code = "GATEWAY_CHAT_STREAM_INTERRUPTED";
48
+ code = GatewayChatStreamInterruptedError.code;
49
+ reason;
50
+ expectedSequence;
51
+ receivedSequence;
52
+ constructor(reason, sequence) {
53
+ super(reason === "sequence-gap" && sequence
54
+ ? `Gateway chat stream interrupted by a sequence gap (expected ${sequence.expected}, received ${sequence.received})`
55
+ : "Gateway chat stream interrupted by an event without run or session identity");
56
+ this.name = "GatewayChatStreamInterruptedError";
57
+ this.reason = reason;
58
+ this.expectedSequence = sequence?.expected;
59
+ this.receivedSequence = sequence?.received;
60
+ }
61
+ }
11
62
  class GatewayRequestError extends Error {
12
63
  gatewayCode;
13
64
  details;
@@ -18,11 +69,23 @@ class GatewayRequestError extends Error {
18
69
  this.details = error.details;
19
70
  }
20
71
  }
21
- const MIN_PROTOCOL_VERSION = 3;
22
- const PROTOCOL_VERSION = 4;
23
- const DEFAULT_TIMEOUT = 15_000;
24
- const CHAT_TIMEOUT = 120_000;
72
+ export function isOpenClawGatewayMethodUnsupported(error, method) {
73
+ const record = error && typeof error === "object" ? error : null;
74
+ const code = typeof record?.gatewayCode === "string" ? record.gatewayCode.trim().toUpperCase() : "";
75
+ if (["METHOD_NOT_FOUND", "NOT_IMPLEMENTED", "UNIMPLEMENTED", "UNSUPPORTED_METHOD"].includes(code))
76
+ return true;
77
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
78
+ if (!/unknown method|method not found|not implemented|unsupported method/i.test(message))
79
+ return false;
80
+ return !method || message.toLowerCase().includes(method.trim().toLowerCase());
81
+ }
82
+ export const MIN_GATEWAY_VERSION = 3;
83
+ export const MAX_GATEWAY_VERSION = 4;
84
+ const DEFAULT_CONNECTION_TIMEOUT = 30_000;
85
+ const WEB_LOGIN_WAIT_TIMEOUT = 120_000;
86
+ const DEFAULT_AGENT_TIMEOUT = 900_000;
25
87
  const SKILLS_MUTATION_TIMEOUT = 300_000;
88
+ const PLUGIN_MUTATION_TIMEOUT = 300_000;
26
89
  const RECONNECT_CLOSE_CODE = 4008;
27
90
  const DEFAULT_CLIENT_ID = "cli";
28
91
  const DEFAULT_CLIENT_MODE = "cli";
@@ -35,6 +98,9 @@ const BACKOFF_MULTIPLIER = 1.7;
35
98
  const OPERATOR_ROLE = "operator";
36
99
  const OPERATOR_SCOPES = ["operator.admin", "operator.approvals", "operator.pairing"];
37
100
  const STORAGE_KEY = "openclaw.device.auth.v1";
101
+ const EPHEMERAL_SESSION_PREFIX = "session-hypercli-ephemeral-";
102
+ const EPHEMERAL_RUN_ID_SUPPRESSION_MS = DEFAULT_AGENT_TIMEOUT + 5 * 60_000;
103
+ const EPHEMERAL_TURN_CLOSE_WAIT_MS = 1_000;
38
104
  const CONNECT_ERROR_PAIRING_REQUIRED = "PAIRING_REQUIRED";
39
105
  const CONNECT_ERROR_AUTH_TOKEN_MISMATCH = "AUTH_TOKEN_MISMATCH";
40
106
  const CONNECT_ERROR_AUTH_RATE_LIMITED = "AUTH_RATE_LIMITED";
@@ -42,13 +108,16 @@ const CONNECT_ERROR_DEVICE_TOKEN_MISMATCH = "AUTH_DEVICE_TOKEN_MISMATCH";
42
108
  const VALID_CLIENT_IDS = new Set([
43
109
  "webchat-ui",
44
110
  "openclaw-control-ui",
111
+ "openclaw-tui",
45
112
  "webchat",
46
113
  "cli",
47
114
  "gateway-client",
48
115
  "openclaw-macos",
49
116
  "openclaw-ios",
117
+ "openclaw-watchos",
50
118
  "openclaw-android",
51
119
  "node-host",
120
+ "openclaw-worker",
52
121
  "test",
53
122
  "fingerprint",
54
123
  "openclaw-probe",
@@ -59,6 +128,7 @@ const VALID_CLIENT_MODES = new Set([
59
128
  "ui",
60
129
  "backend",
61
130
  "node",
131
+ "worker",
62
132
  "probe",
63
133
  "test",
64
134
  ]);
@@ -72,11 +142,94 @@ function makeId() {
72
142
  return (char === "x" ? random : (random & 0x3) | 0x8).toString(16);
73
143
  });
74
144
  }
145
+ function channelConfigPatch(channelId, config, accountId, defaultAccount = false) {
146
+ if (!accountId)
147
+ return { channels: { [channelId]: config } };
148
+ return {
149
+ channels: {
150
+ [channelId]: {
151
+ accounts: { [accountId]: config },
152
+ ...(defaultAccount ? { defaultAccount: accountId } : {}),
153
+ },
154
+ },
155
+ };
156
+ }
75
157
  function asRecord(value) {
76
158
  return value && typeof value === "object" && !Array.isArray(value)
77
159
  ? value
78
160
  : null;
79
161
  }
162
+ function protocolError(code, message, record) {
163
+ const frameType = typeof record?.type === "string" ? record.type : undefined;
164
+ const event = typeof record?.event === "string" ? record.event : undefined;
165
+ const requestId = typeof record?.id === "string" ? record.id : undefined;
166
+ return {
167
+ code,
168
+ message,
169
+ ...(frameType ? { frameType } : {}),
170
+ ...(event ? { event } : {}),
171
+ ...(requestId ? { requestId } : {}),
172
+ };
173
+ }
174
+ function normalizeGatewayResponseError(value) {
175
+ if (typeof value === "string" && value.trim()) {
176
+ return { code: "UNAVAILABLE", message: value.trim() };
177
+ }
178
+ const error = asRecord(value);
179
+ return {
180
+ code: typeof error?.code === "string" && error.code.trim() ? error.code : "UNAVAILABLE",
181
+ message: typeof error?.message === "string" && error.message.trim()
182
+ ? error.message
183
+ : "gateway request failed",
184
+ ...(error?.details !== undefined ? { details: error.details } : {}),
185
+ };
186
+ }
187
+ function decodeGatewayFrame(raw) {
188
+ let value;
189
+ try {
190
+ value = JSON.parse(raw);
191
+ }
192
+ catch {
193
+ return { ok: false, error: protocolError("INVALID_JSON", "gateway frame is not valid JSON") };
194
+ }
195
+ const record = asRecord(value);
196
+ if (!record) {
197
+ return { ok: false, error: protocolError("INVALID_FRAME", "gateway frame must be an object") };
198
+ }
199
+ if (record.type === "event") {
200
+ if (typeof record.event !== "string" || !record.event.trim()) {
201
+ return { ok: false, error: protocolError("INVALID_EVENT", "gateway event requires a non-empty string event", record) };
202
+ }
203
+ if (record.seq !== undefined && (!Number.isSafeInteger(record.seq) || record.seq < 0)) {
204
+ return { ok: false, error: protocolError("INVALID_EVENT", "gateway event seq must be a non-negative safe integer", record) };
205
+ }
206
+ return { ok: true, frame: record };
207
+ }
208
+ if (record.type === "res") {
209
+ if (typeof record.id !== "string" || !record.id.trim()) {
210
+ return { ok: false, error: protocolError("INVALID_RESPONSE", "gateway response requires a non-empty string id", record) };
211
+ }
212
+ if (typeof record.ok !== "boolean") {
213
+ return { ok: false, error: protocolError("INVALID_RESPONSE", "gateway response ok must be a boolean", record) };
214
+ }
215
+ if (record.ok) {
216
+ return { ok: true, frame: { type: "res", id: record.id, ok: true, payload: record.payload } };
217
+ }
218
+ return {
219
+ ok: true,
220
+ frame: {
221
+ type: "res",
222
+ id: record.id,
223
+ ok: false,
224
+ error: normalizeGatewayResponseError(record.error),
225
+ },
226
+ };
227
+ }
228
+ return {
229
+ ok: false,
230
+ error: protocolError("INVALID_FRAME", "gateway frame type must be event or res", record),
231
+ };
232
+ }
80
233
  function asContentItems(value) {
81
234
  if (!Array.isArray(value)) {
82
235
  return [];
@@ -129,7 +282,7 @@ function normalizeToolArgs(value) {
129
282
  }
130
283
  }
131
284
  function stringifyToolResult(value) {
132
- if (value == null) {
285
+ if (value === null || value === undefined) {
133
286
  return undefined;
134
287
  }
135
288
  if (typeof value === "string") {
@@ -144,6 +297,8 @@ function stringifyToolResult(value) {
144
297
  }
145
298
  function gatewayToolCallId(record) {
146
299
  const direct = (typeof record.id === "string" && record.id.trim()) ||
300
+ (typeof record.call_id === "string" && record.call_id.trim()) ||
301
+ (typeof record.callId === "string" && record.callId.trim()) ||
147
302
  (typeof record.toolCallId === "string" && record.toolCallId.trim()) ||
148
303
  (typeof record.tool_call_id === "string" && record.tool_call_id.trim());
149
304
  return direct || undefined;
@@ -171,7 +326,7 @@ function mergeGatewayToolResult(toolCalls, result) {
171
326
  index = cursor;
172
327
  break;
173
328
  }
174
- if (result.name && entry.name === result.name && entry.result == null) {
329
+ if (result.name && entry.name === result.name && (entry.result === null || entry.result === undefined)) {
175
330
  index = cursor;
176
331
  break;
177
332
  }
@@ -213,7 +368,18 @@ export function extractGatewayChatMediaUrls(message) {
213
368
  if (item.type !== "image" && item.type !== "audio" && item.type !== "input_audio" && item.type !== "output_audio") {
214
369
  continue;
215
370
  }
216
- const source = asRecord(item.source);
371
+ const isAudio = item.type === "audio" || item.type === "input_audio" || item.type === "output_audio";
372
+ if (typeof item.url === "string" && item.url.trim()) {
373
+ mediaUrls.push(item.url);
374
+ continue;
375
+ }
376
+ if (typeof item.openUrl === "string" && item.openUrl.trim()) {
377
+ mediaUrls.push(item.openUrl);
378
+ continue;
379
+ }
380
+ const source = asRecord(item.source) ?? (isAudio
381
+ ? asRecord(item.audio) ?? asRecord(item.input_audio) ?? asRecord(item.output_audio) ?? item
382
+ : null);
217
383
  if (!source) {
218
384
  continue;
219
385
  }
@@ -221,13 +387,43 @@ export function extractGatewayChatMediaUrls(message) {
221
387
  mediaUrls.push(source.url);
222
388
  continue;
223
389
  }
224
- if (source.type === "base64" && typeof source.data === "string" && source.data.trim()) {
225
- const mimeType = typeof source.media_type === "string" && source.media_type.trim()
226
- ? source.media_type.trim()
227
- : item.type === "image"
228
- ? "image/png"
229
- : "audio/mpeg";
230
- mediaUrls.push(`data:${mimeType};base64,${source.data}`);
390
+ if (isAudio && typeof source.url === "string" && source.url.trim()) {
391
+ mediaUrls.push(source.url.trim());
392
+ continue;
393
+ }
394
+ if (typeof source.data === "string" && source.data.trim()) {
395
+ if (isAudio && /^data:audio\//i.test(source.data.trim())) {
396
+ mediaUrls.push(source.data.trim());
397
+ continue;
398
+ }
399
+ const rawMimeType = [source.media_type, source.mime_type, item.media_type, item.mime_type]
400
+ .find((value) => typeof value === "string" && value.trim());
401
+ const rawFormat = [source.format, item.format]
402
+ .find((value) => typeof value === "string" && value.trim());
403
+ const format = typeof rawFormat === "string" ? rawFormat.trim().toLowerCase() : "";
404
+ const normalizedMimeType = typeof rawMimeType === "string"
405
+ ? rawMimeType.trim().split(";", 1)[0].trim()
406
+ : "";
407
+ const audioMimeTypes = {
408
+ aac: "audio/aac",
409
+ flac: "audio/flac",
410
+ m4a: "audio/mp4",
411
+ mp3: "audio/mpeg",
412
+ oga: "audio/ogg",
413
+ ogg: "audio/ogg",
414
+ opus: "audio/ogg",
415
+ wav: "audio/wav",
416
+ weba: "audio/webm",
417
+ webm: "audio/webm",
418
+ };
419
+ const mimeType = isAudio
420
+ ? (/^audio\/[A-Za-z0-9.+-]+$/i.test(normalizedMimeType)
421
+ ? normalizedMimeType
422
+ : audioMimeTypes[format] ?? "audio/mpeg")
423
+ : (/^image\/[A-Za-z0-9.+-]+$/i.test(normalizedMimeType)
424
+ ? normalizedMimeType
425
+ : "image/png");
426
+ mediaUrls.push(`data:${mimeType};base64,${source.data.trim()}`);
231
427
  }
232
428
  }
233
429
  if (typeof record.mediaUrl === "string" && record.mediaUrl.trim()) {
@@ -240,7 +436,7 @@ export function extractGatewayChatMediaUrls(message) {
240
436
  }
241
437
  }
242
438
  }
243
- return mediaUrls;
439
+ return Array.from(new Set(mediaUrls));
244
440
  }
245
441
  export function extractGatewayChatToolCalls(message) {
246
442
  const record = asRecord(message);
@@ -256,6 +452,8 @@ export function extractGatewayChatToolCalls(message) {
256
452
  kind === "tool_call" ||
257
453
  kind === "tooluse" ||
258
454
  kind === "tool_use" ||
455
+ kind === "function_call" ||
456
+ kind === "functioncall" ||
259
457
  (name && (item.arguments !== undefined || item.args !== undefined))) {
260
458
  toolCalls.push({
261
459
  ...(id ? { id } : {}),
@@ -264,11 +462,11 @@ export function extractGatewayChatToolCalls(message) {
264
462
  });
265
463
  continue;
266
464
  }
267
- if (kind === "toolresult" || kind === "tool_result") {
465
+ if (kind === "toolresult" || kind === "tool_result" || kind === "function_call_output" || kind === "functioncalloutput") {
268
466
  toolCalls = mergeGatewayToolResult(toolCalls, {
269
467
  ...(id ? { id } : {}),
270
468
  name: name ?? "tool",
271
- result: stringifyToolResult(item.text ?? item.content ?? item.result),
469
+ result: stringifyToolResult(item.text ?? item.content ?? item.result ?? item.output),
272
470
  });
273
471
  }
274
472
  }
@@ -284,6 +482,9 @@ export function extractGatewayChatToolCalls(message) {
284
482
  ...(gatewayToolCallId(tool) ? { id: gatewayToolCallId(tool) } : {}),
285
483
  name,
286
484
  args: normalizeToolArgs(tool.arguments ?? tool.args),
485
+ ...(stringifyToolResult(tool.result ?? tool.output ?? tool.content ?? tool.text) !== undefined
486
+ ? { result: stringifyToolResult(tool.result ?? tool.output ?? tool.content ?? tool.text) }
487
+ : {}),
287
488
  });
288
489
  }
289
490
  }
@@ -292,7 +493,7 @@ export function extractGatewayChatToolCalls(message) {
292
493
  const role = typeof record.role === "string" ? record.role.trim().toLowerCase() : "";
293
494
  if (topLevelToolName &&
294
495
  topLevelResult &&
295
- (role === "toolresult" || role === "tool_result" || record.toolCallId || record.tool_call_id)) {
496
+ (role === "toolresult" || role === "tool_result" || role === "function_call_output" || record.toolCallId || record.tool_call_id || record.call_id || record.callId)) {
296
497
  toolCalls = mergeGatewayToolResult(toolCalls, {
297
498
  ...(gatewayToolCallId(record) ? { id: gatewayToolCallId(record) } : {}),
298
499
  name: topLevelToolName,
@@ -312,6 +513,11 @@ export function normalizeGatewayChatMessage(message) {
312
513
  const mediaUrls = extractGatewayChatMediaUrls(record);
313
514
  const timestamp = typeof record.timestamp === "number" ? record.timestamp : undefined;
314
515
  const role = typeof record.role === "string" && record.role.trim() ? record.role : "assistant";
516
+ const messageId = protocolIdentityString(record.messageId);
517
+ const turnId = protocolIdentityString(record.turnId);
518
+ const runId = extractMessageRunId(record) ?? undefined;
519
+ const sessionKey = chatPayloadSessionKey(record);
520
+ const revision = protocolRevision(record.revision);
315
521
  if (!text && !thinking && toolCalls.length === 0 && mediaUrls.length === 0) {
316
522
  return null;
317
523
  }
@@ -322,6 +528,11 @@ export function normalizeGatewayChatMessage(message) {
322
528
  toolCalls,
323
529
  mediaUrls,
324
530
  ...(timestamp !== undefined ? { timestamp } : {}),
531
+ ...(messageId ? { messageId } : {}),
532
+ ...(turnId ? { turnId } : {}),
533
+ ...(runId ? { runId } : {}),
534
+ ...(sessionKey ? { sessionKey } : {}),
535
+ ...(revision !== undefined ? { revision } : {}),
325
536
  };
326
537
  }
327
538
  function extractMessageText(message) {
@@ -369,7 +580,25 @@ function extractMessageRunId(message) {
369
580
  const metaRunId = typeof meta?.runId === "string" ? meta.runId.trim() : "";
370
581
  return metaRunId || null;
371
582
  }
372
- function latestHistoryAssistantText(messages, acceptedRunIds) {
583
+ function historyAssistantBaseline(messages, priorAssistantTexts = []) {
584
+ const assistantTexts = messages.flatMap((candidate) => {
585
+ const message = asRecord(candidate);
586
+ const role = typeof message?.role === "string" ? message.role.trim().toLowerCase() : "";
587
+ const text = role === "assistant" ? extractMessageText(message)?.trim() : "";
588
+ return text ? [text] : [];
589
+ });
590
+ const useTrackedPrior = priorAssistantTexts.length > assistantTexts.length;
591
+ return {
592
+ count: Math.max(assistantTexts.length, priorAssistantTexts.length),
593
+ latestText: useTrackedPrior
594
+ ? priorAssistantTexts[priorAssistantTexts.length - 1] ?? ""
595
+ : assistantTexts[assistantTexts.length - 1] ?? "",
596
+ };
597
+ }
598
+ function latestHistoryAssistantText(messages, acceptedRunIds, baseline) {
599
+ const currentBaseline = historyAssistantBaseline(messages);
600
+ const hasNewUncorrelatedAssistant = baseline === undefined || Boolean(baseline && (currentBaseline.count > baseline.count ||
601
+ currentBaseline.latestText !== baseline.latestText));
373
602
  for (let index = messages.length - 1; index >= 0; index -= 1) {
374
603
  const message = asRecord(messages[index]);
375
604
  if (!message) {
@@ -383,6 +612,9 @@ function latestHistoryAssistantText(messages, acceptedRunIds) {
383
612
  if (messageRunId && acceptedRunIds.size > 0 && !acceptedRunIds.has(messageRunId)) {
384
613
  continue;
385
614
  }
615
+ if (!messageRunId && !hasNewUncorrelatedAssistant) {
616
+ continue;
617
+ }
386
618
  const text = extractMessageText(message)?.trim();
387
619
  if (text) {
388
620
  return text;
@@ -402,6 +634,59 @@ function streamDelta(previousText, nextText) {
402
634
  }
403
635
  return { delta: nextText, nextText };
404
636
  }
637
+ function appendStreamContent(previousText, text, replace) {
638
+ if (replace)
639
+ return reconcileStreamContent(previousText, text, true);
640
+ if (!text)
641
+ return null;
642
+ return { text, nextText: previousText + text, replace: false };
643
+ }
644
+ function reconcileStreamContent(previousText, snapshot, replace = false) {
645
+ if (replace)
646
+ return { text: snapshot, nextText: snapshot, replace: true };
647
+ if (snapshot === previousText)
648
+ return null;
649
+ if (snapshot.startsWith(previousText)) {
650
+ const text = snapshot.slice(previousText.length);
651
+ return text ? { text, nextText: snapshot, replace: false } : null;
652
+ }
653
+ if (previousText.startsWith(snapshot))
654
+ return null;
655
+ return { text: snapshot, nextText: snapshot, replace: true };
656
+ }
657
+ function protocolIdentityString(value) {
658
+ return typeof value === "string" && value.trim() ? value : undefined;
659
+ }
660
+ function chatPayloadSessionKey(payload) {
661
+ return protocolIdentityString(payload.canonicalSessionKey) ?? protocolIdentityString(payload.sessionKey);
662
+ }
663
+ function protocolRevision(value) {
664
+ return typeof value === "number" && Number.isFinite(value)
665
+ ? value
666
+ : protocolIdentityString(value);
667
+ }
668
+ function chatEventIdentity(payload) {
669
+ const message = asRecord(payload.message);
670
+ const eventId = protocolIdentityString(payload.eventId) ?? protocolIdentityString(message?.eventId);
671
+ const messageId = protocolIdentityString(payload.messageId) ?? protocolIdentityString(message?.messageId);
672
+ const turnId = protocolIdentityString(payload.turnId) ?? protocolIdentityString(message?.turnId);
673
+ const runId = protocolIdentityString(payload.runId) ?? extractMessageRunId(message);
674
+ const sessionKey = chatPayloadSessionKey(payload) ?? (message ? chatPayloadSessionKey(message) : undefined);
675
+ const revision = protocolRevision(payload.revision) ?? protocolRevision(message?.revision);
676
+ return {
677
+ ...(eventId ? { eventId } : {}),
678
+ ...(messageId ? { messageId } : {}),
679
+ ...(turnId ? { turnId } : {}),
680
+ ...(runId ? { runId } : {}),
681
+ ...(sessionKey ? { sessionKey } : {}),
682
+ ...(revision !== undefined ? { revision } : {}),
683
+ };
684
+ }
685
+ function applyChatEventContent(currentText, event) {
686
+ if (event.type !== "content")
687
+ return currentText;
688
+ return event.replace === true ? event.text ?? "" : currentText + (event.text ?? "");
689
+ }
405
690
  function parseAgentSessionKey(sessionKey) {
406
691
  const normalized = (sessionKey ?? "").trim().toLowerCase();
407
692
  if (!normalized) {
@@ -440,6 +725,89 @@ function sameSessionKey(left, right) {
440
725
  }
441
726
  return false;
442
727
  }
728
+ function isReservedEphemeralSessionKey(sessionKey) {
729
+ return /^session-hypercli-ephemeral-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(sessionKey.trim().toLowerCase());
730
+ }
731
+ function validateEphemeralCanonicalKey(requestedKey, canonicalKey) {
732
+ if (!isReservedEphemeralSessionKey(requestedKey))
733
+ return null;
734
+ if (canonicalKey === requestedKey)
735
+ return canonicalKey;
736
+ if (!canonicalKey.startsWith("agent:"))
737
+ return null;
738
+ const agentSeparator = canonicalKey.indexOf(":", "agent:".length);
739
+ if (agentSeparator <= "agent:".length)
740
+ return null;
741
+ if (!canonicalKey.slice("agent:".length, agentSeparator).trim())
742
+ return null;
743
+ return canonicalKey.slice(agentSeparator + 1) === requestedKey ? canonicalKey : null;
744
+ }
745
+ function valueReferencesEphemeralSession(value, activeSessionKeys, seen = new WeakSet()) {
746
+ if (typeof value === "string") {
747
+ const candidate = value.trim();
748
+ const unscoped = parseAgentSessionKey(candidate)?.rest ?? candidate;
749
+ return isReservedEphemeralSessionKey(unscoped) || activeSessionKeys.some((key) => sameSessionKey(candidate, key));
750
+ }
751
+ if (!value || typeof value !== "object" || seen.has(value))
752
+ return false;
753
+ seen.add(value);
754
+ if (Array.isArray(value)) {
755
+ return value.some((item) => valueReferencesEphemeralSession(item, activeSessionKeys, seen));
756
+ }
757
+ return Object.values(value).some((item) => valueReferencesEphemeralSession(item, activeSessionKeys, seen));
758
+ }
759
+ async function promiseSettlesWithin(promise, timeoutMs) {
760
+ let timeout = null;
761
+ try {
762
+ return await Promise.race([
763
+ promise.then(() => true, () => true),
764
+ new Promise((resolve) => {
765
+ timeout = setTimeout(() => resolve(false), timeoutMs);
766
+ }),
767
+ ]);
768
+ }
769
+ finally {
770
+ if (timeout)
771
+ clearTimeout(timeout);
772
+ }
773
+ }
774
+ async function booleanPromiseWithin(promise, timeoutMs) {
775
+ let timeout = null;
776
+ try {
777
+ return await Promise.race([
778
+ promise.catch(() => false),
779
+ new Promise((resolve) => {
780
+ timeout = setTimeout(() => resolve(false), timeoutMs);
781
+ }),
782
+ ]);
783
+ }
784
+ finally {
785
+ if (timeout)
786
+ clearTimeout(timeout);
787
+ }
788
+ }
789
+ function isChatStreamGatewayEvent(event) {
790
+ if (event.event === "chat" || event.event.startsWith("chat."))
791
+ return true;
792
+ if (event.event !== "agent")
793
+ return false;
794
+ const payload = asRecord(event.payload) ?? {};
795
+ const stream = typeof payload.stream === "string" ? payload.stream.toLowerCase() : "";
796
+ return stream === "tool" || stream === "lifecycle";
797
+ }
798
+ function isTerminalChatStreamGatewayEvent(event) {
799
+ if (event.event === "chat.done" || event.event === "chat.error")
800
+ return true;
801
+ const payload = asRecord(event.payload) ?? {};
802
+ if (event.event === "agent" && String(payload.stream ?? "").toLowerCase() === "lifecycle") {
803
+ const phase = String(asRecord(payload.data)?.phase ?? "").toLowerCase();
804
+ return phase === "end" || phase === "error";
805
+ }
806
+ if (event.event !== "chat")
807
+ return false;
808
+ const state = String(payload.state ?? "").toLowerCase();
809
+ return state === "final" || state === "error" || state === "aborted";
810
+ }
443
811
  function splitConfigPath(path) {
444
812
  return path
445
813
  .split(".")
@@ -665,6 +1033,23 @@ function normalizeClientMode(value) {
665
1033
  const normalized = value?.trim().toLowerCase();
666
1034
  return normalized && VALID_CLIENT_MODES.has(normalized) ? normalized : DEFAULT_CLIENT_MODE;
667
1035
  }
1036
+ function normalizeOptionalString(value) {
1037
+ return value?.trim() || undefined;
1038
+ }
1039
+ function normalizeProtocolVersion(value, fallback) {
1040
+ return typeof value === "number" && Number.isFinite(value) && value > 0
1041
+ ? Math.trunc(value)
1042
+ : fallback;
1043
+ }
1044
+ function normalizePermissions(value) {
1045
+ if (!value || typeof value !== "object") {
1046
+ return undefined;
1047
+ }
1048
+ const entries = Object.entries(value)
1049
+ .filter(([key, allowed]) => key.trim() && typeof allowed === "boolean")
1050
+ .map(([key, allowed]) => [key.trim(), allowed]);
1051
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
1052
+ }
668
1053
  function getNavigatorLike() {
669
1054
  const maybeNavigator = globalThis.navigator;
670
1055
  return maybeNavigator ?? null;
@@ -1072,6 +1457,11 @@ export class GatewayClient {
1072
1457
  url;
1073
1458
  token;
1074
1459
  gatewayToken;
1460
+ bootstrapToken;
1461
+ deviceToken;
1462
+ password;
1463
+ approvalRuntimeToken;
1464
+ agentRuntimeIdentityToken;
1075
1465
  deploymentId;
1076
1466
  apiKey;
1077
1467
  apiBase;
@@ -1081,13 +1471,31 @@ export class GatewayClient {
1081
1471
  clientDisplayName;
1082
1472
  clientVersion;
1083
1473
  clientPlatform;
1474
+ clientDeviceFamily;
1084
1475
  clientInstanceId;
1085
1476
  caps;
1477
+ role;
1478
+ scopes;
1479
+ commands;
1480
+ permissions;
1481
+ pathEnv;
1482
+ minProtocol;
1483
+ maxProtocol;
1086
1484
  origin;
1087
1485
  defaultTimeout;
1088
1486
  ws = null;
1089
1487
  pending = new Map();
1090
1488
  eventHandlers = new Set();
1489
+ internalEventHandlers = new Set();
1490
+ internalStreamCloseHandlers = new Set();
1491
+ activeNormalChatStreams = new Set();
1492
+ activeStrictChatStreams = new Set();
1493
+ activeEphemeralSessionKeys = new Map();
1494
+ suppressedEphemeralRunIds = new Map();
1495
+ pendingEphemeralChatAcks = 0;
1496
+ deferredEphemeralCorrelationEvents = new Set();
1497
+ deferredEphemeralEventsCanRepublish = true;
1498
+ suppressUncorrelatedChatEventsUntil = 0;
1091
1499
  connectionStateHandlers = new Set();
1092
1500
  connected = false;
1093
1501
  connectionState = "disconnected";
@@ -1112,37 +1520,58 @@ export class GatewayClient {
1112
1520
  onDisconnect = null;
1113
1521
  constructor(options) {
1114
1522
  this.url = options.url;
1115
- this.token = options.token?.trim() || undefined;
1116
- this.gatewayToken = options.gatewayToken?.trim() || undefined;
1117
- this.deploymentId = options.deploymentId?.trim() || undefined;
1118
- this.apiKey = options.apiKey?.trim() || undefined;
1523
+ this.token = normalizeOptionalString(options.token);
1524
+ this.gatewayToken = normalizeOptionalString(options.gatewayToken);
1525
+ this.bootstrapToken = normalizeOptionalString(options.bootstrapToken);
1526
+ this.deviceToken = normalizeOptionalString(options.deviceToken);
1527
+ this.password = normalizeOptionalString(options.password);
1528
+ this.approvalRuntimeToken = normalizeOptionalString(options.approvalRuntimeToken);
1529
+ this.agentRuntimeIdentityToken = normalizeOptionalString(options.agentRuntimeIdentityToken);
1530
+ this.deploymentId = normalizeOptionalString(options.deploymentId);
1531
+ this.apiKey = normalizeOptionalString(options.apiKey);
1119
1532
  this.apiBase = options.apiBase?.trim().replace(/\/$/, "") || undefined;
1120
1533
  this.autoApprovePairing = options.autoApprovePairing === true;
1121
1534
  this.clientId = normalizeClientId(options.clientId);
1122
1535
  this.clientMode = normalizeClientMode(options.clientMode);
1123
1536
  this.clientVersion = options.clientVersion?.trim() || DEFAULT_CLIENT_VERSION;
1124
1537
  this.clientPlatform = resolvePlatform(options.platform);
1538
+ this.clientDeviceFamily = normalizeOptionalString(options.deviceFamily);
1125
1539
  this.clientDisplayName = resolveClientDisplayName(options.clientDisplayName, this.clientPlatform);
1126
- this.clientInstanceId = options.instanceId?.trim() || makeId();
1540
+ this.clientInstanceId = normalizeOptionalString(options.instanceId) || makeId();
1127
1541
  this.caps = Array.isArray(options.caps)
1128
1542
  ? options.caps.map((cap) => cap.trim()).filter(Boolean)
1129
1543
  : [...DEFAULT_CAPS];
1544
+ this.role = options.role?.trim() || OPERATOR_ROLE;
1545
+ this.scopes = Array.isArray(options.scopes)
1546
+ ? options.scopes.map((scope) => scope.trim()).filter(Boolean)
1547
+ : this.role === OPERATOR_ROLE
1548
+ ? [...OPERATOR_SCOPES]
1549
+ : [];
1550
+ this.commands = Array.isArray(options.commands)
1551
+ ? options.commands.map((command) => command.trim()).filter(Boolean)
1552
+ : undefined;
1553
+ this.permissions = normalizePermissions(options.permissions);
1554
+ this.pathEnv = normalizeOptionalString(options.pathEnv);
1555
+ this.minProtocol = normalizeProtocolVersion(options.minProtocol, MIN_GATEWAY_VERSION);
1556
+ this.maxProtocol = normalizeProtocolVersion(options.maxProtocol, MAX_GATEWAY_VERSION);
1130
1557
  // Non-browser SDK clients should not send Origin by default. OpenClaw
1131
1558
  // treats any Origin header as browser-originated and applies browser
1132
1559
  // origin checks to the connection.
1133
1560
  this.origin = typeof options.origin === "string" && options.origin.trim()
1134
1561
  ? options.origin.trim()
1135
1562
  : undefined;
1136
- this.defaultTimeout = options.timeout ?? DEFAULT_TIMEOUT;
1563
+ this.defaultTimeout = options.timeout ?? DEFAULT_CONNECTION_TIMEOUT;
1137
1564
  this.onHello = options.onHello;
1138
1565
  this.onClose = options.onClose;
1139
1566
  this.onGap = options.onGap;
1567
+ this.onProtocolError = options.onProtocolError;
1140
1568
  this.onPairing = options.onPairing;
1141
- this.pairingState = loadPendingPairing(this.storageScope(), OPERATOR_ROLE);
1569
+ this.pairingState = loadPendingPairing(this.storageScope(), this.role);
1142
1570
  }
1143
1571
  onHello;
1144
1572
  onClose;
1145
1573
  onGap;
1574
+ onProtocolError;
1146
1575
  onPairing;
1147
1576
  get version() {
1148
1577
  return this._version;
@@ -1188,6 +1617,153 @@ export class GatewayClient {
1188
1617
  this.eventHandlers.add(handler);
1189
1618
  return () => this.eventHandlers.delete(handler);
1190
1619
  }
1620
+ currentEphemeralSessionKeys() {
1621
+ const now = Date.now();
1622
+ for (const [sessionKey, expiresAt] of this.activeEphemeralSessionKeys) {
1623
+ if (expiresAt !== null && expiresAt <= now)
1624
+ this.activeEphemeralSessionKeys.delete(sessionKey);
1625
+ }
1626
+ return [...this.activeEphemeralSessionKeys.keys()];
1627
+ }
1628
+ shouldSuppressPublicEvent(event, handledInternally) {
1629
+ if (handledInternally)
1630
+ return true;
1631
+ const payload = asRecord(event.payload) ?? {};
1632
+ const activeSessionKeys = this.currentEphemeralSessionKeys();
1633
+ if (valueReferencesEphemeralSession(payload, activeSessionKeys))
1634
+ return true;
1635
+ const sessionKey = chatPayloadSessionKey(payload)?.trim() ?? "";
1636
+ const unscopedSessionKey = parseAgentSessionKey(sessionKey)?.rest ?? sessionKey;
1637
+ if (isReservedEphemeralSessionKey(unscopedSessionKey))
1638
+ return true;
1639
+ if (sessionKey &&
1640
+ activeSessionKeys.some((activeKey) => sameSessionKey(sessionKey, activeKey))) {
1641
+ return true;
1642
+ }
1643
+ const runId = typeof payload.runId === "string" ? payload.runId.trim() : "";
1644
+ const suppressedRunExpiry = runId ? this.suppressedEphemeralRunIds.get(runId) : undefined;
1645
+ if (suppressedRunExpiry !== undefined) {
1646
+ if (suppressedRunExpiry > Date.now())
1647
+ return true;
1648
+ this.suppressedEphemeralRunIds.delete(runId);
1649
+ }
1650
+ if (runId &&
1651
+ !sessionKey &&
1652
+ isChatStreamGatewayEvent(event) &&
1653
+ this.suppressUncorrelatedChatEventsUntil > Date.now()) {
1654
+ return true;
1655
+ }
1656
+ if (activeSessionKeys.length > 0 && event.event === "activity.log" && !sessionKey && !runId) {
1657
+ return true;
1658
+ }
1659
+ return (activeSessionKeys.length > 0 &&
1660
+ !sessionKey &&
1661
+ !runId &&
1662
+ isChatStreamGatewayEvent(event));
1663
+ }
1664
+ suppressEphemeralRunId(runId) {
1665
+ const normalized = runId.trim();
1666
+ if (!normalized)
1667
+ return;
1668
+ const now = Date.now();
1669
+ for (const [candidate, expiresAt] of this.suppressedEphemeralRunIds) {
1670
+ if (expiresAt <= now)
1671
+ this.suppressedEphemeralRunIds.delete(candidate);
1672
+ }
1673
+ this.suppressedEphemeralRunIds.set(normalized, now + EPHEMERAL_RUN_ID_SUPPRESSION_MS);
1674
+ }
1675
+ beginEphemeralChatAcknowledgement() {
1676
+ if (this.pendingEphemeralChatAcks === 0)
1677
+ this.deferredEphemeralEventsCanRepublish = true;
1678
+ this.pendingEphemeralChatAcks += 1;
1679
+ }
1680
+ deferEphemeralCorrelationEvent(event) {
1681
+ if (this.pendingEphemeralChatAcks > 0)
1682
+ this.deferredEphemeralCorrelationEvents.add(event);
1683
+ }
1684
+ finishEphemeralChatAcknowledgement(canRepublishDeferredEvents) {
1685
+ if (this.pendingEphemeralChatAcks <= 0)
1686
+ return;
1687
+ this.deferredEphemeralEventsCanRepublish &&= canRepublishDeferredEvents;
1688
+ this.pendingEphemeralChatAcks -= 1;
1689
+ if (this.pendingEphemeralChatAcks > 0)
1690
+ return;
1691
+ if (!this.deferredEphemeralEventsCanRepublish) {
1692
+ this.suppressUncorrelatedChatEventsUntil = Math.max(this.suppressUncorrelatedChatEventsUntil, Date.now() + EPHEMERAL_RUN_ID_SUPPRESSION_MS);
1693
+ }
1694
+ const deferredEvents = [...this.deferredEphemeralCorrelationEvents];
1695
+ this.deferredEphemeralCorrelationEvents.clear();
1696
+ const shouldRepublish = this.deferredEphemeralEventsCanRepublish;
1697
+ this.deferredEphemeralEventsCanRepublish = true;
1698
+ if (shouldRepublish)
1699
+ deferredEvents.forEach((event) => this.publishPublicEvent(event));
1700
+ }
1701
+ publishPublicEvent(event, handledInternally = false) {
1702
+ if (this.shouldSuppressPublicEvent(event, handledInternally))
1703
+ return;
1704
+ for (const handler of this.eventHandlers) {
1705
+ try {
1706
+ handler(event);
1707
+ }
1708
+ catch {
1709
+ // Event handlers are isolated from the socket lifecycle.
1710
+ }
1711
+ }
1712
+ }
1713
+ notifyInternalStreamClose(error) {
1714
+ for (const handler of this.internalStreamCloseHandlers) {
1715
+ try {
1716
+ handler(error);
1717
+ }
1718
+ catch {
1719
+ // Stream close handlers are isolated from the socket lifecycle.
1720
+ }
1721
+ }
1722
+ }
1723
+ interruptNormalChatStreams(error) {
1724
+ for (const stream of [...this.activeNormalChatStreams])
1725
+ stream.close(error);
1726
+ }
1727
+ handleForwardSequenceGap(info, receivedEvent) {
1728
+ try {
1729
+ this.onGap?.(info);
1730
+ }
1731
+ catch {
1732
+ // Gap callbacks are observational and must not affect the socket.
1733
+ }
1734
+ queueMicrotask(() => {
1735
+ const error = new GatewayChatStreamInterruptedError("sequence-gap", info);
1736
+ const streams = new Set([
1737
+ ...this.activeNormalChatStreams,
1738
+ ...this.activeStrictChatStreams,
1739
+ ]);
1740
+ for (const stream of streams) {
1741
+ if (stream.terminalEvent !== receivedEvent)
1742
+ stream.close(error);
1743
+ }
1744
+ });
1745
+ }
1746
+ reportProtocolError(info) {
1747
+ try {
1748
+ this.onProtocolError?.(info);
1749
+ }
1750
+ catch {
1751
+ // Protocol diagnostics are observational and must not affect the socket.
1752
+ }
1753
+ }
1754
+ rejectMalformedPendingResponse(info) {
1755
+ if (info.frameType !== "res" || !info.requestId)
1756
+ return;
1757
+ const pending = this.pending.get(info.requestId);
1758
+ if (!pending)
1759
+ return;
1760
+ this.pending.delete(info.requestId);
1761
+ pending.cleanup();
1762
+ pending.reject(new GatewayRequestError({
1763
+ code: "PROTOCOL_ERROR",
1764
+ message: info.message,
1765
+ }));
1766
+ }
1191
1767
  /** Connect and keep reconnecting until stopped */
1192
1768
  connect() {
1193
1769
  return this.start();
@@ -1231,7 +1807,9 @@ export class GatewayClient {
1231
1807
  if (ws) {
1232
1808
  ws.close();
1233
1809
  }
1234
- this.flushPending(new Error("gateway client stopped"));
1810
+ const stoppedError = new Error("gateway client stopped");
1811
+ this.flushPending(stoppedError);
1812
+ this.notifyInternalStreamClose(stoppedError);
1235
1813
  if (this.rejectConnectPromise) {
1236
1814
  this.rejectConnectPromise(new Error("gateway client stopped"));
1237
1815
  }
@@ -1245,7 +1823,7 @@ export class GatewayClient {
1245
1823
  storePendingPairing(pairing, this.storageScope());
1246
1824
  }
1247
1825
  else {
1248
- clearPendingPairing(this.storageScope(), OPERATOR_ROLE);
1826
+ clearPendingPairing(this.storageScope(), this.role);
1249
1827
  }
1250
1828
  this.onPairing?.(pairing);
1251
1829
  }
@@ -1299,6 +1877,7 @@ export class GatewayClient {
1299
1877
  ? new WebSocket(wsUrl)
1300
1878
  : new NodeWebSocket(wsUrl, this.origin ? { headers: { Origin: this.origin } } : undefined);
1301
1879
  this.ws = ws;
1880
+ this.lastSeq = null;
1302
1881
  if (useBrowserSocket) {
1303
1882
  ws.onopen = () => {
1304
1883
  this.queueConnect();
@@ -1361,7 +1940,7 @@ export class GatewayClient {
1361
1940
  }
1362
1941
  flushPending(error) {
1363
1942
  for (const [, pending] of this.pending) {
1364
- clearTimeout(pending.timer);
1943
+ pending.cleanup();
1365
1944
  pending.reject(error);
1366
1945
  }
1367
1946
  this.pending.clear();
@@ -1381,7 +1960,9 @@ export class GatewayClient {
1381
1960
  }
1382
1961
  const error = this.pendingConnectError;
1383
1962
  this.pendingConnectError = null;
1384
- this.flushPending(new Error(`gateway closed (${code}): ${reason || "no reason"}`));
1963
+ const closeError = new Error(`gateway closed (${code}): ${reason || "no reason"}`);
1964
+ this.flushPending(closeError);
1965
+ this.notifyInternalStreamClose(closeError);
1385
1966
  this.onClose?.({ code, reason, error });
1386
1967
  if (!this.closed) {
1387
1968
  this.onDisconnect?.();
@@ -1419,34 +2000,53 @@ export class GatewayClient {
1419
2000
  let storedDeviceToken = null;
1420
2001
  try {
1421
2002
  identity = await loadOrCreateDeviceIdentity();
1422
- storedDeviceToken = loadStoredDeviceToken(identity.deviceId, this.storageScope(), OPERATOR_ROLE)?.token ?? null;
1423
- const authToken = this.gatewayToken ?? storedDeviceToken;
1424
- const authDeviceToken = this.pendingDeviceTokenRetry ? (storedDeviceToken ?? undefined) : undefined;
2003
+ storedDeviceToken = loadStoredDeviceToken(identity.deviceId, this.storageScope(), this.role)?.token ?? null;
2004
+ const resolvedDeviceToken = this.deviceToken ?? storedDeviceToken ?? undefined;
2005
+ const authToken = this.gatewayToken ?? resolvedDeviceToken;
2006
+ const authBootstrapToken = !this.gatewayToken && !resolvedDeviceToken && !this.password
2007
+ ? this.bootstrapToken
2008
+ : undefined;
2009
+ const authDeviceToken = this.deviceToken ?? (this.pendingDeviceTokenRetry ? resolvedDeviceToken : undefined);
2010
+ const signatureToken = authToken ?? authBootstrapToken;
1425
2011
  const signedAtMs = Date.now();
1426
2012
  const payload = buildDeviceAuthPayload({
1427
2013
  deviceId: identity.deviceId,
1428
2014
  clientId: this.clientId,
1429
2015
  clientMode: this.clientMode,
1430
- role: OPERATOR_ROLE,
1431
- scopes: OPERATOR_SCOPES,
2016
+ role: this.role,
2017
+ scopes: this.scopes,
1432
2018
  signedAtMs,
1433
- token: authToken ?? null,
2019
+ token: signatureToken ?? null,
1434
2020
  nonce,
1435
2021
  });
1436
2022
  const signature = await signDevicePayload(identity.privateKey, payload);
2023
+ const auth = {};
2024
+ if (authToken)
2025
+ auth.token = authToken;
2026
+ if (authBootstrapToken)
2027
+ auth.bootstrapToken = authBootstrapToken;
2028
+ if (authDeviceToken)
2029
+ auth.deviceToken = authDeviceToken;
2030
+ if (this.password)
2031
+ auth.password = this.password;
2032
+ if (this.approvalRuntimeToken)
2033
+ auth.approvalRuntimeToken = this.approvalRuntimeToken;
2034
+ if (this.agentRuntimeIdentityToken)
2035
+ auth.agentRuntimeIdentityToken = this.agentRuntimeIdentityToken;
1437
2036
  const params = {
1438
- minProtocol: MIN_PROTOCOL_VERSION,
1439
- maxProtocol: PROTOCOL_VERSION,
2037
+ minProtocol: this.minProtocol,
2038
+ maxProtocol: this.maxProtocol,
1440
2039
  client: {
1441
2040
  id: this.clientId,
1442
2041
  ...(this.clientDisplayName ? { displayName: this.clientDisplayName } : {}),
1443
2042
  version: this.clientVersion,
1444
2043
  platform: this.clientPlatform,
2044
+ ...(this.clientDeviceFamily ? { deviceFamily: this.clientDeviceFamily } : {}),
1445
2045
  mode: this.clientMode,
1446
2046
  ...(this.clientInstanceId ? { instanceId: this.clientInstanceId } : {}),
1447
2047
  },
1448
- role: OPERATOR_ROLE,
1449
- scopes: [...OPERATOR_SCOPES],
2048
+ role: this.role,
2049
+ scopes: [...this.scopes],
1450
2050
  device: {
1451
2051
  id: identity.deviceId,
1452
2052
  publicKey: identity.publicKey,
@@ -1455,30 +2055,21 @@ export class GatewayClient {
1455
2055
  nonce,
1456
2056
  },
1457
2057
  caps: this.caps,
1458
- ...(authToken
1459
- ? {
1460
- auth: {
1461
- ...(authToken ? { token: authToken } : {}),
1462
- ...(authDeviceToken ? { deviceToken: authDeviceToken } : {}),
1463
- },
1464
- }
1465
- : authDeviceToken
1466
- ? {
1467
- auth: {
1468
- deviceToken: authDeviceToken,
1469
- },
1470
- }
1471
- : {}),
2058
+ ...(this.commands ? { commands: this.commands } : {}),
2059
+ ...(this.permissions ? { permissions: this.permissions } : {}),
2060
+ ...(this.pathEnv ? { pathEnv: this.pathEnv } : {}),
2061
+ ...(Object.keys(auth).length ? { auth } : {}),
1472
2062
  ...(resolveUserAgent() ? { userAgent: resolveUserAgent() } : {}),
1473
2063
  ...(resolveLocale() ? { locale: resolveLocale() } : {}),
1474
2064
  };
1475
- const hello = await this.sendRawRequest("connect", params, this.defaultTimeout, true);
2065
+ const hello = await this.sendRawRequest("connect", params, { timeoutMs: this.defaultTimeout }, true);
1476
2066
  if (hello?.auth?.deviceToken) {
2067
+ this.deviceToken = hello.auth.deviceToken;
1477
2068
  storeStoredDeviceToken({
1478
2069
  deviceId: identity.deviceId,
1479
2070
  scope: this.storageScope(),
1480
2071
  gatewayUrl: this.url,
1481
- role: hello.auth.role ?? OPERATOR_ROLE,
2072
+ role: hello.auth.role ?? this.role,
1482
2073
  token: hello.auth.deviceToken,
1483
2074
  scopes: hello.auth.scopes ?? [],
1484
2075
  });
@@ -1510,7 +2101,7 @@ export class GatewayClient {
1510
2101
  // one-shot flag to prevent infinite recursion if the retry also fails.
1511
2102
  if (identity && detailCode === CONNECT_ERROR_DEVICE_TOKEN_MISMATCH && !this.deviceTokenMismatchRetried) {
1512
2103
  this.deviceTokenMismatchRetried = true;
1513
- clearStoredDeviceToken(identity.deviceId, this.storageScope(), OPERATOR_ROLE);
2104
+ clearStoredDeviceToken(identity.deviceId, this.storageScope(), this.role);
1514
2105
  this.connectSent = false;
1515
2106
  this.pendingConnectError = null;
1516
2107
  this.pendingDeviceTokenRetry = false;
@@ -1531,7 +2122,7 @@ export class GatewayClient {
1531
2122
  return;
1532
2123
  }
1533
2124
  if (identity && detailCode === CONNECT_ERROR_PAIRING_REQUIRED) {
1534
- clearStoredDeviceToken(identity.deviceId, this.storageScope(), OPERATOR_ROLE);
2125
+ clearStoredDeviceToken(identity.deviceId, this.storageScope(), this.role);
1535
2126
  }
1536
2127
  if (detailCode === CONNECT_ERROR_PAIRING_REQUIRED && requestId) {
1537
2128
  if (this.canAutoApprovePairing() && !this.autoApproveAttemptedRequestIds.has(requestId)) {
@@ -1556,7 +2147,7 @@ export class GatewayClient {
1556
2147
  this.pendingConnectError = toCloseError(approvalError);
1557
2148
  this.updatePairingState({
1558
2149
  requestId,
1559
- role: OPERATOR_ROLE,
2150
+ role: this.role,
1560
2151
  gatewayUrl: this.url,
1561
2152
  ...(identity ? { deviceId: identity.deviceId } : {}),
1562
2153
  status: "failed",
@@ -1569,7 +2160,7 @@ export class GatewayClient {
1569
2160
  // No auto-approve — surface pairing state so the UI can prompt.
1570
2161
  this.updatePairingState({
1571
2162
  requestId,
1572
- role: OPERATOR_ROLE,
2163
+ role: this.role,
1573
2164
  gatewayUrl: this.url,
1574
2165
  ...(identity ? { deviceId: identity.deviceId } : {}),
1575
2166
  status: "pending",
@@ -1583,19 +2174,18 @@ export class GatewayClient {
1583
2174
  }
1584
2175
  }
1585
2176
  handleMessage(raw) {
1586
- let message;
1587
- try {
1588
- message = JSON.parse(raw);
1589
- }
1590
- catch {
2177
+ const decoded = decodeGatewayFrame(raw);
2178
+ if (!decoded.ok) {
2179
+ this.reportProtocolError(decoded.error);
2180
+ this.rejectMalformedPendingResponse(decoded.error);
1591
2181
  return;
1592
2182
  }
2183
+ const message = decoded.frame;
1593
2184
  if (message.type === "event") {
1594
2185
  const gatewayEvent = message;
1595
2186
  if (gatewayEvent.event === "connect.challenge") {
1596
- const nonce = gatewayEvent.payload && typeof gatewayEvent.payload.nonce === "string"
1597
- ? gatewayEvent.payload.nonce.trim()
1598
- : "";
2187
+ const challenge = asRecord(gatewayEvent.payload);
2188
+ const nonce = typeof challenge?.nonce === "string" ? challenge.nonce.trim() : "";
1599
2189
  if (!nonce) {
1600
2190
  this.pendingConnectError = {
1601
2191
  code: "DEVICE_AUTH_NONCE_REQUIRED",
@@ -1608,67 +2198,161 @@ export class GatewayClient {
1608
2198
  void this.sendConnect();
1609
2199
  return;
1610
2200
  }
2201
+ let sequenceGap = null;
1611
2202
  if (typeof gatewayEvent.seq === "number") {
1612
- if (this.lastSeq !== null && gatewayEvent.seq > this.lastSeq + 1) {
1613
- this.onGap?.({ expected: this.lastSeq + 1, received: gatewayEvent.seq });
2203
+ if (this.lastSeq !== null) {
2204
+ if (gatewayEvent.seq === this.lastSeq) {
2205
+ this.reportProtocolError({
2206
+ code: "DUPLICATE_SEQUENCE",
2207
+ message: "duplicate gateway event sequence was discarded",
2208
+ frameType: "event",
2209
+ event: gatewayEvent.event,
2210
+ });
2211
+ return;
2212
+ }
2213
+ if (gatewayEvent.seq < this.lastSeq) {
2214
+ this.reportProtocolError({
2215
+ code: "OUT_OF_ORDER_SEQUENCE",
2216
+ message: "out-of-order gateway event sequence was discarded",
2217
+ frameType: "event",
2218
+ event: gatewayEvent.event,
2219
+ });
2220
+ return;
2221
+ }
2222
+ if (gatewayEvent.seq > this.lastSeq + 1) {
2223
+ sequenceGap = { expected: this.lastSeq + 1, received: gatewayEvent.seq };
2224
+ }
1614
2225
  }
1615
2226
  this.lastSeq = gatewayEvent.seq;
1616
2227
  }
1617
- for (const handler of this.eventHandlers) {
2228
+ const payload = asRecord(gatewayEvent.payload) ?? {};
2229
+ const identity = chatEventIdentity(payload);
2230
+ if (isChatStreamGatewayEvent(gatewayEvent) &&
2231
+ !identity.runId &&
2232
+ !identity.sessionKey &&
2233
+ this.activeNormalChatStreams.size > 1) {
2234
+ this.reportProtocolError({
2235
+ code: "AMBIGUOUS_CHAT_STREAM_EVENT",
2236
+ message: "chat stream event without run or session identity was discarded",
2237
+ frameType: "event",
2238
+ event: gatewayEvent.event,
2239
+ });
2240
+ this.interruptNormalChatStreams(new GatewayChatStreamInterruptedError("ambiguous-event"));
2241
+ if (sequenceGap)
2242
+ this.handleForwardSequenceGap(sequenceGap, gatewayEvent);
2243
+ return;
2244
+ }
2245
+ let handledInternally = false;
2246
+ for (const handler of this.internalEventHandlers) {
1618
2247
  try {
1619
- handler(gatewayEvent);
2248
+ handledInternally = handler(gatewayEvent) || handledInternally;
1620
2249
  }
1621
2250
  catch {
1622
- // Event handlers are isolated from the socket lifecycle.
2251
+ // Internal stream handlers are isolated from the socket lifecycle.
1623
2252
  }
1624
2253
  }
1625
- return;
1626
- }
1627
- if (message.type !== "res") {
2254
+ this.publishPublicEvent(gatewayEvent, handledInternally);
2255
+ if (sequenceGap)
2256
+ this.handleForwardSequenceGap(sequenceGap, gatewayEvent);
1628
2257
  return;
1629
2258
  }
1630
2259
  const pending = this.pending.get(message.id);
1631
2260
  if (!pending) {
1632
2261
  return;
1633
2262
  }
2263
+ const payload = message.ok ? asRecord(message.payload) : null;
2264
+ if (pending.expectFinal && payload?.status === "accepted") {
2265
+ if (!pending.acceptedNotified) {
2266
+ pending.acceptedNotified = true;
2267
+ try {
2268
+ pending.onAccepted?.(payload);
2269
+ }
2270
+ catch {
2271
+ // Accepted callbacks are observational and must not break the request.
2272
+ }
2273
+ }
2274
+ return;
2275
+ }
1634
2276
  this.pending.delete(message.id);
1635
- clearTimeout(pending.timer);
2277
+ pending.cleanup();
1636
2278
  if (message.ok) {
1637
2279
  pending.resolve(message.payload);
1638
2280
  return;
1639
2281
  }
1640
- pending.reject(new GatewayRequestError({
1641
- code: message.error?.code ?? "UNAVAILABLE",
1642
- message: message.error?.message ?? "gateway request failed",
1643
- details: message.error?.details,
1644
- }));
2282
+ pending.reject(new GatewayRequestError(message.error));
1645
2283
  }
1646
- sendRawRequest(method, params = {}, timeout = this.defaultTimeout, allowBeforeHello = false) {
2284
+ sendRawRequest(method, params = {}, options = {}, allowBeforeHello = false) {
1647
2285
  if (!this.ws || !isSocketOpen(this.ws)) {
1648
2286
  return Promise.reject(new Error("gateway not connected"));
1649
2287
  }
1650
2288
  if (!allowBeforeHello && !this.connected) {
1651
2289
  return Promise.reject(new Error("gateway not connected"));
1652
2290
  }
2291
+ if (options.signal?.aborted) {
2292
+ return Promise.reject(createGatewayRequestAbortError(method));
2293
+ }
1653
2294
  const id = makeId();
1654
2295
  const request = { type: "req", id, method, params };
1655
- return new Promise((resolve, reject) => {
1656
- const timer = setTimeout(() => {
2296
+ const expectFinal = options.expectFinal === true;
2297
+ const timeout = options.timeoutMs === null
2298
+ ? null
2299
+ : typeof options.timeoutMs === "number" && Number.isFinite(options.timeoutMs)
2300
+ ? Math.max(0, options.timeoutMs)
2301
+ : expectFinal
2302
+ ? null
2303
+ : this.defaultTimeout;
2304
+ const promise = new Promise((resolve, reject) => {
2305
+ const timer = timeout === null
2306
+ ? null
2307
+ : setTimeout(() => {
2308
+ const pending = this.pending.get(id);
2309
+ this.pending.delete(id);
2310
+ pending?.cleanup();
2311
+ reject(new Error(`RPC timeout: ${method}`));
2312
+ }, timeout);
2313
+ const abortHandler = () => {
2314
+ const pending = this.pending.get(id);
1657
2315
  this.pending.delete(id);
1658
- reject(new Error(`RPC timeout: ${method}`));
1659
- }, timeout);
1660
- this.pending.set(id, { resolve, reject, timer });
1661
- this.ws?.send(JSON.stringify(request));
2316
+ pending?.cleanup();
2317
+ reject(createGatewayRequestAbortError(method));
2318
+ };
2319
+ const cleanup = () => {
2320
+ if (timer) {
2321
+ clearTimeout(timer);
2322
+ }
2323
+ options.signal?.removeEventListener("abort", abortHandler);
2324
+ };
2325
+ this.pending.set(id, {
2326
+ resolve,
2327
+ reject,
2328
+ expectFinal,
2329
+ timer,
2330
+ cleanup,
2331
+ onAccepted: options.onAccepted,
2332
+ });
2333
+ options.signal?.addEventListener("abort", abortHandler, { once: true });
1662
2334
  });
2335
+ try {
2336
+ this.ws?.send(JSON.stringify(request));
2337
+ }
2338
+ catch (error) {
2339
+ const pending = this.pending.get(id);
2340
+ this.pending.delete(id);
2341
+ pending?.cleanup();
2342
+ throw error;
2343
+ }
2344
+ return promise;
1663
2345
  }
1664
2346
  // ---------------------------------------------------------------------------
1665
2347
  // RPC
1666
2348
  // ---------------------------------------------------------------------------
1667
2349
  rpc(method, params = {}, timeout) {
1668
- return this.sendRawRequest(method, params, timeout ?? this.defaultTimeout);
2350
+ return this.sendRawRequest(method, params, {
2351
+ timeoutMs: timeout === undefined ? this.defaultTimeout : timeout,
2352
+ });
1669
2353
  }
1670
- request(method, params = {}, timeout) {
1671
- return this.rpc(method, params, timeout);
2354
+ request(method, params = {}, options) {
2355
+ return this.sendRawRequest(method, params, typeof options === "object" && options !== null ? options : { timeoutMs: options });
1672
2356
  }
1673
2357
  // ---------------------------------------------------------------------------
1674
2358
  // Config
@@ -1691,6 +2375,9 @@ export class GatewayClient {
1691
2375
  const res = await this.rpc("config.schema");
1692
2376
  return normalizeOpenClawConfigSchema(res) ?? { schema: {}, uiHints: {} };
1693
2377
  }
2378
+ async configSchemaLookup(path) {
2379
+ return await this.rpc("config.schema.lookup", { path });
2380
+ }
1694
2381
  async configPatch(patch) {
1695
2382
  const { hash, baseHash } = await this.rpc("config.get");
1696
2383
  await this.rpc("config.patch", {
@@ -1712,6 +2399,42 @@ export class GatewayClient {
1712
2399
  baseHash: hash ?? baseHash ?? "",
1713
2400
  });
1714
2401
  }
2402
+ async configureSlackSocket(config, accountId) {
2403
+ await this.configPatch(channelConfigPatch("slack", { ...config, mode: "socket" }, accountId));
2404
+ }
2405
+ async configureSlackHttp(config, accountId) {
2406
+ await this.configPatch(channelConfigPatch("slack", { ...config, mode: "http" }, accountId));
2407
+ }
2408
+ async configureSlackRelay(options, accountId) {
2409
+ if ("relay" in options) {
2410
+ await this.configPatch(channelConfigPatch("slack", {
2411
+ ...options,
2412
+ enterpriseOrgInstall: false,
2413
+ mode: "relay",
2414
+ }, accountId));
2415
+ return;
2416
+ }
2417
+ const relayConfig = {
2418
+ mode: "relay",
2419
+ ...(options.botToken ? { botToken: options.botToken } : {}),
2420
+ relay: {
2421
+ url: options.url,
2422
+ authToken: {
2423
+ source: "env",
2424
+ provider: "default",
2425
+ id: options.authTokenEnv ?? "HYPER_AGENTS_API_KEY",
2426
+ },
2427
+ gatewayId: options.gatewayId,
2428
+ },
2429
+ };
2430
+ await this.configPatch(channelConfigPatch("slack", relayConfig, options.accountId, Boolean(options.accountId)));
2431
+ }
2432
+ async configureTelegram(config, accountId) {
2433
+ await this.configPatch(channelConfigPatch("telegram", config, accountId));
2434
+ }
2435
+ async configureWhatsapp(config, accountId) {
2436
+ await this.configPatch(channelConfigPatch("whatsapp", config, accountId));
2437
+ }
1715
2438
  async modelsList() {
1716
2439
  const res = await this.rpc("models.list");
1717
2440
  return res?.models ?? res ?? [];
@@ -1742,6 +2465,18 @@ export class GatewayClient {
1742
2465
  const isClawHubUpdate = "source" in params && params.source === "clawhub";
1743
2466
  return await this.rpc("skills.update", params, isClawHubUpdate ? SKILLS_MUTATION_TIMEOUT : undefined);
1744
2467
  }
2468
+ async integrationsAuthStart(params) {
2469
+ return await this.rpc("integrations.auth.start", params, 30_000);
2470
+ }
2471
+ async integrationsAuthStatus(params) {
2472
+ return await this.rpc("integrations.auth.status", params);
2473
+ }
2474
+ async integrationsStatus(params = {}) {
2475
+ return await this.rpc("integrations.status", params);
2476
+ }
2477
+ async integrationsDisconnect(params) {
2478
+ return await this.rpc("integrations.disconnect", params);
2479
+ }
1745
2480
  async waitReady(timeoutMs = 300_000, options = {}) {
1746
2481
  const retryIntervalMs = options.retryIntervalMs ?? 5_000;
1747
2482
  const probe = options.probe ?? "config";
@@ -1769,18 +2504,111 @@ export class GatewayClient {
1769
2504
  const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
1770
2505
  throw new Error(`Gateway readiness probe timed out after ${timeoutMs}ms${detail}`);
1771
2506
  }
1772
- async channelsStatus(probe = false, timeoutMs) {
2507
+ async channelsStatus(probe = false, timeoutMs, channel) {
1773
2508
  const params = { probe };
1774
2509
  if (timeoutMs !== undefined)
1775
2510
  params.timeoutMs = timeoutMs;
2511
+ if (channel !== undefined)
2512
+ params.channel = channel;
1776
2513
  return await this.rpc("channels.status", params);
1777
2514
  }
2515
+ async channelsStart(channel, accountId) {
2516
+ const params = { channel };
2517
+ if (accountId !== undefined)
2518
+ params.accountId = accountId;
2519
+ return await this.rpc("channels.start", params);
2520
+ }
2521
+ async channelsStop(channel, accountId) {
2522
+ const params = { channel };
2523
+ if (accountId !== undefined)
2524
+ params.accountId = accountId;
2525
+ return await this.rpc("channels.stop", params);
2526
+ }
1778
2527
  async channelsLogout(channel, accountId) {
1779
2528
  const params = { channel };
1780
- if (accountId)
2529
+ if (accountId !== undefined)
1781
2530
  params.accountId = accountId;
1782
2531
  return await this.rpc("channels.logout", params);
1783
2532
  }
2533
+ async messageAction(params) {
2534
+ const request = {
2535
+ channel: params.channel,
2536
+ action: params.action,
2537
+ params: params.params,
2538
+ idempotencyKey: params.idempotencyKey ?? makeId(),
2539
+ };
2540
+ if (params.accountId !== undefined)
2541
+ request.accountId = params.accountId;
2542
+ if (params.sessionKey !== undefined)
2543
+ request.sessionKey = params.sessionKey;
2544
+ if (params.sessionId !== undefined)
2545
+ request.sessionId = params.sessionId;
2546
+ if (params.inboundTurnKind !== undefined)
2547
+ request.inboundTurnKind = params.inboundTurnKind;
2548
+ if (params.agentId !== undefined)
2549
+ request.agentId = params.agentId;
2550
+ if (params.conversationReadOrigin !== undefined) {
2551
+ request.conversationReadOrigin = params.conversationReadOrigin;
2552
+ }
2553
+ return await this.rpc("message.action", request);
2554
+ }
2555
+ async send(params) {
2556
+ return await this.rpc("send", {
2557
+ ...params,
2558
+ idempotencyKey: params.idempotencyKey ?? makeId(),
2559
+ });
2560
+ }
2561
+ async readMediaBytes(pathOrUrl) {
2562
+ const token = this.gatewayToken ?? this.deviceToken ?? this.password;
2563
+ if (!token) {
2564
+ throw new Error("Gateway media requires an auth token");
2565
+ }
2566
+ if (typeof globalThis.fetch !== "function") {
2567
+ throw new Error("fetch is not available in this runtime");
2568
+ }
2569
+ const base = new URL(this.url);
2570
+ const protocol = base.protocol === "wss:" ? "https:" : base.protocol === "ws:" ? "http:" : base.protocol;
2571
+ const url = pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")
2572
+ ? pathOrUrl
2573
+ : `${protocol}//${base.host}${pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`}`;
2574
+ const response = await globalThis.fetch(url, {
2575
+ headers: { Authorization: `Bearer ${token}` },
2576
+ });
2577
+ if (!response.ok) {
2578
+ throw new Error(`Gateway media read failed: ${response.status} ${response.statusText}`);
2579
+ }
2580
+ return new Uint8Array(await response.arrayBuffer());
2581
+ }
2582
+ async pluginsList() {
2583
+ return await this.rpc("plugins.list", {});
2584
+ }
2585
+ async pluginsInstall(params) {
2586
+ return await this.rpc("plugins.install", params, Math.max(PLUGIN_MUTATION_TIMEOUT, this.defaultTimeout));
2587
+ }
2588
+ async pluginsSetEnabled(params) {
2589
+ return await this.rpc("plugins.setEnabled", params);
2590
+ }
2591
+ async pluginsUninstall(params) {
2592
+ return await this.rpc("plugins.uninstall", params, Math.max(PLUGIN_MUTATION_TIMEOUT, this.defaultTimeout));
2593
+ }
2594
+ async pluginsRefresh() {
2595
+ return await this.rpc("plugins.refresh", {});
2596
+ }
2597
+ async toolsCatalog(params = {}) {
2598
+ return await this.rpc("tools.catalog", params);
2599
+ }
2600
+ async toolsEffective(params) {
2601
+ return await this.rpc("tools.effective", params);
2602
+ }
2603
+ async toolsInvoke(params) {
2604
+ return await this.rpc("tools.invoke", {
2605
+ ...params,
2606
+ idempotencyKey: params.idempotencyKey ?? makeId(),
2607
+ });
2608
+ }
2609
+ async commandsList(params = {}) {
2610
+ return await this.rpc("commands.list", params);
2611
+ }
1784
2612
  async webLoginStart(options = {}) {
1785
2613
  const params = {};
1786
2614
  if (options.force)
@@ -1799,19 +2627,38 @@ export class GatewayClient {
1799
2627
  params.timeoutMs = options.timeoutMs;
1800
2628
  if (options.accountId)
1801
2629
  params.accountId = options.accountId;
1802
- return await this.rpc("web.login.wait", params, CHAT_TIMEOUT);
2630
+ if (options.currentQrDataUrl)
2631
+ params.currentQrDataUrl = options.currentQrDataUrl;
2632
+ return await this.rpc("web.login.wait", params, WEB_LOGIN_WAIT_TIMEOUT);
1803
2633
  }
1804
2634
  // ---------------------------------------------------------------------------
1805
2635
  // Sessions
1806
2636
  // ---------------------------------------------------------------------------
1807
- async sessionsList() {
2637
+ async sessionsListResult() {
1808
2638
  const res = await this.rpc("sessions.list");
1809
- return res?.sessions ?? res ?? [];
2639
+ if (Array.isArray(res))
2640
+ return { sessions: res };
2641
+ if (!res || typeof res !== "object")
2642
+ return { sessions: [] };
2643
+ return {
2644
+ ...res,
2645
+ sessions: Array.isArray(res.sessions) ? res.sessions : [],
2646
+ };
2647
+ }
2648
+ async sessionsList() {
2649
+ return (await this.sessionsListResult()).sessions;
1810
2650
  }
1811
2651
  async sessionsPreview(sessionKey, limit = 20) {
1812
2652
  const res = await this.rpc("sessions.preview", { keys: [sessionKey], limit });
1813
2653
  return res?.previews?.[0]?.items ?? [];
1814
2654
  }
2655
+ async sessionsSubscribe() {
2656
+ const result = await this.rpc("sessions.subscribe", {});
2657
+ return result?.subscribed === true;
2658
+ }
2659
+ async sessionsCreate(params = {}) {
2660
+ return await this.rpc("sessions.create", params);
2661
+ }
1815
2662
  async sessionsPatch(patch) {
1816
2663
  return await this.rpc("sessions.patch", patch);
1817
2664
  }
@@ -1820,7 +2667,14 @@ export class GatewayClient {
1820
2667
  if (sessionKey)
1821
2668
  params.sessionKey = sessionKey;
1822
2669
  const res = await this.rpc("chat.history", params);
1823
- return res?.messages ?? res ?? [];
2670
+ if (Array.isArray(res))
2671
+ return res;
2672
+ if (asRecord(res) && Array.isArray(res.messages))
2673
+ return res.messages;
2674
+ throw new GatewayRequestError({
2675
+ code: "PROTOCOL_ERROR",
2676
+ message: "Gateway protocol error: chat.history response must be an array or an object with an array `messages` property",
2677
+ });
1824
2678
  }
1825
2679
  async chatAbort(sessionKey) {
1826
2680
  const params = {};
@@ -1828,59 +2682,359 @@ export class GatewayClient {
1828
2682
  params.sessionKey = sessionKey;
1829
2683
  await this.rpc("chat.abort", params);
1830
2684
  }
1831
- async sendChat(message, sessionKey = "main", agentId, attachments) {
2685
+ async sendChat(message, sessionKey, agentId, attachments) {
2686
+ const resolvedSessionKey = sessionKey?.trim() || createOpenClawSdkSessionKey();
1832
2687
  const normalizedAttachments = normalizeChatAttachments(attachments);
1833
2688
  const params = {
1834
2689
  message,
1835
2690
  deliver: false,
1836
- sessionKey,
2691
+ sessionKey: resolvedSessionKey,
1837
2692
  idempotencyKey: makeId(),
1838
2693
  };
1839
2694
  if (agentId)
1840
2695
  params.agentId = agentId;
1841
2696
  if (normalizedAttachments)
1842
2697
  params.attachments = normalizedAttachments;
1843
- return this.rpc("chat.send", params, CHAT_TIMEOUT);
2698
+ return this.rpc("chat.send", params, DEFAULT_AGENT_TIMEOUT);
1844
2699
  }
1845
2700
  async sessionsReset(sessionKey, reason) {
1846
2701
  const params = { key: sessionKey };
1847
2702
  if (reason)
1848
2703
  params.reason = reason;
1849
- await this.rpc("sessions.reset", params);
2704
+ const result = await this.rpc("sessions.reset", params);
2705
+ const canonicalKey = result?.key ?? result?.sessionKey ?? result?.session?.key;
2706
+ return typeof canonicalKey === "string" && canonicalKey.trim() ? canonicalKey.trim() : sessionKey;
2707
+ }
2708
+ async createEphemeralChatSession() {
2709
+ if (!this.connected || !this.ws)
2710
+ throw new Error("Not connected");
2711
+ const requestedSessionKey = `${EPHEMERAL_SESSION_PREFIX}${makeId()}`;
2712
+ const canonicalSessionKey = await this.sessionsReset(requestedSessionKey, "new");
2713
+ const sessionKey = validateEphemeralCanonicalKey(requestedSessionKey, canonicalSessionKey);
2714
+ if (!sessionKey) {
2715
+ await this.sessionsReset(requestedSessionKey, "reset").catch(() => undefined);
2716
+ throw new Error(`Gateway returned an unsafe ephemeral session key: ${canonicalSessionKey}`);
2717
+ }
2718
+ this.activeEphemeralSessionKeys.set(sessionKey, null);
2719
+ let closed = false;
2720
+ let activeStream = null;
2721
+ let closePromise = null;
2722
+ let turnsStarted = 0;
2723
+ let backendTurnMayBeActive = false;
2724
+ const priorAssistantTexts = [];
2725
+ const assertOpen = () => {
2726
+ if (closed)
2727
+ throw new Error("Ephemeral chat session is closed.");
2728
+ };
2729
+ const session = {
2730
+ sessionKey,
2731
+ get closed() {
2732
+ return closed;
2733
+ },
2734
+ chatSend: (message, attachments) => {
2735
+ assertOpen();
2736
+ if (activeStream || backendTurnMayBeActive) {
2737
+ throw new Error("Ephemeral chat session already has an active turn.");
2738
+ }
2739
+ normalizeChatAttachments(attachments);
2740
+ const source = this.chatSend(message, sessionKey, attachments, {
2741
+ strictCorrelation: true,
2742
+ ephemeralSession: true,
2743
+ ...(turnsStarted > 0 ? { captureHistoryBaseline: true } : {}),
2744
+ ...(priorAssistantTexts.length > 0 ? { priorAssistantTexts: [...priorAssistantTexts] } : {}),
2745
+ });
2746
+ turnsStarted += 1;
2747
+ backendTurnMayBeActive = true;
2748
+ let turnText = "";
2749
+ let turnRecorded = false;
2750
+ const observeEvent = (event) => {
2751
+ turnText = applyChatEventContent(turnText, event);
2752
+ if (event.type !== "done" && event.type !== "error")
2753
+ return;
2754
+ backendTurnMayBeActive = false;
2755
+ if (event.type === "done" && turnText.trim() && !turnRecorded) {
2756
+ turnRecorded = true;
2757
+ priorAssistantTexts.push(turnText.trim());
2758
+ }
2759
+ };
2760
+ const release = () => {
2761
+ if (activeStream === stream)
2762
+ activeStream = null;
2763
+ };
2764
+ const stream = {
2765
+ async next(value) {
2766
+ try {
2767
+ const result = await source.next(value);
2768
+ if (result.done) {
2769
+ backendTurnMayBeActive = false;
2770
+ release();
2771
+ }
2772
+ else {
2773
+ observeEvent(result.value);
2774
+ }
2775
+ return result;
2776
+ }
2777
+ catch (error) {
2778
+ release();
2779
+ throw error;
2780
+ }
2781
+ },
2782
+ async return(value) {
2783
+ try {
2784
+ return await source.return(value);
2785
+ }
2786
+ finally {
2787
+ release();
2788
+ }
2789
+ },
2790
+ async throw(error) {
2791
+ try {
2792
+ const result = await source.throw(error);
2793
+ if (result.done) {
2794
+ backendTurnMayBeActive = false;
2795
+ release();
2796
+ }
2797
+ else {
2798
+ observeEvent(result.value);
2799
+ }
2800
+ return result;
2801
+ }
2802
+ catch (cause) {
2803
+ release();
2804
+ throw cause;
2805
+ }
2806
+ },
2807
+ [Symbol.asyncIterator]() {
2808
+ return stream;
2809
+ },
2810
+ };
2811
+ activeStream = stream;
2812
+ return stream;
2813
+ },
2814
+ chatHistory: (limit = 50) => {
2815
+ assertOpen();
2816
+ return this.chatHistory(sessionKey, limit);
2817
+ },
2818
+ chatAbort: async () => {
2819
+ assertOpen();
2820
+ const stream = activeStream;
2821
+ await this.chatAbort(sessionKey);
2822
+ backendTurnMayBeActive = false;
2823
+ if (stream && !await promiseSettlesWithin(stream.return(undefined), EPHEMERAL_TURN_CLOSE_WAIT_MS)) {
2824
+ throw new Error("Ephemeral chat turn is still stopping.");
2825
+ }
2826
+ },
2827
+ close: () => {
2828
+ if (closePromise)
2829
+ return closePromise;
2830
+ closed = true;
2831
+ const stream = activeStream;
2832
+ const abortPromise = backendTurnMayBeActive
2833
+ ? this.chatAbort(sessionKey).then(() => {
2834
+ backendTurnMayBeActive = false;
2835
+ return true;
2836
+ }, () => false)
2837
+ : Promise.resolve(true);
2838
+ const streamReturnPromise = stream?.return(undefined);
2839
+ let cleanupComplete = false;
2840
+ const attempt = (async () => {
2841
+ try {
2842
+ const backendStopped = await booleanPromiseWithin(abortPromise, EPHEMERAL_TURN_CLOSE_WAIT_MS);
2843
+ if (!backendStopped)
2844
+ throw new Error("Ephemeral chat turn did not stop before cleanup timed out.");
2845
+ if (streamReturnPromise) {
2846
+ await promiseSettlesWithin(streamReturnPromise, EPHEMERAL_TURN_CLOSE_WAIT_MS);
2847
+ }
2848
+ await this.sessionsReset(sessionKey, "reset");
2849
+ backendTurnMayBeActive = false;
2850
+ cleanupComplete = true;
2851
+ }
2852
+ finally {
2853
+ if (cleanupComplete) {
2854
+ this.activeEphemeralSessionKeys.delete(sessionKey);
2855
+ }
2856
+ else {
2857
+ this.activeEphemeralSessionKeys.set(sessionKey, Date.now() + EPHEMERAL_RUN_ID_SUPPRESSION_MS);
2858
+ }
2859
+ }
2860
+ })();
2861
+ closePromise = attempt;
2862
+ void attempt.catch(() => {
2863
+ if (closePromise === attempt)
2864
+ closePromise = null;
2865
+ });
2866
+ return attempt;
2867
+ },
2868
+ };
2869
+ return session;
2870
+ }
2871
+ // ---------------------------------------------------------------------------
2872
+ // Nodes (operator-side: manage & drive paired nodes)
2873
+ //
2874
+ // A "node" is a device/machine connected to the same gateway with
2875
+ // `role: "node"` that exposes a command surface. An operator lists/approves
2876
+ // node pairings and drives them with `node.invoke`; the node executes the
2877
+ // command locally and returns the result. This is the gateway's built-in
2878
+ // "agent asks a connected machine to do X and use the result" path.
2879
+ // ---------------------------------------------------------------------------
2880
+ /** Paired nodes known to the gateway. */
2881
+ async nodesList() {
2882
+ const res = await this.rpc("node.list");
2883
+ return res?.nodes ?? res ?? [];
2884
+ }
2885
+ /** Detailed metadata for one paired node (caps + supported invoke commands). */
2886
+ async nodeDescribe(nodeId) {
2887
+ return await this.rpc("node.describe", { nodeId });
2888
+ }
2889
+ /** Pending and paired node-pairing requests. */
2890
+ async nodePairList() {
2891
+ const res = await this.rpc("node.pair.list");
2892
+ return { pending: res?.pending ?? [], paired: res?.paired ?? [] };
2893
+ }
2894
+ /**
2895
+ * Approve a pending node pairing (and its declared command surface). This is
2896
+ * the sibling of `device.pair.approve`; it requires `operator.pairing` and,
2897
+ * for command-bearing nodes, `operator.write`/`operator.admin`.
2898
+ */
2899
+ async nodePairApprove(requestId) {
2900
+ return await this.rpc("node.pair.approve", { requestId });
2901
+ }
2902
+ /** Reject a pending node pairing request. */
2903
+ async nodePairReject(requestId) {
2904
+ await this.rpc("node.pair.reject", { requestId });
2905
+ }
2906
+ /** Remove an already-paired node from the gateway trust set. */
2907
+ async nodePairRemove(nodeId) {
2908
+ await this.rpc("node.pair.remove", { nodeId });
2909
+ }
2910
+ /** Rename a paired node while preserving its stable node id. */
2911
+ async nodeRename(nodeId, displayName) {
2912
+ await this.rpc("node.rename", { nodeId, displayName });
2913
+ }
2914
+ /**
2915
+ * Invoke a command on a paired node and return its result. The node must be
2916
+ * connected, have declared `command`, and the command must be allowed by the
2917
+ * gateway policy (declared + approved surface, and/or
2918
+ * `gateway.nodes.allowCommands`).
2919
+ */
2920
+ async nodeInvoke(nodeId, command, params = {}, timeoutMs) {
2921
+ const rpcParams = {
2922
+ nodeId,
2923
+ command,
2924
+ params,
2925
+ idempotencyKey: makeId(),
2926
+ };
2927
+ if (timeoutMs !== undefined)
2928
+ rpcParams.timeoutMs = timeoutMs;
2929
+ return await this.rpc("node.invoke", rpcParams, timeoutMs);
1850
2930
  }
1851
2931
  // ---------------------------------------------------------------------------
1852
2932
  // Chat (streaming via events)
1853
2933
  // ---------------------------------------------------------------------------
1854
- async *chatSend(message, sessionKey, attachments) {
2934
+ async *chatSend(message, sessionKey, attachments, options = {}) {
1855
2935
  if (!this.connected || !this.ws) {
1856
2936
  throw new Error("Not connected");
1857
2937
  }
1858
2938
  const idempotencyKey = makeId();
1859
2939
  const acceptedRunIds = new Set([idempotencyKey]);
2940
+ if (options.ephemeralSession)
2941
+ this.suppressEphemeralRunId(idempotencyKey);
1860
2942
  const queuedEvents = [];
1861
2943
  let resolveWait = null;
1862
- let streamedDisplayText = false;
1863
- let lastLegacyText = "";
2944
+ let correlationReady = false;
2945
+ let emittedDisplayText = "";
1864
2946
  let lastThinkingText = "";
1865
2947
  const seenToolCallIds = new Set();
1866
2948
  const seenToolResultIds = new Set();
2949
+ let acknowledgementPending = false;
2950
+ let historyBaseline;
2951
+ let streamCloseError = null;
2952
+ let resolveStreamClose = null;
2953
+ const streamClosed = new Promise((resolve) => {
2954
+ resolveStreamClose = resolve;
2955
+ });
2956
+ let streamState = null;
2957
+ const wakeWaiter = () => {
2958
+ const waiter = resolveWait;
2959
+ resolveWait = null;
2960
+ waiter?.();
2961
+ };
2962
+ const closeHandler = (error) => {
2963
+ if (streamCloseError)
2964
+ return;
2965
+ streamCloseError = error;
2966
+ this.internalEventHandlers.delete(handler);
2967
+ this.internalStreamCloseHandlers.delete(closeHandler);
2968
+ if (streamState) {
2969
+ this.activeNormalChatStreams.delete(streamState);
2970
+ this.activeStrictChatStreams.delete(streamState);
2971
+ }
2972
+ resolveStreamClose?.(error);
2973
+ wakeWaiter();
2974
+ };
2975
+ const stopOnStreamClose = async (promise) => {
2976
+ return await Promise.race([
2977
+ promise,
2978
+ streamClosed.then((error) => Promise.reject(error)),
2979
+ ]);
2980
+ };
1867
2981
  const handler = (evt) => {
1868
- const isAgentStreamEvent = evt.event === "agent" &&
1869
- (() => {
1870
- const payload = asRecord(evt.payload) ?? {};
1871
- const stream = typeof payload.stream === "string" ? payload.stream.toLowerCase() : "";
1872
- return stream === "tool" || stream === "lifecycle";
1873
- })();
1874
- if (evt.event === "chat" || evt.event?.startsWith("chat.") || isAgentStreamEvent) {
1875
- queuedEvents.push(evt);
1876
- const waiter = resolveWait;
1877
- resolveWait = null;
1878
- waiter?.();
2982
+ if (!isChatStreamGatewayEvent(evt))
2983
+ return false;
2984
+ const payload = asRecord(evt.payload) ?? {};
2985
+ const identity = chatEventIdentity(payload);
2986
+ const payloadRunId = identity.runId?.trim() ?? "";
2987
+ const payloadSessionKey = identity.sessionKey?.trim() ?? "";
2988
+ if (payloadSessionKey && !sameSessionKey(payloadSessionKey, sessionKey))
2989
+ return false;
2990
+ if (payloadRunId && correlationReady && !acceptedRunIds.has(payloadRunId))
2991
+ return false;
2992
+ if (options.strictCorrelation) {
2993
+ if (!payloadRunId && !payloadSessionKey)
2994
+ return false;
2995
+ if (options.ephemeralSession &&
2996
+ !correlationReady &&
2997
+ payloadRunId &&
2998
+ !payloadSessionKey &&
2999
+ !acceptedRunIds.has(payloadRunId)) {
3000
+ this.deferEphemeralCorrelationEvent(evt);
3001
+ }
1879
3002
  }
3003
+ queuedEvents.push(evt);
3004
+ if (correlationReady && isTerminalChatStreamGatewayEvent(evt) && streamState) {
3005
+ streamState.terminalEvent = evt;
3006
+ }
3007
+ wakeWaiter();
3008
+ return options.strictCorrelation === true;
1880
3009
  };
1881
- this.eventHandlers.add(handler);
3010
+ streamState = { close: closeHandler, terminalEvent: null };
1882
3011
  try {
3012
+ if (options.captureHistoryBaseline) {
3013
+ try {
3014
+ historyBaseline = historyAssistantBaseline(await this.chatHistory(sessionKey, 20), options.priorAssistantTexts);
3015
+ }
3016
+ catch {
3017
+ historyBaseline = options.priorAssistantTexts?.length
3018
+ ? historyAssistantBaseline([], options.priorAssistantTexts)
3019
+ : null;
3020
+ }
3021
+ }
3022
+ else if (options.priorAssistantTexts) {
3023
+ historyBaseline = historyAssistantBaseline([], options.priorAssistantTexts);
3024
+ }
1883
3025
  const normalizedAttachments = normalizeChatAttachments(attachments);
3026
+ if (options.ephemeralSession) {
3027
+ this.beginEphemeralChatAcknowledgement();
3028
+ acknowledgementPending = true;
3029
+ }
3030
+ this.internalEventHandlers.add(handler);
3031
+ this.internalStreamCloseHandlers.add(closeHandler);
3032
+ if (options.strictCorrelation) {
3033
+ this.activeStrictChatStreams.add(streamState);
3034
+ }
3035
+ else {
3036
+ this.activeNormalChatStreams.add(streamState);
3037
+ }
1884
3038
  const params = {
1885
3039
  message,
1886
3040
  deliver: false,
@@ -1889,25 +3043,75 @@ export class GatewayClient {
1889
3043
  };
1890
3044
  if (normalizedAttachments)
1891
3045
  params.attachments = normalizedAttachments;
1892
- const ack = await this.rpc("chat.send", params, CHAT_TIMEOUT);
3046
+ const ack = await stopOnStreamClose(this.rpc("chat.send", params, DEFAULT_AGENT_TIMEOUT));
1893
3047
  const serverRunId = typeof ack?.runId === "string" ? ack.runId.trim() : "";
1894
3048
  if (serverRunId) {
1895
3049
  acceptedRunIds.add(serverRunId);
3050
+ if (options.ephemeralSession)
3051
+ this.suppressEphemeralRunId(serverRunId);
1896
3052
  }
3053
+ correlationReady = true;
3054
+ if (queuedEvents.length > 0) {
3055
+ for (let index = queuedEvents.length - 1; index >= 0; index -= 1) {
3056
+ const payload = asRecord(queuedEvents[index]?.payload) ?? {};
3057
+ const identity = chatEventIdentity(payload);
3058
+ const queuedRunId = identity.runId?.trim() ?? "";
3059
+ const queuedSessionKey = identity.sessionKey?.trim() ?? "";
3060
+ const mismatchedSession = Boolean(queuedSessionKey && !sameSessionKey(queuedSessionKey, sessionKey));
3061
+ const unacceptedRun = Boolean(queuedRunId && !acceptedRunIds.has(queuedRunId));
3062
+ if (mismatchedSession || unacceptedRun) {
3063
+ queuedEvents.splice(index, 1);
3064
+ }
3065
+ }
3066
+ streamState.terminalEvent =
3067
+ [...queuedEvents].reverse().find(isTerminalChatStreamGatewayEvent) ?? null;
3068
+ }
3069
+ if (acknowledgementPending) {
3070
+ acknowledgementPending = false;
3071
+ this.finishEphemeralChatAcknowledgement(Boolean(serverRunId));
3072
+ }
3073
+ const readLatestHistoryText = async () => {
3074
+ try {
3075
+ return latestHistoryAssistantText(await stopOnStreamClose(this.chatHistory(sessionKey, 20)), acceptedRunIds, historyBaseline) ?? "";
3076
+ }
3077
+ catch (error) {
3078
+ if (streamCloseError)
3079
+ throw error;
3080
+ return "";
3081
+ }
3082
+ };
1897
3083
  const waitForHistoryText = async (timeoutMs = 10_000) => {
1898
- const historyDeadline = Date.now() + Math.min(timeoutMs, CHAT_TIMEOUT);
3084
+ const historyDeadline = Date.now() + Math.min(timeoutMs, DEFAULT_AGENT_TIMEOUT);
1899
3085
  while (true) {
1900
- const historyText = latestHistoryAssistantText(await this.chatHistory(sessionKey, 20), acceptedRunIds);
3086
+ const historyText = await readLatestHistoryText();
1901
3087
  if (historyText)
1902
3088
  return historyText;
1903
3089
  if (Date.now() >= historyDeadline)
1904
3090
  return "";
1905
- await new Promise((resolve) => setTimeout(resolve, 500));
3091
+ await stopOnStreamClose(new Promise((resolve) => setTimeout(resolve, 500)));
3092
+ }
3093
+ };
3094
+ const reconcileHistoryAfterToolActivity = async (streamedText) => {
3095
+ let historyText = await readLatestHistoryText();
3096
+ if (!historyText || historyText !== streamedText)
3097
+ return historyText;
3098
+ const historyDeadline = Date.now() + 1_500;
3099
+ while (Date.now() < historyDeadline) {
3100
+ await stopOnStreamClose(new Promise((resolve) => setTimeout(resolve, 500)));
3101
+ const nextHistoryText = await readLatestHistoryText();
3102
+ if (!nextHistoryText)
3103
+ continue;
3104
+ historyText = nextHistoryText;
3105
+ if (historyText !== streamedText)
3106
+ break;
1906
3107
  }
3108
+ return historyText;
1907
3109
  };
1908
- let deadline = Date.now() + CHAT_TIMEOUT;
3110
+ let deadline = Date.now() + DEFAULT_AGENT_TIMEOUT;
1909
3111
  while (Date.now() < deadline) {
1910
3112
  if (queuedEvents.length === 0) {
3113
+ if (streamCloseError)
3114
+ throw streamCloseError;
1911
3115
  const remainingMs = Math.max(100, Math.min(1000, deadline - Date.now()));
1912
3116
  await new Promise((resolve) => {
1913
3117
  const timer = setTimeout(() => {
@@ -1922,24 +3126,34 @@ export class GatewayClient {
1922
3126
  };
1923
3127
  resolveWait = release;
1924
3128
  });
3129
+ if (queuedEvents.length === 0 && streamCloseError)
3130
+ throw streamCloseError;
1925
3131
  continue;
1926
3132
  }
1927
3133
  const evt = queuedEvents.shift();
1928
3134
  const payload = asRecord(evt.payload) ?? {};
1929
- const payloadRunId = typeof payload.runId === "string" ? payload.runId.trim() : "";
1930
- const payloadSessionKey = typeof payload.sessionKey === "string" ? payload.sessionKey.trim() : "";
3135
+ const routingIdentity = chatEventIdentity(payload);
3136
+ const payloadRunId = routingIdentity.runId?.trim() ?? "";
3137
+ const payloadSessionKey = routingIdentity.sessionKey?.trim() ?? "";
1931
3138
  if (payloadRunId && !acceptedRunIds.has(payloadRunId)) {
1932
3139
  continue;
1933
3140
  }
1934
3141
  if (payloadSessionKey && !sameSessionKey(payloadSessionKey, sessionKey)) {
1935
3142
  continue;
1936
3143
  }
1937
- deadline = Date.now() + CHAT_TIMEOUT;
3144
+ const identity = chatEventIdentity(payload);
3145
+ deadline = Date.now() + DEFAULT_AGENT_TIMEOUT;
1938
3146
  if (evt.event === "chat.content") {
1939
3147
  const text = typeof payload.text === "string" ? payload.text : "";
1940
- if (text) {
1941
- streamedDisplayText = true;
1942
- yield { type: "content", text };
3148
+ const update = appendStreamContent(emittedDisplayText, text, payload.replace === true);
3149
+ if (update) {
3150
+ emittedDisplayText = update.nextText;
3151
+ yield {
3152
+ type: "content",
3153
+ text: update.text,
3154
+ ...(update.replace ? { replace: true } : {}),
3155
+ ...identity,
3156
+ };
1943
3157
  }
1944
3158
  continue;
1945
3159
  }
@@ -1952,6 +3166,7 @@ export class GatewayClient {
1952
3166
  seenToolCallIds.add(toolCallId);
1953
3167
  yield {
1954
3168
  type: "tool_call",
3169
+ ...identity,
1955
3170
  data: {
1956
3171
  ...gatewayToolStreamPayload(toolPayload),
1957
3172
  args: toolPayload.args,
@@ -1962,6 +3177,7 @@ export class GatewayClient {
1962
3177
  seenToolResultIds.add(toolCallId);
1963
3178
  yield {
1964
3179
  type: "tool_result",
3180
+ ...identity,
1965
3181
  data: {
1966
3182
  ...gatewayToolStreamPayload(toolPayload),
1967
3183
  result: toolPayload.result ?? toolPayload.meta ?? toolPayload.content ?? toolPayload.text ?? toolPayload.partialResult,
@@ -1976,20 +3192,29 @@ export class GatewayClient {
1976
3192
  const phase = typeof lifecyclePayload.phase === "string" ? lifecyclePayload.phase.toLowerCase() : "";
1977
3193
  if (phase === "end") {
1978
3194
  const hasNonTextActivity = Boolean(lastThinkingText) || seenToolCallIds.size > 0 || seenToolResultIds.size > 0;
1979
- const historyText = hasNonTextActivity
1980
- ? ""
1981
- : streamedDisplayText || lastLegacyText
1982
- ? latestHistoryAssistantText(await this.chatHistory(sessionKey, 20), acceptedRunIds)
1983
- : await waitForHistoryText();
3195
+ const historyText = emittedDisplayText && hasNonTextActivity
3196
+ ? await reconcileHistoryAfterToolActivity(emittedDisplayText)
3197
+ : emittedDisplayText
3198
+ ? await readLatestHistoryText()
3199
+ : hasNonTextActivity
3200
+ ? ""
3201
+ : await waitForHistoryText();
3202
+ if (queuedEvents.length > 0)
3203
+ continue;
1984
3204
  if (historyText) {
1985
- const streamed = streamDelta(lastLegacyText, historyText);
1986
- lastLegacyText = streamed.nextText;
1987
- if (streamed.delta) {
1988
- streamedDisplayText = true;
1989
- yield { type: "content", text: streamed.delta, data: payload };
3205
+ const update = reconcileStreamContent(emittedDisplayText, historyText);
3206
+ if (update) {
3207
+ emittedDisplayText = update.nextText;
3208
+ yield {
3209
+ type: "content",
3210
+ text: update.text,
3211
+ ...(update.replace ? { replace: true } : {}),
3212
+ ...identity,
3213
+ data: payload,
3214
+ };
1990
3215
  }
1991
3216
  }
1992
- yield { type: "done", data: payload };
3217
+ yield { type: "done", ...identity, data: payload };
1993
3218
  return;
1994
3219
  }
1995
3220
  if (phase === "error") {
@@ -2000,6 +3225,7 @@ export class GatewayClient {
2000
3225
  : typeof payload.errorMessage === "string" && payload.errorMessage
2001
3226
  ? payload.errorMessage
2002
3227
  : phase,
3228
+ ...identity,
2003
3229
  data: payload,
2004
3230
  };
2005
3231
  return;
@@ -2011,38 +3237,51 @@ export class GatewayClient {
2011
3237
  if (text) {
2012
3238
  lastThinkingText += text;
2013
3239
  }
2014
- yield { type: "thinking", text };
3240
+ yield { type: "thinking", text, ...identity };
2015
3241
  continue;
2016
3242
  }
2017
3243
  if (evt.event === "chat.tool_call") {
2018
3244
  const toolCallId = gatewayToolCallId(payload) ??
2019
3245
  `${gatewayToolName(payload) ?? "tool"}:${JSON.stringify(payload.args ?? payload.arguments ?? null)}`;
2020
3246
  seenToolCallIds.add(toolCallId);
2021
- yield { type: "tool_call", data: payload };
3247
+ yield { type: "tool_call", ...identity, data: payload };
2022
3248
  continue;
2023
3249
  }
2024
3250
  if (evt.event === "chat.tool_result") {
2025
3251
  const toolCallId = gatewayToolCallId(payload) ??
2026
3252
  `${gatewayToolName(payload) ?? "tool"}:${JSON.stringify(payload.args ?? payload.arguments ?? null)}`;
2027
3253
  seenToolResultIds.add(toolCallId);
2028
- yield { type: "tool_result", data: payload };
3254
+ yield { type: "tool_result", ...identity, data: payload };
2029
3255
  continue;
2030
3256
  }
2031
3257
  if (evt.event === "chat.done") {
2032
3258
  const hasNonTextActivity = Boolean(lastThinkingText) || seenToolCallIds.size > 0 || seenToolResultIds.size > 0;
2033
- if (!streamedDisplayText && !lastLegacyText && !hasNonTextActivity) {
2034
- const historyText = await waitForHistoryText();
2035
- if (historyText) {
2036
- yield { type: "content", text: historyText, data: payload };
3259
+ const historyText = emittedDisplayText && hasNonTextActivity
3260
+ ? await reconcileHistoryAfterToolActivity(emittedDisplayText)
3261
+ : !emittedDisplayText && !hasNonTextActivity
3262
+ ? await waitForHistoryText()
3263
+ : "";
3264
+ if (historyText) {
3265
+ const update = reconcileStreamContent(emittedDisplayText, historyText);
3266
+ if (update) {
3267
+ emittedDisplayText = update.nextText;
3268
+ yield {
3269
+ type: "content",
3270
+ text: update.text,
3271
+ ...(update.replace ? { replace: true } : {}),
3272
+ ...identity,
3273
+ data: payload,
3274
+ };
2037
3275
  }
2038
3276
  }
2039
- yield { type: "done", data: payload };
3277
+ yield { type: "done", ...identity, data: payload };
2040
3278
  return;
2041
3279
  }
2042
3280
  if (evt.event === "chat.error") {
2043
3281
  yield {
2044
3282
  type: "error",
2045
3283
  text: typeof payload.message === "string" ? payload.message : "Unknown error",
3284
+ ...identity,
2046
3285
  data: payload,
2047
3286
  };
2048
3287
  return;
@@ -2051,24 +3290,22 @@ export class GatewayClient {
2051
3290
  continue;
2052
3291
  }
2053
3292
  const state = typeof payload.state === "string" ? payload.state.trim().toLowerCase() : "";
2054
- const currentText = extractMessageText(payload.message) ?? "";
3293
+ const currentText = extractMessageText(payload.message);
2055
3294
  const currentDeltaText = typeof payload.deltaText === "string" ? payload.deltaText : "";
2056
3295
  const normalizedMessage = normalizeGatewayChatMessage(payload.message);
2057
3296
  if (state === "delta") {
2058
- const streamed = currentText
2059
- ? streamDelta(lastLegacyText, currentText)
2060
- : {
2061
- delta: currentDeltaText,
2062
- nextText: currentDeltaText
2063
- ? payload.replace === true
2064
- ? currentDeltaText
2065
- : lastLegacyText + currentDeltaText
2066
- : lastLegacyText,
3297
+ const update = currentText !== null && (currentText || payload.replace === true)
3298
+ ? reconcileStreamContent(emittedDisplayText, currentText, payload.replace === true)
3299
+ : appendStreamContent(emittedDisplayText, currentDeltaText, payload.replace === true);
3300
+ if (update) {
3301
+ emittedDisplayText = update.nextText;
3302
+ yield {
3303
+ type: "content",
3304
+ text: update.text,
3305
+ ...(update.replace ? { replace: true } : {}),
3306
+ ...identity,
3307
+ data: payload,
2067
3308
  };
2068
- lastLegacyText = streamed.nextText;
2069
- if (streamed.delta) {
2070
- streamedDisplayText = true;
2071
- yield { type: "content", text: streamed.delta, data: payload };
2072
3309
  }
2073
3310
  continue;
2074
3311
  }
@@ -2078,7 +3315,7 @@ export class GatewayClient {
2078
3315
  : { delta: "", nextText: lastThinkingText };
2079
3316
  lastThinkingText = thinkingDelta.nextText;
2080
3317
  if (thinkingDelta.delta) {
2081
- yield { type: "thinking", text: thinkingDelta.delta, data: payload };
3318
+ yield { type: "thinking", text: thinkingDelta.delta, ...identity, data: payload };
2082
3319
  }
2083
3320
  for (const toolCall of normalizedMessage?.toolCalls ?? []) {
2084
3321
  const toolCallKey = toolCall.id?.trim() ||
@@ -2087,6 +3324,7 @@ export class GatewayClient {
2087
3324
  seenToolCallIds.add(toolCallKey);
2088
3325
  yield {
2089
3326
  type: "tool_call",
3327
+ ...identity,
2090
3328
  data: {
2091
3329
  ...(toolCall.id ? { toolCallId: toolCall.id } : {}),
2092
3330
  name: toolCall.name,
@@ -2098,6 +3336,7 @@ export class GatewayClient {
2098
3336
  seenToolResultIds.add(toolCallKey);
2099
3337
  yield {
2100
3338
  type: "tool_result",
3339
+ ...identity,
2101
3340
  data: {
2102
3341
  ...(toolCall.id ? { toolCallId: toolCall.id } : {}),
2103
3342
  name: toolCall.name,
@@ -2106,53 +3345,242 @@ export class GatewayClient {
2106
3345
  };
2107
3346
  }
2108
3347
  }
2109
- if (currentText) {
2110
- const streamed = streamDelta(lastLegacyText, currentText);
2111
- lastLegacyText = streamed.nextText;
2112
- if (streamed.delta) {
2113
- streamedDisplayText = true;
2114
- yield { type: "content", text: streamed.delta, data: payload };
3348
+ if (currentText !== null && (currentText || payload.replace === true)) {
3349
+ const update = reconcileStreamContent(emittedDisplayText, currentText, payload.replace === true);
3350
+ if (update) {
3351
+ emittedDisplayText = update.nextText;
3352
+ yield {
3353
+ type: "content",
3354
+ text: update.text,
3355
+ ...(update.replace ? { replace: true } : {}),
3356
+ ...identity,
3357
+ data: payload,
3358
+ };
2115
3359
  }
2116
- yield { type: "done", data: payload };
3360
+ yield { type: "done", ...identity, data: payload };
2117
3361
  return;
2118
3362
  }
2119
- if (streamedDisplayText || lastLegacyText) {
2120
- yield { type: "done", data: payload };
3363
+ if (emittedDisplayText) {
3364
+ yield { type: "done", ...identity, data: payload };
2121
3365
  return;
2122
3366
  }
2123
3367
  if (normalizedMessage?.thinking || (normalizedMessage?.toolCalls.length ?? 0) > 0) {
2124
- yield { type: "done", data: payload };
3368
+ yield { type: "done", ...identity, data: payload };
2125
3369
  return;
2126
3370
  }
2127
3371
  const historyText = await waitForHistoryText();
2128
3372
  if (historyText) {
2129
- yield { type: "content", text: historyText, data: payload };
3373
+ const update = reconcileStreamContent(emittedDisplayText, historyText);
3374
+ if (update) {
3375
+ emittedDisplayText = update.nextText;
3376
+ yield {
3377
+ type: "content",
3378
+ text: update.text,
3379
+ ...(update.replace ? { replace: true } : {}),
3380
+ ...identity,
3381
+ data: payload,
3382
+ };
3383
+ }
2130
3384
  }
2131
- yield { type: "done", data: payload };
3385
+ yield { type: "done", ...identity, data: payload };
2132
3386
  return;
2133
3387
  }
2134
3388
  if (state === "error" || state === "aborted") {
2135
- if (currentText) {
2136
- const streamed = streamDelta(lastLegacyText, currentText);
2137
- lastLegacyText = streamed.nextText;
2138
- if (streamed.delta) {
2139
- streamedDisplayText = true;
2140
- yield { type: "content", text: streamed.delta, data: payload };
3389
+ if (currentText !== null && (currentText || payload.replace === true)) {
3390
+ const update = reconcileStreamContent(emittedDisplayText, currentText, payload.replace === true);
3391
+ if (update) {
3392
+ emittedDisplayText = update.nextText;
3393
+ yield {
3394
+ type: "content",
3395
+ text: update.text,
3396
+ ...(update.replace ? { replace: true } : {}),
3397
+ ...identity,
3398
+ data: payload,
3399
+ };
2141
3400
  }
2142
3401
  }
2143
3402
  yield {
2144
3403
  type: "error",
2145
3404
  text: typeof payload.errorMessage === "string" ? payload.errorMessage : state,
3405
+ ...identity,
2146
3406
  data: payload,
2147
3407
  };
2148
3408
  return;
2149
3409
  }
2150
3410
  }
3411
+ if (streamCloseError)
3412
+ throw streamCloseError;
2151
3413
  throw new Error("Streaming chat.send timed out");
2152
3414
  }
2153
3415
  finally {
2154
- this.eventHandlers.delete(handler);
3416
+ if (acknowledgementPending)
3417
+ this.finishEphemeralChatAcknowledgement(false);
3418
+ this.internalEventHandlers.delete(handler);
3419
+ this.internalStreamCloseHandlers.delete(closeHandler);
3420
+ this.activeNormalChatStreams.delete(streamState);
3421
+ this.activeStrictChatStreams.delete(streamState);
3422
+ }
3423
+ }
3424
+ async runEphemeralChat(message, options = {}) {
3425
+ const prompt = message.trim();
3426
+ if (!prompt)
3427
+ throw new Error("Ephemeral chat requires a message.");
3428
+ if (!this.connected || !this.ws)
3429
+ throw new Error("Not connected");
3430
+ const timeoutMs = options.timeoutMs ?? DEFAULT_AGENT_TIMEOUT;
3431
+ const maxResponseChars = options.maxResponseChars ?? 128 * 1024;
3432
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > DEFAULT_AGENT_TIMEOUT) {
3433
+ throw new Error(`Ephemeral chat timeout must be between 1 and ${DEFAULT_AGENT_TIMEOUT} milliseconds.`);
3434
+ }
3435
+ if (!Number.isSafeInteger(maxResponseChars) || maxResponseChars <= 0) {
3436
+ throw new Error("Ephemeral chat response limit must be a positive integer.");
3437
+ }
3438
+ let session = null;
3439
+ let terminal = false;
3440
+ let stream = null;
3441
+ let stopError = null;
3442
+ let abortPromise = null;
3443
+ let primaryError = null;
3444
+ let cleanupError = null;
3445
+ let resultContent = null;
3446
+ let resolveStop = null;
3447
+ const stopped = new Promise((resolve) => {
3448
+ resolveStop = resolve;
3449
+ });
3450
+ const requestAbort = (error) => {
3451
+ if (!stopError) {
3452
+ stopError = error;
3453
+ resolveStop?.(error);
3454
+ }
3455
+ if (session && !terminal && !abortPromise) {
3456
+ abortPromise = session.chatAbort().catch(() => undefined);
3457
+ }
3458
+ void stream?.return(undefined).catch(() => undefined);
3459
+ };
3460
+ const dispatchEvent = async (event) => {
3461
+ if (!options.onEvent)
3462
+ return;
3463
+ await Promise.race([
3464
+ Promise.resolve().then(() => options.onEvent?.(event)),
3465
+ stopped.then((error) => Promise.reject(error)),
3466
+ ]);
3467
+ };
3468
+ const abortError = () => {
3469
+ const error = new Error("Ephemeral chat was cancelled.");
3470
+ error.name = "AbortError";
3471
+ return error;
3472
+ };
3473
+ const handleSignalAbort = () => requestAbort(abortError());
3474
+ const timeout = setTimeout(() => requestAbort(new Error("Ephemeral chat timed out.")), timeoutMs);
3475
+ options.signal?.addEventListener("abort", handleSignalAbort, { once: true });
3476
+ try {
3477
+ if (options.signal?.aborted)
3478
+ throw abortError();
3479
+ session = await this.createEphemeralChatSession();
3480
+ if (options.signal?.aborted)
3481
+ throw abortError();
3482
+ if (stopError)
3483
+ throw stopError;
3484
+ if (options.fastMode) {
3485
+ let fastModeTerminal = false;
3486
+ stream = session.chatSend("/fast on");
3487
+ while (true) {
3488
+ const next = await Promise.race([
3489
+ stream.next(),
3490
+ stopped.then((error) => Promise.reject(error)),
3491
+ ]);
3492
+ if (next.done)
3493
+ break;
3494
+ const event = next.value;
3495
+ if (event.type === "error") {
3496
+ fastModeTerminal = true;
3497
+ throw new Error(event.text || "Ephemeral fast mode failed.");
3498
+ }
3499
+ if (event.type === "done") {
3500
+ fastModeTerminal = true;
3501
+ break;
3502
+ }
3503
+ }
3504
+ await stream.return(undefined).catch(() => undefined);
3505
+ stream = null;
3506
+ if (stopError)
3507
+ throw stopError;
3508
+ if (!fastModeTerminal)
3509
+ throw new Error("Ephemeral fast mode ended without completing.");
3510
+ }
3511
+ let content = "";
3512
+ stream = session.chatSend(prompt);
3513
+ while (true) {
3514
+ const next = await Promise.race([
3515
+ stream.next(),
3516
+ stopped.then((error) => Promise.reject(error)),
3517
+ ]);
3518
+ if (next.done)
3519
+ break;
3520
+ const event = next.value;
3521
+ if (stopError)
3522
+ throw stopError;
3523
+ if (event.type === "content") {
3524
+ content = applyChatEventContent(content, event);
3525
+ if (content.length > maxResponseChars) {
3526
+ const error = new Error("Ephemeral chat response exceeds the configured limit.");
3527
+ requestAbort(error);
3528
+ throw error;
3529
+ }
3530
+ }
3531
+ await dispatchEvent(event);
3532
+ if (event.type === "error") {
3533
+ terminal = true;
3534
+ throw new Error(event.text || "Ephemeral chat failed.");
3535
+ }
3536
+ else if (event.type === "done") {
3537
+ terminal = true;
3538
+ }
3539
+ }
3540
+ if (stopError)
3541
+ throw stopError;
3542
+ if (!terminal)
3543
+ throw new Error("Ephemeral chat ended without completing.");
3544
+ if (!content.trim())
3545
+ throw new Error("Ephemeral chat returned an empty response.");
3546
+ resultContent = content;
3547
+ }
3548
+ catch (error) {
3549
+ if (session && !terminal)
3550
+ requestAbort(error instanceof Error ? error : new Error("Ephemeral chat failed."));
3551
+ await abortPromise;
3552
+ primaryError = stopError ?? error;
3553
+ }
3554
+ finally {
3555
+ clearTimeout(timeout);
3556
+ options.signal?.removeEventListener("abort", handleSignalAbort);
3557
+ if (stream && !primaryError) {
3558
+ await promiseSettlesWithin(stream.return(undefined), EPHEMERAL_TURN_CLOSE_WAIT_MS);
3559
+ }
3560
+ if (session) {
3561
+ try {
3562
+ await session.close();
3563
+ }
3564
+ catch (error) {
3565
+ cleanupError = error;
3566
+ }
3567
+ }
2155
3568
  }
3569
+ if (primaryError && cleanupError) {
3570
+ const primaryMessage = primaryError instanceof Error ? primaryError.message : String(primaryError);
3571
+ const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
3572
+ const combinedError = new Error(`${primaryMessage} Private chat cleanup also failed: ${cleanupMessage}`);
3573
+ combinedError.name = primaryError instanceof Error ? primaryError.name : "Error";
3574
+ combinedError.cause = cleanupError;
3575
+ throw combinedError;
3576
+ }
3577
+ if (primaryError)
3578
+ throw primaryError;
3579
+ if (cleanupError)
3580
+ throw cleanupError;
3581
+ if (resultContent === null)
3582
+ throw new Error("Ephemeral chat ended without a result.");
3583
+ return resultContent;
2156
3584
  }
2157
3585
  // ---------------------------------------------------------------------------
2158
3586
  // Files (agent workspace files)
@@ -2211,4 +3639,115 @@ export class GatewayClient {
2211
3639
  return this.rpc("status");
2212
3640
  }
2213
3641
  }
3642
+ function createGatewayRequestAbortError(method) {
3643
+ const err = new Error(`gateway request aborted for ${method}`);
3644
+ err.name = "AbortError";
3645
+ return err;
3646
+ }
3647
+ function coerceNodeInvokeRequest(payload) {
3648
+ if (!payload || typeof payload !== "object") {
3649
+ return null;
3650
+ }
3651
+ const obj = payload;
3652
+ const id = typeof obj.id === "string" ? obj.id.trim() : "";
3653
+ const nodeId = typeof obj.nodeId === "string" ? obj.nodeId.trim() : "";
3654
+ const command = typeof obj.command === "string" ? obj.command.trim() : "";
3655
+ if (!id || !nodeId || !command) {
3656
+ return null;
3657
+ }
3658
+ const paramsJSON = typeof obj.paramsJSON === "string"
3659
+ ? obj.paramsJSON
3660
+ : obj.params !== undefined
3661
+ ? JSON.stringify(obj.params)
3662
+ : null;
3663
+ return { id, nodeId, command, paramsJSON };
3664
+ }
3665
+ /**
3666
+ * Serve a node command surface over a gateway connection. Wraps a
3667
+ * {@link GatewayClient} connected as `role: "node"`, subscribes to
3668
+ * `node.invoke.request`, dispatches to the registered handlers, and replies with
3669
+ * `node.invoke.result` (JSON-encoded payload on success, or an error).
3670
+ */
3671
+ export class NodeServer {
3672
+ gateway;
3673
+ handlers;
3674
+ unsubscribe = null;
3675
+ constructor(commands, options) {
3676
+ this.handlers = { ...commands };
3677
+ const nodeId = options.nodeId?.trim() || undefined;
3678
+ this.gateway = new GatewayClient({
3679
+ ...options,
3680
+ clientId: options.clientId ?? "node-host",
3681
+ clientMode: "node",
3682
+ role: "node",
3683
+ scopes: [],
3684
+ caps: options.caps ?? [],
3685
+ commands: Object.keys(this.handlers),
3686
+ ...(nodeId ? { instanceId: nodeId } : {}),
3687
+ });
3688
+ }
3689
+ /** Connect as a node and start answering invoke requests. Resolves once connected. */
3690
+ async start() {
3691
+ if (!this.unsubscribe) {
3692
+ this.unsubscribe = this.gateway.onEvent((event) => {
3693
+ if (event.event === "node.invoke.request") {
3694
+ void this.handleInvoke(event.payload);
3695
+ }
3696
+ });
3697
+ }
3698
+ await this.gateway.connect();
3699
+ }
3700
+ /** Stop answering requests and disconnect. */
3701
+ stop() {
3702
+ this.unsubscribe?.();
3703
+ this.unsubscribe = null;
3704
+ this.gateway.stop();
3705
+ }
3706
+ async handleInvoke(payload) {
3707
+ const frame = coerceNodeInvokeRequest(payload);
3708
+ if (!frame) {
3709
+ return;
3710
+ }
3711
+ const result = await this.dispatch(frame);
3712
+ try {
3713
+ await this.gateway.request("node.invoke.result", {
3714
+ id: frame.id,
3715
+ nodeId: frame.nodeId,
3716
+ ...result,
3717
+ });
3718
+ }
3719
+ catch {
3720
+ // node invoke replies are best-effort; the gateway times out stale ids.
3721
+ }
3722
+ }
3723
+ async dispatch(frame) {
3724
+ const handler = this.handlers[frame.command];
3725
+ if (!handler) {
3726
+ return {
3727
+ ok: false,
3728
+ error: { code: "INVALID_REQUEST", message: `command not supported: ${frame.command}` },
3729
+ };
3730
+ }
3731
+ let params;
3732
+ try {
3733
+ params = frame.paramsJSON ? JSON.parse(frame.paramsJSON) : {};
3734
+ }
3735
+ catch {
3736
+ return {
3737
+ ok: false,
3738
+ error: { code: "INVALID_REQUEST", message: "paramsJSON malformed JSON" },
3739
+ };
3740
+ }
3741
+ try {
3742
+ const result = await handler(params);
3743
+ return { ok: true, payloadJSON: JSON.stringify(result === undefined ? null : result) };
3744
+ }
3745
+ catch (err) {
3746
+ return {
3747
+ ok: false,
3748
+ error: { code: "UNAVAILABLE", message: err instanceof Error ? err.message : String(err) },
3749
+ };
3750
+ }
3751
+ }
3752
+ }
2214
3753
  //# sourceMappingURL=gateway.js.map