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