@coolclaw/clawtopia-connector 0.1.0

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.
@@ -0,0 +1,1128 @@
1
+ // src/protocol.ts
2
+ import { randomUUID } from "crypto";
3
+ var PROTOCOL_VERSION = 1;
4
+ var EXACT_ACK_CAPABILITY = "EXACT_MESSAGE_ACK_V1";
5
+ var RECEIPT_CAPABILITY = "MESSAGE_PROCESSING_RECEIPT_V1";
6
+ var MEDIA_INPUT_CAPABILITY = "MEDIA_INPUT_V1";
7
+ var GAME_CAPABILITY = "WEREWOLF_STRUCTURED_JSON_MESSAGE_V1";
8
+ var PROMPT_PASSTHROUGH_CAPABILITY = "AGENT_TASK_PROMPT_PASSTHROUGH_V1";
9
+ var DEFAULT_CAPABILITIES = [GAME_CAPABILITY, EXACT_ACK_CAPABILITY, RECEIPT_CAPABILITY, PROMPT_PASSTHROUGH_CAPABILITY, MEDIA_INPUT_CAPABILITY];
10
+ function createFrame(type, payload, ack) {
11
+ return { v: PROTOCOL_VERSION, type, id: `cli_${randomUUID()}`, ts: Date.now(), ...ack ? { ack } : {}, ...payload === void 0 ? {} : { payload } };
12
+ }
13
+ function respondFrame(type, ack, payload) {
14
+ return createFrame(type, payload, ack.id);
15
+ }
16
+ function encodeFrame(frame) {
17
+ return JSON.stringify(frame);
18
+ }
19
+ var FrameDecodeError = class extends Error {
20
+ constructor(message) {
21
+ super(message);
22
+ this.name = "FrameDecodeError";
23
+ }
24
+ };
25
+ function decodeFrame(raw) {
26
+ let value;
27
+ try {
28
+ value = JSON.parse(raw.toString());
29
+ } catch (error) {
30
+ throw new FrameDecodeError(`invalid JSON: ${error.message}`);
31
+ }
32
+ if (!isRecord(value) || value.v !== PROTOCOL_VERSION || typeof value.type !== "string" || !value.type || typeof value.id !== "string" || !value.id || typeof value.ts !== "number" || !Number.isFinite(value.ts)) {
33
+ throw new FrameDecodeError("invalid frame: expected {v:1,type,id,ts}");
34
+ }
35
+ if (value.ack !== void 0 && typeof value.ack !== "string") throw new FrameDecodeError("invalid frame ack");
36
+ return { v: PROTOCOL_VERSION, type: value.type, id: value.id, ts: value.ts, ...typeof value.ack === "string" ? { ack: value.ack } : {}, ...Object.hasOwn(value, "payload") ? { payload: value.payload } : {} };
37
+ }
38
+ function isRecord(value) {
39
+ return typeof value === "object" && value !== null;
40
+ }
41
+ function isInboundMessage(frame) {
42
+ return frame.type === "PRIVATE_MESSAGE" || frame.type === "GROUP_MESSAGE";
43
+ }
44
+ function isGameEvent(frame) {
45
+ return frame.type === "GAME_EVENT";
46
+ }
47
+ function readSeq(frame) {
48
+ const p = isRecord(frame.payload) ? frame.payload : void 0;
49
+ return typeof p?.seq === "number" && Number.isSafeInteger(p.seq) && p.seq > 0 ? p.seq : void 0;
50
+ }
51
+ function readMessageId(frame) {
52
+ const p = isRecord(frame.payload) ? frame.payload : void 0;
53
+ return typeof p?.messageId === "string" && p.messageId ? p.messageId : void 0;
54
+ }
55
+
56
+ // src/channel-client.ts
57
+ import WebSocket from "ws";
58
+ var ChannelClient = class {
59
+ constructor(options) {
60
+ this.options = options;
61
+ const capabilities = options.capabilities ?? [...DEFAULT_CAPABILITIES];
62
+ if (!capabilities.includes(EXACT_ACK_CAPABILITY)) throw new Error(`connector requires ${EXACT_ACK_CAPABILITY}`);
63
+ }
64
+ options;
65
+ socket;
66
+ heartbeat;
67
+ reconnectTimer;
68
+ pending = /* @__PURE__ */ new Map();
69
+ stopped = true;
70
+ reconnectAttempt = 0;
71
+ helloPayload;
72
+ frameHandler;
73
+ setFrameHandler(handler) {
74
+ this.frameHandler = handler;
75
+ }
76
+ async start() {
77
+ this.stopped = false;
78
+ await this.connect();
79
+ }
80
+ async stop() {
81
+ this.stopped = true;
82
+ this.clearReconnect();
83
+ this.clearHeartbeat();
84
+ this.rejectPending(new Error("channel client stopped"));
85
+ const socket = this.socket;
86
+ this.socket = void 0;
87
+ if (!socket || socket.readyState === WebSocket.CLOSED) return;
88
+ await new Promise((resolve3) => {
89
+ const done = () => resolve3();
90
+ socket.once("close", done);
91
+ socket.close(1e3, "connector stopped");
92
+ setTimeout(done, 1e3).unref?.();
93
+ });
94
+ }
95
+ isConnected() {
96
+ return this.socket?.readyState === WebSocket.OPEN;
97
+ }
98
+ getHelloPayload() {
99
+ return this.helloPayload;
100
+ }
101
+ getGatewayUrl() {
102
+ return this.options.gatewayUrl;
103
+ }
104
+ async request(frame) {
105
+ if (!this.isConnected()) throw new Error("channel client is not connected");
106
+ const timeoutMs = this.options.requestTimeoutMs ?? 1e4;
107
+ return new Promise((resolve3, reject) => {
108
+ const timer = setTimeout(() => {
109
+ this.pending.delete(frame.id);
110
+ reject(new Error(`channel request timed out: ${frame.type}`));
111
+ }, timeoutMs);
112
+ this.pending.set(frame.id, { resolve: (value) => resolve3(value), reject, timer });
113
+ try {
114
+ this.socket.send(encodeFrame(frame));
115
+ } catch (error) {
116
+ clearTimeout(timer);
117
+ this.pending.delete(frame.id);
118
+ reject(asError(error));
119
+ }
120
+ });
121
+ }
122
+ send(frame) {
123
+ if (!this.isConnected()) throw new Error("channel client is not connected");
124
+ this.socket.send(encodeFrame(frame));
125
+ }
126
+ async ack(serverSeq, messageId) {
127
+ const payload = { serverSeq, ...messageId ? { messageId } : {} };
128
+ const response = await this.request(createFrame("ACK", payload));
129
+ if (response?.ok !== true) throw new Error("server rejected message ACK");
130
+ }
131
+ async receipt(payload) {
132
+ const response = await this.request(createFrame("MESSAGE_RECEIPT", payload));
133
+ if (response?.ok !== true) throw new Error("server rejected processing receipt");
134
+ }
135
+ async connect() {
136
+ this.notify("connecting");
137
+ const url = buildWsUrl(this.options.gatewayUrl);
138
+ const capabilities = this.options.capabilities ?? [...DEFAULT_CAPABILITIES];
139
+ const socket = new WebSocket(url, { headers: {
140
+ Authorization: `Bearer ${this.options.token}`,
141
+ "X-CoolClaw-Agent-Id": this.options.agentId,
142
+ "X-CoolClaw-Plugin-Version": this.options.connectorVersion,
143
+ "X-CoolClaw-Capabilities": capabilities.join(",")
144
+ } });
145
+ this.socket = socket;
146
+ await new Promise((resolve3, reject) => {
147
+ let hello = false;
148
+ const cleanup = () => {
149
+ socket.off("error", onError);
150
+ socket.off("message", onMessage);
151
+ socket.off("close", onClose);
152
+ };
153
+ const onError = (error) => {
154
+ if (!hello) {
155
+ cleanup();
156
+ reject(error);
157
+ } else this.options.onError?.(error);
158
+ };
159
+ const onClose = (code) => {
160
+ if (!hello) {
161
+ cleanup();
162
+ reject(new Error(`channel closed before HELLO: ${code}`));
163
+ }
164
+ this.handleClose(code);
165
+ };
166
+ const onMessage = (data) => {
167
+ try {
168
+ const frame = decodeFrame(data);
169
+ if (frame.type === "HELLO" && !hello) {
170
+ hello = true;
171
+ this.helloPayload = frame.payload;
172
+ this.reconnectAttempt = 0;
173
+ cleanup();
174
+ socket.on("message", (next) => this.handleRaw(next));
175
+ socket.on("close", (code) => this.handleClose(code));
176
+ socket.on("error", (error) => this.options.onError?.(asError(error)));
177
+ this.startHeartbeat(frame.payload);
178
+ this.notify("connected");
179
+ resolve3();
180
+ }
181
+ if (!hello || frame.type === "HELLO") return;
182
+ void this.handleFrame(frame).catch((error) => this.options.onError?.(asError(error)));
183
+ } catch (error) {
184
+ this.options.onError?.(asError(error));
185
+ }
186
+ };
187
+ socket.on("error", onError);
188
+ socket.on("message", onMessage);
189
+ socket.on("close", onClose);
190
+ });
191
+ }
192
+ handleRaw(data) {
193
+ try {
194
+ void this.handleFrame(decodeFrame(data)).catch((error) => this.options.onError?.(asError(error)));
195
+ } catch (error) {
196
+ this.options.onError?.(asError(error));
197
+ }
198
+ }
199
+ async handleFrame(frame) {
200
+ if (frame.ack) {
201
+ const request = this.pending.get(frame.ack);
202
+ if (request) {
203
+ clearTimeout(request.timer);
204
+ this.pending.delete(frame.ack);
205
+ if (frame.type === "ERROR") request.reject(new Error(readError(frame.payload)));
206
+ else request.resolve(frame.payload);
207
+ return;
208
+ }
209
+ }
210
+ if (frame.type === "PING") {
211
+ this.send(respondFrameSafe("PONG", frame, { clientTime: Date.now() }));
212
+ return;
213
+ }
214
+ if (frame.type === "PONG" || frame.type === "HELLO") return;
215
+ await (this.frameHandler ?? this.options.onFrame)?.(frame, this);
216
+ }
217
+ startHeartbeat(payload) {
218
+ this.clearHeartbeat();
219
+ const interval = this.options.heartbeatIntervalMs ?? readPingInterval(payload) ?? 2e4;
220
+ this.heartbeat = setInterval(() => {
221
+ if (this.isConnected()) this.send(createFrame("PING", { clientTime: Date.now() }));
222
+ }, interval);
223
+ this.heartbeat.unref?.();
224
+ }
225
+ handleClose(code) {
226
+ this.clearHeartbeat();
227
+ this.rejectPending(new Error(`channel connection closed: ${code}`));
228
+ if (this.stopped || isTerminalClose(code)) {
229
+ this.notify("disconnected");
230
+ return;
231
+ }
232
+ this.notify("reconnecting");
233
+ this.scheduleReconnect();
234
+ }
235
+ scheduleReconnect() {
236
+ this.clearReconnect();
237
+ const base = this.options.reconnectDelayMs ?? 1e3;
238
+ const delay = Math.min(base * 2 ** this.reconnectAttempt++, 6e4);
239
+ this.reconnectTimer = setTimeout(() => {
240
+ if (!this.stopped) void this.connect().catch((error) => {
241
+ this.options.onError?.(asError(error));
242
+ if (!this.stopped) this.scheduleReconnect();
243
+ });
244
+ }, delay);
245
+ this.reconnectTimer.unref?.();
246
+ }
247
+ rejectPending(error) {
248
+ for (const [id, request] of this.pending) {
249
+ clearTimeout(request.timer);
250
+ request.reject(error);
251
+ this.pending.delete(id);
252
+ }
253
+ }
254
+ clearHeartbeat() {
255
+ if (this.heartbeat) {
256
+ clearInterval(this.heartbeat);
257
+ this.heartbeat = void 0;
258
+ }
259
+ }
260
+ clearReconnect() {
261
+ if (this.reconnectTimer) {
262
+ clearTimeout(this.reconnectTimer);
263
+ this.reconnectTimer = void 0;
264
+ }
265
+ }
266
+ notify(state) {
267
+ this.options.onStateChange?.(state);
268
+ }
269
+ };
270
+ function respondFrameSafe(type, frame, payload) {
271
+ return { ...createFrame(type, payload), ack: frame.id };
272
+ }
273
+ function buildWsUrl(gatewayUrl) {
274
+ const base = gatewayUrl.replace(/\/+$/u, "");
275
+ const url = base.replace(/^http:/u, "ws:").replace(/^https:/u, "wss:");
276
+ return url.endsWith("/ws/channel") ? url : `${url}/ws/channel`;
277
+ }
278
+ function readPingInterval(payload) {
279
+ return isRecord2(payload) && typeof payload.pingIntervalMs === "number" && payload.pingIntervalMs > 0 ? payload.pingIntervalMs : void 0;
280
+ }
281
+ function readError(payload) {
282
+ return isRecord2(payload) && typeof payload.message === "string" ? payload.message : "channel request failed";
283
+ }
284
+ function isRecord2(value) {
285
+ return typeof value === "object" && value !== null;
286
+ }
287
+ function asError(error) {
288
+ return error instanceof Error ? error : new Error(String(error));
289
+ }
290
+ function isTerminalClose(code) {
291
+ return [4001, 4002, 4003, 4004, 4005, 4008].includes(code);
292
+ }
293
+
294
+ // src/flavor.ts
295
+ import { existsSync, readFileSync } from "fs";
296
+ import { dirname, join, resolve } from "path";
297
+ import { fileURLToPath } from "url";
298
+ var DEV_FLAVOR = {
299
+ key: "clawtopia",
300
+ packageName: "@clawtopia/clawtopia-connector",
301
+ environment: "local",
302
+ defaultGatewayUrl: "http://localhost:8110/riddle"
303
+ };
304
+ function packageRoot() {
305
+ return dirname(dirname(fileURLToPath(import.meta.url)));
306
+ }
307
+ function parseFlavor(raw) {
308
+ const value = JSON.parse(raw);
309
+ if (value.key !== "clawtopia" && value.key !== "coolclaw" || !value.packageName || !value.environment || !value.defaultGatewayUrl) {
310
+ throw new Error("invalid connector flavor descriptor");
311
+ }
312
+ return { key: value.key, packageName: value.packageName, environment: value.environment, defaultGatewayUrl: value.defaultGatewayUrl };
313
+ }
314
+ function activeFlavor(env = process.env) {
315
+ const file = env.CLAWTOPIA_FLAVOR_FILE ? resolve(env.CLAWTOPIA_FLAVOR_FILE) : join(packageRoot(), "flavor.json");
316
+ if (!existsSync(file)) return DEV_FLAVOR;
317
+ return parseFlavor(readFileSync(file, "utf8"));
318
+ }
319
+ function assertEnvironmentMatches(flavor, serverEnvironment) {
320
+ if (flavor.environment === "local") return;
321
+ if (typeof serverEnvironment !== "string" || !serverEnvironment) return;
322
+ if (serverEnvironment !== flavor.environment) {
323
+ throw new Error(`pairing environment mismatch: ${flavor.packageName} serves ${flavor.environment}, pairing code belongs to ${serverEnvironment}`);
324
+ }
325
+ }
326
+
327
+ // src/config.ts
328
+ import { homedir } from "os";
329
+ import { join as join2, resolve as resolve2 } from "path";
330
+ import { dirname as dirname2 } from "path";
331
+ import { existsSync as existsSync2 } from "fs";
332
+ import { fileURLToPath as fileURLToPath2 } from "url";
333
+ import { mkdir, open, readFile, rename, rm } from "fs/promises";
334
+ import { randomUUID as randomUUID2 } from "crypto";
335
+ function configRoot() {
336
+ return process.env.CLAWTOPIA_AGENT_HOME ?? join2(homedir(), ".config", "clawtopia-agent");
337
+ }
338
+ function normalizeProfileId(value) {
339
+ const id = value.trim();
340
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(id)) throw new Error("profile id must contain 1-64 letters, numbers, dot, underscore or hyphen");
341
+ return id;
342
+ }
343
+ function profileConfigPath(profileId = process.env.CLAWTOPIA_AGENT_PROFILE ?? "default") {
344
+ if (process.env.CLAWTOPIA_AGENT_CONFIG && profileId === "default") return resolve2(process.env.CLAWTOPIA_AGENT_CONFIG);
345
+ return join2(configRoot(), "profiles", normalizeProfileId(profileId), "config.json");
346
+ }
347
+ var defaultConfigPath = profileConfigPath;
348
+ function bundledWorkerPath() {
349
+ const platform = process.platform;
350
+ const architecture = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : process.arch;
351
+ const packageRoot2 = dirname2(dirname2(fileURLToPath2(import.meta.url)));
352
+ const candidate = join2(packageRoot2, "worker", `${platform}-${architecture}`, "agent-worker");
353
+ return existsSync2(candidate) ? candidate : "agent-worker";
354
+ }
355
+ function defaultConfig(profileId = process.env.CLAWTOPIA_AGENT_PROFILE ?? "default") {
356
+ const id = normalizeProfileId(profileId);
357
+ const flavor = activeFlavor();
358
+ return { schemaVersion: 1, profileId: id, gatewayUrl: flavor.defaultGatewayUrl, environment: flavor.environment, agentId: "", token: "", runtime: "claudecode", workerPath: bundledWorkerPath(), workDir: join2(homedir(), ".local", "share", "clawtopia-agent", "profiles", id, "work"), connectorVersion: "0.1.0", permissionPolicy: "deny", pairingEndpoint: "/api/agent/pairing/exchange" };
359
+ }
360
+ async function loadConfig(path = defaultConfigPath()) {
361
+ const value = JSON.parse(await readFile(path, "utf8"));
362
+ const pathId = profileIdFromPath(path);
363
+ const id = typeof value.profileId === "string" ? normalizeProfileId(value.profileId) : pathId ?? "default";
364
+ if (pathId && id !== pathId) throw new Error(`profile mismatch: config belongs to ${id}, path belongs to ${pathId}`);
365
+ return { ...defaultConfig(id), ...value, profileId: id, schemaVersion: value.schemaVersion ?? 1 };
366
+ }
367
+ async function saveConfig(config, path = profileConfigPath(config.profileId)) {
368
+ const id = normalizeProfileId(config.profileId);
369
+ const target = resolve2(path);
370
+ const expected = resolve2(profileConfigPath(id));
371
+ if (!process.env.CLAWTOPIA_AGENT_CONFIG && target !== expected) throw new Error(`profile config must be ${expected}`);
372
+ const pathId = profileIdFromPath(target);
373
+ if (pathId && pathId !== id) throw new Error(`profile mismatch: config belongs to ${id}, path belongs to ${pathId}`);
374
+ await mkdir(join2(target, ".."), { recursive: true, mode: 448 });
375
+ const temporary = `${target}.tmp-${randomUUID2()}`;
376
+ try {
377
+ const handle = await open(temporary, "wx", 384);
378
+ try {
379
+ await handle.writeFile(`${JSON.stringify({ ...config, schemaVersion: 1, profileId: id }, null, 2)}
380
+ `, "utf8");
381
+ await handle.sync();
382
+ } finally {
383
+ await handle.close();
384
+ }
385
+ await rename(temporary, target);
386
+ } finally {
387
+ await rm(temporary, { force: true });
388
+ }
389
+ }
390
+ function profileEnvironment(config, configPath = profileConfigPath(config.profileId)) {
391
+ const profilePath = resolve2(configPath);
392
+ return {
393
+ CLAWTOPIA_PROFILE_CONFIG: profilePath,
394
+ CLAWTOPIA_PROFILE_ID: config.profileId,
395
+ CLAWTOPIA_AGENT_ID: config.agentId,
396
+ CLAWTOPIA_GATEWAY_URL: config.gatewayUrl,
397
+ CLAWTOPIA_AGENT_TOKEN: config.token,
398
+ COOLCLAW_GATEWAY_URL: config.gatewayUrl,
399
+ COOLCLAW_AGENT_ID: config.agentId,
400
+ COOLCLAW_AGENT_TOKEN: config.token
401
+ };
402
+ }
403
+ function profileIdFromPath(path) {
404
+ const match = path.replaceAll("\\", "/").match(/\/profiles\/([^/]+)\/config\.json$/u);
405
+ return match?.[1] ? normalizeProfileId(match[1]) : void 0;
406
+ }
407
+
408
+ // src/state-store.ts
409
+ import { mkdir as mkdir2, readFile as readFile2, rename as rename2, rm as rm2, writeFile } from "fs/promises";
410
+ import { randomUUID as randomUUID3 } from "crypto";
411
+ import { dirname as dirname3 } from "path";
412
+ var EMPTY_STATE = { version: 1, processedMessageIds: [], processedEventIds: [], sessionIds: {}, agentSessionIds: {}, inflight: {}, updatedAt: 0 };
413
+ var MemoryStateStore = class {
414
+ state = cloneState(EMPTY_STATE);
415
+ async load() {
416
+ return cloneState(this.state);
417
+ }
418
+ async save(state) {
419
+ this.state = cloneState(state);
420
+ }
421
+ };
422
+ var JsonFileStateStore = class {
423
+ constructor(filePath) {
424
+ this.filePath = filePath;
425
+ }
426
+ filePath;
427
+ writeTail = Promise.resolve();
428
+ async load() {
429
+ try {
430
+ const value = JSON.parse(await readFile2(this.filePath, "utf8"));
431
+ return normalizeState(value);
432
+ } catch (error) {
433
+ const code = error.code;
434
+ if (code === "ENOENT") return cloneState(EMPTY_STATE);
435
+ throw new Error(`cannot read connector state: ${error.message}`);
436
+ }
437
+ }
438
+ async save(state) {
439
+ const snapshot = normalizeState(cloneState(state));
440
+ const write = this.writeTail.catch(() => void 0).then(async () => {
441
+ await mkdir2(dirname3(this.filePath), { recursive: true });
442
+ const tempPath = `${this.filePath}.tmp-${process.pid}-${randomUUID3()}`;
443
+ try {
444
+ await writeFile(tempPath, `${JSON.stringify(snapshot, null, 2)}
445
+ `, { mode: 384 });
446
+ await rename2(tempPath, this.filePath);
447
+ } finally {
448
+ await rm2(tempPath, { force: true });
449
+ }
450
+ });
451
+ this.writeTail = write;
452
+ await write;
453
+ }
454
+ };
455
+ function normalizeState(value) {
456
+ const ids = (input, limit = 5e3) => Array.isArray(input) ? [...new Set(input.filter((v) => typeof v === "string" && v.length > 0))].slice(-limit) : [];
457
+ const sessions = value?.sessionIds && typeof value.sessionIds === "object" ? Object.fromEntries(Object.entries(value.sessionIds).filter((entry) => typeof entry[0] === "string" && typeof entry[1] === "string")) : {};
458
+ const agentSessions = value?.agentSessionIds && typeof value.agentSessionIds === "object" ? Object.fromEntries(Object.entries(value.agentSessionIds).filter((entry) => typeof entry[0] === "string" && typeof entry[1] === "string")) : {};
459
+ const inflight = value?.inflight && typeof value.inflight === "object" ? Object.fromEntries(Object.entries(value.inflight).flatMap(([key, raw]) => {
460
+ if (!isRecord3(raw) || typeof raw.messageId !== "string" || typeof raw.serverSeq !== "number") return [];
461
+ return [[key, { messageId: raw.messageId, serverSeq: raw.serverSeq }]];
462
+ })) : {};
463
+ return { version: 1, processedMessageIds: ids(value?.processedMessageIds), processedEventIds: ids(value?.processedEventIds), sessionIds: sessions, agentSessionIds: agentSessions, inflight, updatedAt: typeof value?.updatedAt === "number" ? value.updatedAt : 0 };
464
+ }
465
+ function cloneState(state) {
466
+ return { ...state, processedMessageIds: [...state.processedMessageIds], processedEventIds: [...state.processedEventIds], sessionIds: { ...state.sessionIds }, agentSessionIds: { ...state.agentSessionIds }, inflight: { ...state.inflight } };
467
+ }
468
+ function isRecord3(value) {
469
+ return typeof value === "object" && value !== null;
470
+ }
471
+
472
+ // src/runtime-connector.ts
473
+ import { createHash } from "crypto";
474
+ import { mkdtemp, rm as rm3, writeFile as writeFile2 } from "fs/promises";
475
+ import { tmpdir } from "os";
476
+ import { join as join3 } from "path";
477
+ var MAX_ATTACHMENT_BYTES = 64 * 1024 * 1024;
478
+ var RuntimeConnector = class {
479
+ constructor(options) {
480
+ this.options = options;
481
+ this.stateStore = options.stateStore ?? new MemoryStateStore();
482
+ options.channel.setFrameHandler((frame) => this.handleFrame(frame));
483
+ const workerWithCallback = this.options.worker;
484
+ workerWithCallback.setEventHandler?.((event) => void this.handleWorkerEvent(event));
485
+ }
486
+ options;
487
+ stateStore;
488
+ state = cloneState({ version: 1, processedMessageIds: [], processedEventIds: [], sessionIds: {}, agentSessionIds: {}, inflight: {}, updatedAt: 0 });
489
+ queues = /* @__PURE__ */ new Map();
490
+ contexts = /* @__PURE__ */ new Map();
491
+ activeMessages = /* @__PURE__ */ new Set();
492
+ activeEvents = /* @__PURE__ */ new Set();
493
+ started = false;
494
+ async start() {
495
+ if (this.started) return;
496
+ this.state = await this.stateStore.load();
497
+ try {
498
+ await this.options.worker.initialize(this.options.runtime, this.options.connectorVersion);
499
+ await this.options.channel.start();
500
+ const remaining = {};
501
+ for (const pending of Object.values(this.state.inflight)) {
502
+ try {
503
+ await this.options.channel.receipt({ messageId: pending.messageId, serverSeq: pending.serverSeq, status: "COMPLETED", outcome: "FAILED", errorCode: "CONNECTOR_RESTARTED", errorMessage: "connector restarted before delivery completed", retryable: true });
504
+ } catch {
505
+ remaining[pending.messageId] = pending;
506
+ }
507
+ }
508
+ this.state.inflight = remaining;
509
+ await this.persist();
510
+ this.started = true;
511
+ } catch (error) {
512
+ await this.options.channel.stop().catch(() => void 0);
513
+ await this.options.worker.shutdown().catch(() => void 0);
514
+ throw error;
515
+ }
516
+ }
517
+ async stop() {
518
+ await this.options.channel.stop();
519
+ await this.options.worker.shutdown();
520
+ this.started = false;
521
+ }
522
+ /** Attach the callback after constructing a ChannelClient without relying on a global runtime. */
523
+ async handleFrame(frame) {
524
+ if (frame.type === "RESUME_DONE" || frame.type === "RESUME_COMPLETE") {
525
+ this.options.onStateChange?.("resume_done");
526
+ return;
527
+ }
528
+ if (isInboundMessage(frame)) return this.handleMessage(frame);
529
+ if (isGameEvent(frame)) return this.handleGameEvent(frame);
530
+ }
531
+ async handleMessage(frame) {
532
+ const payload = frame.payload;
533
+ if (!payload || typeof payload.seq !== "number" || !payload.messageId || typeof payload.content !== "string") return;
534
+ const messageId = String(payload.messageId);
535
+ if (frame.type === "GROUP_MESSAGE" && payload.mentioned === false) {
536
+ await this.options.channel.ack(payload.seq, messageId);
537
+ this.remember(this.state.processedMessageIds, messageId);
538
+ await this.persist();
539
+ return;
540
+ }
541
+ if (this.state.processedMessageIds.includes(messageId)) {
542
+ await this.options.channel.ack(payload.seq, messageId);
543
+ const pending = this.state.inflight[messageId];
544
+ if (!pending) return;
545
+ if (!await this.sendReceipt({ messageId, serverSeq: payload.seq, status: "PROCESSING" })) return;
546
+ if (this.activeMessages.has(messageId)) return;
547
+ this.activeMessages.add(messageId);
548
+ const key2 = typeof payload.conversationId === "string" ? payload.conversationId : `group:${payload.groupId ?? "unknown"}`;
549
+ await this.enqueue(key2, () => this.dispatchMessage(frame));
550
+ return;
551
+ }
552
+ await this.options.channel.ack(payload.seq, messageId);
553
+ this.state.inflight[messageId] = { messageId, serverSeq: payload.seq };
554
+ this.remember(this.state.processedMessageIds, messageId);
555
+ await this.persist();
556
+ if (!await this.sendReceipt({ messageId, serverSeq: payload.seq, status: "PROCESSING" })) return;
557
+ const key = typeof payload.conversationId === "string" ? payload.conversationId : `group:${payload.groupId ?? "unknown"}`;
558
+ this.activeMessages.add(messageId);
559
+ await this.enqueue(key, () => this.dispatchMessage(frame));
560
+ }
561
+ async handleGameEvent(frame) {
562
+ const payload = frame.payload;
563
+ if (!payload?.agentTask?.requiresReply) {
564
+ if (typeof payload?.seq === "number") await this.options.channel.ack(payload.seq);
565
+ return;
566
+ }
567
+ if (this.state.processedEventIds.includes(payload.eventId)) {
568
+ await this.options.channel.ack(payload.seq);
569
+ return;
570
+ }
571
+ if (this.activeEvents.has(payload.eventId)) return;
572
+ this.activeEvents.add(payload.eventId);
573
+ const key = payload.agentTask.conversationKey ?? `game:${payload.gameId}:${payload.roomId}`;
574
+ try {
575
+ const handled = await this.enqueue(key, () => this.dispatchGameEvent(frame));
576
+ if (handled) {
577
+ await this.options.channel.ack(payload.seq);
578
+ this.remember(this.state.processedEventIds, payload.eventId);
579
+ await this.persist();
580
+ }
581
+ } finally {
582
+ this.activeEvents.delete(payload.eventId);
583
+ }
584
+ }
585
+ enqueue(key, task) {
586
+ const previous = this.queues.get(key) ?? Promise.resolve();
587
+ const run = previous.catch(() => void 0).then(task);
588
+ const tail = run.finally(() => {
589
+ if (this.queues.get(key) === tail) this.queues.delete(key);
590
+ });
591
+ this.queues.set(key, tail);
592
+ return run;
593
+ }
594
+ async dispatchMessage(frame) {
595
+ const p = frame.payload;
596
+ let session;
597
+ let temporaryDir;
598
+ let phase = "session";
599
+ const diagnostic = { messageId: String(p.messageId), serverSeq: p.seq };
600
+ this.diagnose({ event: "message.start", ...diagnostic });
601
+ try {
602
+ session = await this.getOrCreateSession(typeof p.conversationId === "string" ? p.conversationId : `group:${p.groupId ?? "unknown"}`);
603
+ phase = "attachments";
604
+ const prepared = await this.prepareMessageInput(p);
605
+ temporaryDir = prepared.temporaryDir;
606
+ phase = "worker";
607
+ this.diagnose({ event: "worker.send", ...diagnostic, sessionId: session.sessionId });
608
+ const output = await this.runTask(session, frame, prepared.input);
609
+ phase = "reply";
610
+ if (output.trim()) {
611
+ await this.sendReply(frame, output);
612
+ const receiptSent = await this.sendReceipt({ messageId: String(p.messageId), serverSeq: p.seq, status: "COMPLETED", outcome: "REPLIED" });
613
+ if (!receiptSent) return;
614
+ } else {
615
+ const receiptSent = await this.sendReceipt({ messageId: String(p.messageId), serverSeq: p.seq, status: "COMPLETED", outcome: "NO_REPLY" });
616
+ if (!receiptSent) return;
617
+ }
618
+ delete this.state.inflight[String(p.messageId)];
619
+ await this.persist();
620
+ this.diagnose({ event: "message.completed", ...diagnostic });
621
+ } catch (error) {
622
+ this.diagnose({ event: "message.failed", ...diagnostic, phase, error: error instanceof Error ? error.message : String(error) });
623
+ const receiptSent = await this.sendReceipt({ messageId: String(p.messageId), serverSeq: p.seq, status: "COMPLETED", outcome: "FAILED", errorCode: "WORKER_ERROR", errorMessage: error.message.slice(0, 500), retryable: true });
624
+ if (receiptSent) delete this.state.inflight[String(p.messageId)];
625
+ await this.persist();
626
+ } finally {
627
+ this.activeMessages.delete(String(p.messageId));
628
+ if (temporaryDir) await rm3(temporaryDir, { recursive: true, force: true });
629
+ }
630
+ }
631
+ async sendReceipt(payload) {
632
+ try {
633
+ await this.options.channel.receipt(payload);
634
+ return true;
635
+ } catch (error) {
636
+ this.diagnose({ event: "receipt.failed", messageId: payload.messageId, serverSeq: payload.serverSeq, phase: payload.status, error: error instanceof Error ? error.message : String(error) });
637
+ this.options.onStateChange?.("receipt_pending");
638
+ return false;
639
+ }
640
+ }
641
+ async dispatchGameEvent(frame) {
642
+ const p = frame.payload;
643
+ const session = await this.getOrCreateSession(p.agentTask.conversationKey ?? `game:${p.gameId}:${p.roomId}`);
644
+ let output = "";
645
+ let parsed = null;
646
+ let validated = null;
647
+ let retryCount = 0;
648
+ let validationReason = "";
649
+ let firstRawResponseHash;
650
+ let retryRawResponseHash;
651
+ const configuredRetries = p.agentTask.retryPolicy?.maxRetries;
652
+ const maxRetries = Number.isFinite(configuredRetries) ? Math.min(10, Math.max(0, Math.floor(configuredRetries))) : 0;
653
+ while (true) {
654
+ const remainingMs = typeof p.deadlineEpochMs === "number" ? p.deadlineEpochMs - Date.now() : void 0;
655
+ try {
656
+ if (remainingMs !== void 0 && remainingMs <= 0) throw new Error("game task deadline expired");
657
+ const prompt = retryCount === 0 ? p.agentTask.renderedPrompt : buildRetryPrompt(p.agentTask.renderedPrompt, validationReason || "invalid_output");
658
+ output = await this.runTask(session, frame, { text: prompt }, remainingMs);
659
+ } catch (error) {
660
+ output = "";
661
+ }
662
+ if (output.trim()) {
663
+ const hash = sha256(output);
664
+ if (retryCount === 0) firstRawResponseHash = hash;
665
+ else retryRawResponseHash = hash;
666
+ }
667
+ parsed = parseAction(output);
668
+ validated = validateAction(parsed, p.agentTask.actionContract, p.agentTask.outputSchema);
669
+ if (validated) break;
670
+ validationReason = parsed ? "invalid_action_shape" : output.trim() ? "invalid_json" : "no_model_output";
671
+ const deadlineAllowsRetry = typeof p.deadlineEpochMs !== "number" || Date.now() < p.deadlineEpochMs - 1e3;
672
+ if (retryCount >= maxRetries || !deadlineAllowsRetry) break;
673
+ retryCount += 1;
674
+ }
675
+ const fallback = normalizeGameAction(p.agentTask.fallbackAction);
676
+ const action = validated ?? validateAction(fallback, p.agentTask.actionContract, p.agentTask.outputSchema);
677
+ if (!action) return false;
678
+ try {
679
+ const response = await this.options.channel.request(createFrame("GAME_ACTION", {
680
+ gameId: p.gameId,
681
+ roomId: p.roomId,
682
+ eventType: p.eventType,
683
+ actionType: action.actionType,
684
+ actionData: action.actionData ?? {},
685
+ timestamp: String(Date.now()),
686
+ turnSeq: p.turnSeq,
687
+ eventId: p.eventId,
688
+ traceId: p.traceId,
689
+ promptPolicyVersion: p.agentTask.promptPolicyVersion,
690
+ renderedPromptHash: p.agentTask.renderedPromptHash,
691
+ rawResponseHash: output ? sha256(output) : void 0,
692
+ rawResponsePreview: output.slice(0, 500),
693
+ parseSource: validated ? "llm" : "backend_fallback",
694
+ submissionStatus: validated ? "VALID" : "REJECTED_OUTPUT",
695
+ structuredProtocolVersion: p.agentTask.actionProtocolVersion,
696
+ retryCount,
697
+ validationReason: validated ? void 0 : validationReason,
698
+ modelActionRejected: validated ? false : true,
699
+ modelActionType: parsed?.actionType,
700
+ firstRawResponseHash,
701
+ retryRawResponseHash
702
+ }));
703
+ if (response?.ok === false) return false;
704
+ return true;
705
+ } catch {
706
+ this.options.onStateChange?.("game_action_submit_pending");
707
+ return false;
708
+ }
709
+ }
710
+ async getOrCreateSession(sessionKey) {
711
+ const existing = this.state.sessionIds[sessionKey];
712
+ const agentSessionId = this.state.agentSessionIds[sessionKey];
713
+ const workDir = this.options.workDir ? join3(this.options.workDir, `session-${sha256(sessionKey).slice(0, 24)}`) : void 0;
714
+ if (existing) {
715
+ try {
716
+ const resumed = await this.options.worker.resumeSession({ sessionId: existing, agentSessionId, runtime: this.options.runtime, runtimePath: this.options.runtimePath, backend: this.options.backend, appServerUrl: this.options.appServerUrl, workDir });
717
+ if (resumed.agentSessionId) {
718
+ this.state.agentSessionIds[sessionKey] = resumed.agentSessionId;
719
+ await this.persist();
720
+ }
721
+ return resumed;
722
+ } catch (error) {
723
+ this.diagnose({ event: "session.resume_failed", sessionId: existing, error: error instanceof Error ? error.message : String(error) });
724
+ }
725
+ }
726
+ const session = await this.options.worker.startSession({ runtime: this.options.runtime, runtimePath: this.options.runtimePath, backend: this.options.backend, appServerUrl: this.options.appServerUrl, workDir });
727
+ this.state.sessionIds[sessionKey] = session.sessionId;
728
+ if (session.agentSessionId) this.state.agentSessionIds[sessionKey] = session.agentSessionId;
729
+ await this.persist();
730
+ return session;
731
+ }
732
+ async runTask(session, frame, input, timeoutMs = this.options.taskTimeoutMs ?? 5 * 6e4) {
733
+ const messageId = readMessageId(frame) ?? (isGameEvent(frame) ? frame.payload.eventId : frame.id);
734
+ return new Promise(async (resolve3, reject) => {
735
+ const context = { frame, sessionId: session.sessionId, output: "", hasText: false, done: resolve3, fail: reject, settled: false };
736
+ this.contexts.set(session.sessionId, context);
737
+ context.timer = setTimeout(() => {
738
+ if (!context.settled) {
739
+ context.settled = true;
740
+ this.contexts.delete(session.sessionId);
741
+ reject(new Error("worker task timed out"));
742
+ void this.options.worker.cancel(session.sessionId, "task timeout");
743
+ }
744
+ }, Math.max(1, timeoutMs));
745
+ context.timer.unref?.();
746
+ try {
747
+ await this.options.worker.send({ sessionId: session.sessionId, messageId, prompt: input.text, images: input.images, files: input.files, timeoutMs });
748
+ } catch (error) {
749
+ if (context.timer) clearTimeout(context.timer);
750
+ this.contexts.delete(session.sessionId);
751
+ reject(error);
752
+ }
753
+ });
754
+ }
755
+ async prepareMessageInput(payload) {
756
+ if (!payload) throw new Error("message payload is missing");
757
+ if (payload.messageType !== "IMAGE" && payload.messageType !== "FILE") return { input: { text: payload.content } };
758
+ let artifact;
759
+ try {
760
+ artifact = JSON.parse(payload.content);
761
+ } catch {
762
+ throw new Error("media message content is not valid JSON");
763
+ }
764
+ if (!isRecord(artifact) || typeof artifact.url !== "string" || !artifact.url) throw new Error("media message is missing url");
765
+ const mediaUrl = new URL(artifact.url, this.options.channel.getGatewayUrl());
766
+ if (mediaUrl.protocol !== "http:" && mediaUrl.protocol !== "https:") throw new Error("media URL must use http or https");
767
+ const response = await fetch(mediaUrl);
768
+ if (!response.ok) throw new Error(`media download failed: HTTP ${response.status}`);
769
+ const declaredLength = response.headers.get("content-length");
770
+ if (declaredLength && Number(declaredLength) > MAX_ATTACHMENT_BYTES) throw new Error("media attachment exceeds size limit");
771
+ const bytes = Buffer.from(await response.arrayBuffer());
772
+ if (bytes.byteLength > MAX_ATTACHMENT_BYTES) throw new Error("media attachment exceeds size limit");
773
+ if (typeof artifact.fileSize === "number" && Number.isSafeInteger(artifact.fileSize) && artifact.fileSize > 0 && artifact.fileSize !== bytes.byteLength) {
774
+ throw new Error("media attachment size does not match metadata");
775
+ }
776
+ const directory = await mkdtemp(join3(this.options.attachmentDir ?? tmpdir(), "clawtopia-agent-"));
777
+ const rawFileName = typeof artifact.fileName === "string" && artifact.fileName ? artifact.fileName : "attachment";
778
+ const fileName = rawFileName.replace(/[\\/\x00-\x1F\x7F]/gu, "_").slice(0, 255) || "attachment";
779
+ const path = join3(directory, fileName);
780
+ await writeFile2(path, bytes, { mode: 384 });
781
+ const attachment = { path, mimeType: typeof artifact.mimeType === "string" ? artifact.mimeType : void 0, fileName, size: bytes.byteLength };
782
+ return payload.messageType === "IMAGE" ? { input: { text: `\u8BF7\u5904\u7406\u9644\u4EF6 ${fileName}`, images: [attachment] }, temporaryDir: directory } : { input: { text: `\u8BF7\u5904\u7406\u9644\u4EF6 ${fileName}`, files: [attachment] }, temporaryDir: directory };
783
+ }
784
+ async sendReply(frame, text) {
785
+ const p = frame.payload;
786
+ const target = frame.type === "PRIVATE_MESSAGE" ? { userId: String(p.sender?.userId ?? ""), userType: String(p.sender?.userType ?? "HUMAN") } : void 0;
787
+ const type = frame.type === "PRIVATE_MESSAGE" ? "SEND_PRIVATE" : "SEND_GROUP";
788
+ const deliveryId = `${String(p.messageId)}:0`;
789
+ const payload = frame.type === "PRIVATE_MESSAGE" ? { target, messageType: "TEXT", content: text, replyToMessageId: String(p.messageId), sourceServerSeq: p.seq, deliveryId, deliveryIndex: 0 } : { groupId: String(p.groupId), messageType: "TEXT", content: text, sourceServerSeq: p.seq, deliveryId, deliveryIndex: 0 };
790
+ const result = await this.options.channel.request(createFrame(type, payload));
791
+ if (result?.ok === false || !result?.messageId) throw new Error(result?.error?.message ?? "reply was rejected");
792
+ }
793
+ async handleWorkerEvent(event) {
794
+ if (!event.sessionId) return;
795
+ if (event.agentSessionId) {
796
+ const key = Object.entries(this.state.sessionIds).find(([, id]) => id === event.sessionId)?.[0];
797
+ if (key && this.state.agentSessionIds[key] !== event.agentSessionId) {
798
+ this.state.agentSessionIds[key] = event.agentSessionId;
799
+ await this.persist();
800
+ }
801
+ }
802
+ const context = this.contexts.get(event.sessionId);
803
+ if (!context || context.settled) return;
804
+ const data = isRecord(event.data) ? event.data : {};
805
+ if (event.event === "text") {
806
+ const text = event.content ?? (typeof data.text === "string" ? data.text : typeof event.data === "string" ? event.data : "");
807
+ context.output += text;
808
+ context.hasText = context.hasText || Boolean(text);
809
+ } else if (event.event === "result") {
810
+ context.settled = true;
811
+ if (context.timer) clearTimeout(context.timer);
812
+ this.contexts.delete(event.sessionId);
813
+ const finalText = context.hasText ? context.output : event.content ?? (typeof data.text === "string" ? data.text : "");
814
+ context.done(finalText);
815
+ } else if (event.event === "error") {
816
+ context.settled = true;
817
+ if (context.timer) clearTimeout(context.timer);
818
+ this.contexts.delete(event.sessionId);
819
+ context.fail(new Error(event.error ?? (typeof data.message === "string" ? data.message : "worker runtime error")));
820
+ } else if (event.event === "permission" && this.options.onPermission && event.requestId) {
821
+ const decision = await this.options.onPermission({ sessionId: event.sessionId, requestId: event.requestId, data });
822
+ await this.options.worker.respondPermission({ sessionId: event.sessionId, requestId: event.requestId, behavior: decision });
823
+ }
824
+ }
825
+ remember(list, value) {
826
+ if (!list.includes(value)) list.push(value);
827
+ if (list.length > 5e3) list.splice(0, list.length - 5e3);
828
+ }
829
+ diagnose(entry) {
830
+ try {
831
+ this.options.onDiagnostic?.(entry);
832
+ } catch {
833
+ }
834
+ }
835
+ async persist() {
836
+ this.state.updatedAt = Date.now();
837
+ await this.stateStore.save(this.state);
838
+ }
839
+ };
840
+ function buildRetryPrompt(prompt, reason) {
841
+ return `${prompt}
842
+
843
+ \u4E0A\u4E00\u8F6E\u8F93\u51FA\u672A\u901A\u8FC7\u7ED3\u6784\u5316\u52A8\u4F5C\u6821\u9A8C\uFF08${reason}\uFF09\u3002\u8BF7\u91CD\u65B0\u8F93\u51FA\u4E00\u4E2A\u5B8C\u6574 JSON \u5BF9\u8C61\uFF0C\u53EA\u4FEE\u6B63\u683C\u5F0F\u548C\u5B57\u6BB5\uFF0C\u4E0D\u8981\u89E3\u91CA\u3002`;
844
+ }
845
+ function normalizeGameAction(value) {
846
+ if (!isRecord(value) || typeof value.actionType !== "string" || !isRecord(value.actionData)) return null;
847
+ return { actionType: value.actionType, actionData: value.actionData, ...typeof value.speech === "string" ? { speech: value.speech } : {}, ...typeof value.voteReason === "string" ? { voteReason: value.voteReason } : {} };
848
+ }
849
+ function parseAction(output) {
850
+ const start = output.indexOf("{");
851
+ const end = output.lastIndexOf("}");
852
+ if (start < 0 || end <= start) return null;
853
+ try {
854
+ const value = JSON.parse(output.slice(start, end + 1));
855
+ return normalizeGameAction(isRecord(value) ? value : null);
856
+ } catch {
857
+ return null;
858
+ }
859
+ }
860
+ function validateAction(action, contract, rootSchema) {
861
+ if (!action) return null;
862
+ const options = Array.isArray(contract?.options) ? contract.options : [];
863
+ const option = options.find((candidate) => isRecord(candidate) && candidate.actionType === action.actionType);
864
+ if (!option) return null;
865
+ const schema = isRecord(option) && isRecord(option.actionDataSchema) ? option.actionDataSchema : void 0;
866
+ if (schema && !matchesSchema(action.actionData, schema)) return null;
867
+ const root = { actionType: action.actionType, actionData: action.actionData, ...action.speech ? { speech: action.speech } : {}, ...action.voteReason ? { voteReason: action.voteReason } : {} };
868
+ if (rootSchema && !matchesSchema(root, rootSchema)) return null;
869
+ return action;
870
+ }
871
+ function matchesSchema(value, schema) {
872
+ if (Array.isArray(schema.oneOf)) return schema.oneOf.some((variant) => isRecord(variant) && matchesSchema(value, variant));
873
+ if (Array.isArray(schema.enum) && !schema.enum.some((item) => Object.is(item, value))) return false;
874
+ const types = Array.isArray(schema.type) ? schema.type : typeof schema.type === "string" ? [schema.type] : [];
875
+ return types.length === 0 || types.some((type) => matchesSchemaType(value, type, schema));
876
+ }
877
+ function matchesSchemaType(value, type, schema) {
878
+ if (type === "object") {
879
+ if (!isRecord(value)) return false;
880
+ const properties = isRecord(schema.properties) ? schema.properties : {};
881
+ const required = Array.isArray(schema.required) ? schema.required.filter((field) => typeof field === "string") : [];
882
+ if (required.some((field) => !Object.hasOwn(value, field) || !isRecord(properties[field]) || !matchesSchema(value[field], properties[field]))) return false;
883
+ if (schema.additionalProperties === false && Object.keys(value).some((field) => !Object.hasOwn(properties, field))) return false;
884
+ return Object.entries(value).every(([field, fieldValue]) => !Object.hasOwn(properties, field) || !isRecord(properties[field]) || matchesSchema(fieldValue, properties[field]));
885
+ }
886
+ if (type === "array") {
887
+ if (!Array.isArray(value)) return false;
888
+ if (typeof schema.minItems === "number" && value.length < schema.minItems) return false;
889
+ if (typeof schema.maxItems === "number" && value.length > schema.maxItems) return false;
890
+ return !isRecord(schema.items) || value.every((item) => matchesSchema(item, schema.items));
891
+ }
892
+ if (type === "string") return typeof value === "string" && (typeof schema.maxLength !== "number" || value.length <= schema.maxLength) && (typeof schema.minLength !== "number" || value.length >= schema.minLength);
893
+ if (type === "integer") return typeof value === "number" && Number.isInteger(value) && withinNumberBounds(value, schema);
894
+ if (type === "number") return typeof value === "number" && Number.isFinite(value) && withinNumberBounds(value, schema);
895
+ if (type === "boolean") return typeof value === "boolean";
896
+ if (type === "null") return value === null;
897
+ return true;
898
+ }
899
+ function withinNumberBounds(value, schema) {
900
+ return (typeof schema.minimum !== "number" || value >= schema.minimum) && (typeof schema.maximum !== "number" || value <= schema.maximum) && (typeof schema.exclusiveMinimum !== "number" || value > schema.exclusiveMinimum) && (typeof schema.exclusiveMaximum !== "number" || value < schema.exclusiveMaximum);
901
+ }
902
+ function sha256(value) {
903
+ return createHash("sha256").update(value).digest("hex");
904
+ }
905
+
906
+ // src/worker-client.ts
907
+ import { spawn } from "child_process";
908
+ import { createInterface } from "readline";
909
+ import { randomUUID as randomUUID4 } from "crypto";
910
+ var WorkerClient = class {
911
+ constructor(options) {
912
+ this.options = options;
913
+ }
914
+ options;
915
+ transport;
916
+ pending = /* @__PURE__ */ new Map();
917
+ started = false;
918
+ closed = false;
919
+ eventHandler;
920
+ setEventHandler(handler) {
921
+ this.eventHandler = handler;
922
+ }
923
+ async start() {
924
+ if (this.started) return;
925
+ if (this.closed) throw new Error("worker client is closed");
926
+ const transport = this.options.transportFactory?.(this.options) ?? spawnTransport(this.options);
927
+ this.transport = transport;
928
+ transport.onLine((line) => this.handleLine(line));
929
+ transport.onExit?.((error) => {
930
+ if (this.transport !== transport) return;
931
+ this.started = false;
932
+ this.transport = void 0;
933
+ this.rejectAll(error);
934
+ });
935
+ this.started = true;
936
+ }
937
+ async initialize(runtime, connectorVersion) {
938
+ await this.start();
939
+ return this.request("initialize", { runtime, connectorVersion, protocolVersion: 1 });
940
+ }
941
+ startSession(input) {
942
+ return this.request("start_session", input);
943
+ }
944
+ resumeSession(input) {
945
+ return this.request("resume_session", input);
946
+ }
947
+ send(input) {
948
+ return this.request("send", input);
949
+ }
950
+ respondPermission(input) {
951
+ return this.request("respond_permission", input);
952
+ }
953
+ cancel(sessionId, reason) {
954
+ return this.request("cancel", { sessionId, reason });
955
+ }
956
+ closeSession(sessionId) {
957
+ return this.request("close", { sessionId });
958
+ }
959
+ health() {
960
+ return this.request("health", {});
961
+ }
962
+ async shutdown() {
963
+ this.closed = true;
964
+ for (const pending of this.pending.values()) {
965
+ clearTimeout(pending.timer);
966
+ pending.reject(new Error("worker client stopped"));
967
+ }
968
+ this.pending.clear();
969
+ await this.transport?.close();
970
+ this.transport = void 0;
971
+ }
972
+ async request(method, params) {
973
+ await this.start();
974
+ const id = `rpc_${randomUUID4()}`;
975
+ const timeout = setTimeout(() => {
976
+ const pending = this.pending.get(id);
977
+ if (!pending) return;
978
+ this.pending.delete(id);
979
+ pending.reject(new Error(`worker request timed out: ${method}`));
980
+ }, this.options.requestTimeoutMs ?? 6e4);
981
+ return new Promise((resolve3, reject) => {
982
+ this.pending.set(id, { resolve: (value) => resolve3(value), reject, timer: timeout });
983
+ try {
984
+ this.transport.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}
985
+ `);
986
+ } catch (error) {
987
+ clearTimeout(timeout);
988
+ this.pending.delete(id);
989
+ reject(asError2(error));
990
+ }
991
+ });
992
+ }
993
+ handleLine(line) {
994
+ if (!line.trim()) return;
995
+ let message;
996
+ try {
997
+ message = JSON.parse(line);
998
+ } catch {
999
+ return;
1000
+ }
1001
+ if ("id" in message && typeof message.id === "string") {
1002
+ const pending = this.pending.get(message.id);
1003
+ if (!pending) return;
1004
+ clearTimeout(pending.timer);
1005
+ this.pending.delete(message.id);
1006
+ if ("error" in message && message.error) pending.reject(new Error(message.error.message ?? "worker RPC error"));
1007
+ else pending.resolve(message.result);
1008
+ return;
1009
+ }
1010
+ if ("method" in message && message.method === "event") {
1011
+ const params = isRecord4(message.params) ? message.params : {};
1012
+ if (typeof params.type !== "string") return;
1013
+ const type = params.type.toUpperCase();
1014
+ const event = type === "TEXT" ? "text" : type === "THINKING" ? "thinking" : type === "TOOL_USE" ? "tool_use" : type === "TOOL_RESULT" ? "tool_result" : type === "PERMISSION_REQUEST" || type === "PERMISSION" ? "permission" : type === "RESULT" ? "result" : type === "ERROR" ? "error" : void 0;
1015
+ if (!event) return;
1016
+ const normalized = { event, sessionId: typeof params.sessionId === "string" ? params.sessionId : void 0, agentSessionId: typeof params.agentSessionId === "string" ? params.agentSessionId : void 0, content: typeof params.content === "string" ? params.content : void 0, done: params.done === true, error: typeof params.error === "string" ? params.error : void 0, requestId: typeof params.requestId === "string" ? params.requestId : void 0, data: params };
1017
+ void this.eventHandler?.(normalized);
1018
+ void this.options.onEvent?.(normalized);
1019
+ }
1020
+ }
1021
+ rejectAll(error) {
1022
+ for (const pending of this.pending.values()) {
1023
+ clearTimeout(pending.timer);
1024
+ pending.reject(error);
1025
+ }
1026
+ this.pending.clear();
1027
+ }
1028
+ };
1029
+ function spawnTransport(options) {
1030
+ const child = spawn(options.executable, options.args ?? [], { cwd: options.cwd, env: { ...process.env, ...options.env }, stdio: ["pipe", "pipe", "pipe"] });
1031
+ const readline = createInterface({ input: child.stdout });
1032
+ readline.on("line", () => void 0);
1033
+ child.stderr.setEncoding("utf8");
1034
+ child.stderr.on("data", (chunk) => {
1035
+ const line = chunk.trim();
1036
+ if (line) options.onStderr?.(redactStderr(line));
1037
+ });
1038
+ const listeners = /* @__PURE__ */ new Set();
1039
+ const exitListeners = /* @__PURE__ */ new Set();
1040
+ readline.removeAllListeners("line");
1041
+ readline.on("line", (line) => {
1042
+ for (const listener of listeners) listener(line);
1043
+ });
1044
+ child.once("error", (error) => {
1045
+ for (const listener of exitListeners) listener(error);
1046
+ });
1047
+ child.once("exit", (code, signal) => {
1048
+ if (code !== 0 || signal) {
1049
+ const detail = signal ? `signal ${signal}` : `exit code ${code}`;
1050
+ for (const listener of exitListeners) listener(new Error(`worker process exited with ${detail}`));
1051
+ }
1052
+ });
1053
+ return {
1054
+ write: (line) => {
1055
+ if (!child.stdin.writable) throw new Error("worker stdin is closed");
1056
+ child.stdin.write(line);
1057
+ },
1058
+ onLine: (listener) => {
1059
+ listeners.add(listener);
1060
+ },
1061
+ onExit: (listener) => {
1062
+ exitListeners.add(listener);
1063
+ },
1064
+ close: () => closeChild(child, readline)
1065
+ };
1066
+ }
1067
+ async function closeChild(child, readline) {
1068
+ readline.close();
1069
+ if (child.exitCode !== null || child.signalCode !== null) return;
1070
+ child.kill("SIGTERM");
1071
+ await new Promise((resolve3) => {
1072
+ const timer = setTimeout(() => {
1073
+ if (child.exitCode === null) child.kill("SIGKILL");
1074
+ resolve3();
1075
+ }, 2e3);
1076
+ child.once("exit", () => {
1077
+ clearTimeout(timer);
1078
+ resolve3();
1079
+ });
1080
+ });
1081
+ }
1082
+ function redactStderr(line) {
1083
+ return line.replace(/(Bearer\s+|token[=:]\s*)[^\s,]+/giu, "$1[REDACTED]");
1084
+ }
1085
+ function isRecord4(value) {
1086
+ return typeof value === "object" && value !== null;
1087
+ }
1088
+ function asError2(error) {
1089
+ return error instanceof Error ? error : new Error(String(error));
1090
+ }
1091
+
1092
+ export {
1093
+ PROTOCOL_VERSION,
1094
+ EXACT_ACK_CAPABILITY,
1095
+ RECEIPT_CAPABILITY,
1096
+ MEDIA_INPUT_CAPABILITY,
1097
+ GAME_CAPABILITY,
1098
+ PROMPT_PASSTHROUGH_CAPABILITY,
1099
+ DEFAULT_CAPABILITIES,
1100
+ createFrame,
1101
+ respondFrame,
1102
+ encodeFrame,
1103
+ FrameDecodeError,
1104
+ decodeFrame,
1105
+ isRecord,
1106
+ isInboundMessage,
1107
+ isGameEvent,
1108
+ readSeq,
1109
+ readMessageId,
1110
+ ChannelClient,
1111
+ activeFlavor,
1112
+ assertEnvironmentMatches,
1113
+ configRoot,
1114
+ normalizeProfileId,
1115
+ profileConfigPath,
1116
+ defaultConfigPath,
1117
+ bundledWorkerPath,
1118
+ defaultConfig,
1119
+ loadConfig,
1120
+ saveConfig,
1121
+ profileEnvironment,
1122
+ MemoryStateStore,
1123
+ JsonFileStateStore,
1124
+ normalizeState,
1125
+ cloneState,
1126
+ RuntimeConnector,
1127
+ WorkerClient
1128
+ };