@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.
- package/README.md +82 -1
- package/dist/agent.d.ts +19 -2
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +24 -0
- package/dist/agent.js.map +1 -1
- package/dist/agents.d.ts +561 -41
- package/dist/agents.d.ts.map +1 -1
- package/dist/agents.js +2016 -106
- package/dist/agents.js.map +1 -1
- package/dist/browser.d.ts +3 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +9 -2
- package/dist/browser.js.map +1 -1
- package/dist/channels.d.ts +156 -0
- package/dist/channels.d.ts.map +1 -0
- package/dist/channels.js +110 -0
- package/dist/channels.js.map +1 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +9 -3
- package/dist/client.js.map +1 -1
- package/dist/connectors.d.ts +79 -0
- package/dist/connectors.d.ts.map +1 -0
- package/dist/connectors.js +2 -0
- package/dist/connectors.js.map +1 -0
- package/dist/http.d.ts +10 -3
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +135 -61
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +12 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/keys.d.ts +6 -1
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +8 -1
- package/dist/keys.js.map +1 -1
- package/dist/openclaw/channels.d.ts +86 -0
- package/dist/openclaw/channels.d.ts.map +1 -0
- package/dist/openclaw/channels.js +290 -0
- package/dist/openclaw/channels.js.map +1 -0
- package/dist/openclaw/connectors.d.ts +61 -0
- package/dist/openclaw/connectors.d.ts.map +1 -0
- package/dist/openclaw/connectors.js +174 -0
- package/dist/openclaw/connectors.js.map +1 -0
- package/dist/openclaw/gateway.d.ts +733 -15
- package/dist/openclaw/gateway.d.ts.map +1 -1
- package/dist/openclaw/gateway.js +1720 -181
- package/dist/openclaw/gateway.js.map +1 -1
- package/dist/openclaw/skills.d.ts +106 -0
- package/dist/openclaw/skills.d.ts.map +1 -0
- package/dist/openclaw/skills.js +575 -0
- package/dist/openclaw/skills.js.map +1 -0
- package/dist/openclaw/slack.d.ts +818 -0
- package/dist/openclaw/slack.d.ts.map +1 -0
- package/dist/openclaw/slack.js +536 -0
- package/dist/openclaw/slack.js.map +1 -0
- package/dist/openclaw/whatsapp.d.ts +63 -0
- package/dist/openclaw/whatsapp.d.ts.map +1 -0
- package/dist/openclaw/whatsapp.js +264 -0
- package/dist/openclaw/whatsapp.js.map +1 -0
- package/dist/renders.js +11 -11
- package/dist/renders.js.map +1 -1
- package/dist/skills.d.ts +126 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +2 -0
- package/dist/skills.js.map +1 -0
- package/dist/user.d.ts +15 -2
- package/dist/user.d.ts.map +1 -1
- package/dist/user.js +51 -4
- package/dist/user.js.map +1 -1
- package/dist/voice-session.d.ts +74 -0
- package/dist/voice-session.d.ts.map +1 -0
- package/dist/voice-session.js +255 -0
- package/dist/voice-session.js.map +1 -0
- package/dist/voice.d.ts +27 -3
- package/dist/voice.d.ts.map +1 -1
- package/dist/voice.js +89 -6
- package/dist/voice.js.map +1 -1
- package/dist/workspaces.d.ts +181 -0
- package/dist/workspaces.d.ts.map +1 -0
- package/dist/workspaces.js +495 -0
- package/dist/workspaces.js.map +1 -0
- package/dist/x402.d.ts.map +1 -1
- package/dist/x402.js +1 -2
- package/dist/x402.js.map +1 -1
- package/package.json +38 -2
package/dist/openclaw/gateway.js
CHANGED
|
@@ -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
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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
|
|
1116
|
-
this.gatewayToken = options.gatewayToken
|
|
1117
|
-
this.
|
|
1118
|
-
this.
|
|
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
|
|
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 ??
|
|
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(),
|
|
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
|
-
|
|
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(),
|
|
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
|
-
|
|
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
|
-
|
|
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(),
|
|
1423
|
-
const
|
|
1424
|
-
const
|
|
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:
|
|
1431
|
-
scopes:
|
|
2016
|
+
role: this.role,
|
|
2017
|
+
scopes: this.scopes,
|
|
1432
2018
|
signedAtMs,
|
|
1433
|
-
token:
|
|
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:
|
|
1439
|
-
maxProtocol:
|
|
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:
|
|
1449
|
-
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
|
-
...(
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
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 ??
|
|
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(),
|
|
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(),
|
|
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:
|
|
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:
|
|
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
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
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
|
|
1597
|
-
|
|
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
|
|
1613
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2251
|
+
// Internal stream handlers are isolated from the socket lifecycle.
|
|
1623
2252
|
}
|
|
1624
2253
|
}
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
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
|
-
|
|
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 = {},
|
|
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
|
-
|
|
1656
|
-
|
|
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
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
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,
|
|
2350
|
+
return this.sendRawRequest(method, params, {
|
|
2351
|
+
timeoutMs: timeout === undefined ? this.defaultTimeout : timeout,
|
|
2352
|
+
});
|
|
1669
2353
|
}
|
|
1670
|
-
request(method, params = {},
|
|
1671
|
-
return this.
|
|
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
|
-
|
|
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
|
|
2637
|
+
async sessionsListResult() {
|
|
1808
2638
|
const res = await this.rpc("sessions.list");
|
|
1809
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
1863
|
-
let
|
|
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
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
if (
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
3084
|
+
const historyDeadline = Date.now() + Math.min(timeoutMs, DEFAULT_AGENT_TIMEOUT);
|
|
1899
3085
|
while (true) {
|
|
1900
|
-
const historyText =
|
|
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() +
|
|
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
|
|
1930
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
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
|
-
:
|
|
1982
|
-
?
|
|
1983
|
-
:
|
|
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
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
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
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
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
|
|
2059
|
-
?
|
|
2060
|
-
:
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
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
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
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
|
-
|
|
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
|