@rethinkingstudio/clawpilot 1.0.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.
Files changed (45) hide show
  1. package/README.md +79 -0
  2. package/dist/commands/install.d.ts +4 -0
  3. package/dist/commands/install.js +105 -0
  4. package/dist/commands/install.js.map +1 -0
  5. package/dist/commands/pair.d.ts +6 -0
  6. package/dist/commands/pair.js +60 -0
  7. package/dist/commands/pair.js.map +1 -0
  8. package/dist/commands/run.d.ts +1 -0
  9. package/dist/commands/run.js +27 -0
  10. package/dist/commands/run.js.map +1 -0
  11. package/dist/commands/status.d.ts +1 -0
  12. package/dist/commands/status.js +56 -0
  13. package/dist/commands/status.js.map +1 -0
  14. package/dist/config/config.d.ts +22 -0
  15. package/dist/config/config.js +53 -0
  16. package/dist/config/config.js.map +1 -0
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.js +63 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/relay/gateway-client.d.ts +34 -0
  21. package/dist/relay/gateway-client.js +268 -0
  22. package/dist/relay/gateway-client.js.map +1 -0
  23. package/dist/relay/reconnect.d.ts +14 -0
  24. package/dist/relay/reconnect.js +27 -0
  25. package/dist/relay/reconnect.js.map +1 -0
  26. package/dist/relay/relay-manager.d.ts +19 -0
  27. package/dist/relay/relay-manager.js +101 -0
  28. package/dist/relay/relay-manager.js.map +1 -0
  29. package/dist/relay/session-proxy.d.ts +18 -0
  30. package/dist/relay/session-proxy.js +75 -0
  31. package/dist/relay/session-proxy.js.map +1 -0
  32. package/package.json +26 -0
  33. package/run-relay.sh +24 -0
  34. package/src/commands/install.ts +110 -0
  35. package/src/commands/pair.ts +84 -0
  36. package/src/commands/run.ts +33 -0
  37. package/src/commands/status.ts +58 -0
  38. package/src/config/config.ts +67 -0
  39. package/src/index.ts +70 -0
  40. package/src/relay/gateway-client.ts +333 -0
  41. package/src/relay/reconnect.ts +37 -0
  42. package/src/relay/relay-manager.ts +148 -0
  43. package/test-chat.mjs +64 -0
  44. package/test-direct.mjs +171 -0
  45. package/tsconfig.json +16 -0
@@ -0,0 +1,333 @@
1
+ import { WebSocket } from "ws";
2
+ import { randomUUID, generateKeyPairSync, createPrivateKey, sign, createPublicKey, createHash } from "node:crypto";
3
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { homedir } from "node:os";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Device identity (Ed25519, persisted across restarts)
9
+ // ---------------------------------------------------------------------------
10
+
11
+ const IDENTITY_PATH = join(homedir(), ".clawai", "device-identity.json");
12
+ const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
13
+
14
+ interface DeviceIdentity {
15
+ deviceId: string;
16
+ publicKeyPem: string;
17
+ privateKeyPem: string;
18
+ }
19
+
20
+ function base64UrlEncode(buf: Buffer): string {
21
+ return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
22
+ }
23
+
24
+ function rawPublicKeyBytes(publicKeyPem: string): Buffer {
25
+ const key = createPublicKey(publicKeyPem);
26
+ const spki = key.export({ type: "spki", format: "der" }) as Buffer;
27
+ if (
28
+ spki.length === ED25519_SPKI_PREFIX.length + 32 &&
29
+ spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
30
+ ) {
31
+ return spki.subarray(ED25519_SPKI_PREFIX.length);
32
+ }
33
+ return spki;
34
+ }
35
+
36
+ function loadOrCreateDeviceIdentity(): DeviceIdentity {
37
+ if (existsSync(IDENTITY_PATH)) {
38
+ try {
39
+ const stored = JSON.parse(readFileSync(IDENTITY_PATH, "utf8")) as DeviceIdentity & { version?: number };
40
+ if (stored.deviceId && stored.publicKeyPem && stored.privateKeyPem) {
41
+ return { deviceId: stored.deviceId, publicKeyPem: stored.publicKeyPem, privateKeyPem: stored.privateKeyPem };
42
+ }
43
+ } catch { /* fall through */ }
44
+ }
45
+
46
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
47
+ const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
48
+ const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
49
+ const deviceId = createHash("sha256").update(rawPublicKeyBytes(publicKeyPem)).digest("hex");
50
+ const identity: DeviceIdentity = { deviceId, publicKeyPem, privateKeyPem };
51
+
52
+ mkdirSync(join(homedir(), ".clawai"), { recursive: true });
53
+ writeFileSync(IDENTITY_PATH, JSON.stringify({ version: 1, ...identity, createdAtMs: Date.now() }, null, 2) + "\n", { mode: 0o600 });
54
+ return identity;
55
+ }
56
+
57
+ function buildSignedDevice(identity: DeviceIdentity, opts: {
58
+ clientId: string;
59
+ clientMode: string;
60
+ role: string;
61
+ scopes: string[];
62
+ signedAtMs: number;
63
+ token?: string;
64
+ nonce?: string;
65
+ }): { id: string; publicKey: string; signature: string; signedAt: number; nonce?: string } {
66
+ const version = opts.nonce ? "v2" : "v1";
67
+ const payload = [
68
+ version,
69
+ identity.deviceId,
70
+ opts.clientId,
71
+ opts.clientMode,
72
+ opts.role,
73
+ opts.scopes.join(","),
74
+ String(opts.signedAtMs),
75
+ opts.token ?? "",
76
+ ...(version === "v2" ? [opts.nonce ?? ""] : []),
77
+ ].join("|");
78
+
79
+ const key = createPrivateKey(identity.privateKeyPem);
80
+ const signature = base64UrlEncode(sign(null, Buffer.from(payload, "utf8"), key));
81
+
82
+ return {
83
+ id: identity.deviceId,
84
+ publicKey: base64UrlEncode(rawPublicKeyBytes(identity.publicKeyPem)),
85
+ signature,
86
+ signedAt: opts.signedAtMs,
87
+ nonce: opts.nonce,
88
+ };
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Wire frame types (OpenClaw protocol v3)
93
+ // ---------------------------------------------------------------------------
94
+
95
+ interface ReqFrame {
96
+ type: "req";
97
+ id: string;
98
+ method: string;
99
+ params?: unknown;
100
+ }
101
+
102
+ interface ResFrame {
103
+ type: "res";
104
+ id: string;
105
+ ok: boolean;
106
+ payload?: unknown;
107
+ error?: { message?: string };
108
+ }
109
+
110
+ interface EvtFrame {
111
+ type: "event";
112
+ event: string;
113
+ payload?: unknown;
114
+ seq?: number;
115
+ }
116
+
117
+ const PROTOCOL_VERSION = 3;
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Public API
121
+ // ---------------------------------------------------------------------------
122
+
123
+ export interface GatewayClientOptions {
124
+ url: string;
125
+ token?: string;
126
+ password?: string;
127
+ onConnected: () => void;
128
+ onEvent: (eventName: string, payload: unknown) => void;
129
+ onDisconnected: (reason: string) => void;
130
+ }
131
+
132
+ export class OpenClawGatewayClient {
133
+ private ws: WebSocket | null = null;
134
+ private pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: unknown) => void }>();
135
+ private backoffMs = 1000;
136
+ private stopped = false;
137
+ private connectNonce: string | null = null;
138
+ private connectSent = false;
139
+ private storedDeviceToken: string | null = null;
140
+ private connectTimer: ReturnType<typeof setTimeout> | null = null;
141
+ private tickTimer: ReturnType<typeof setInterval> | null = null;
142
+ private lastTick = 0;
143
+ private tickIntervalMs = 30_000;
144
+ private readonly identity: DeviceIdentity;
145
+
146
+ constructor(private readonly opts: GatewayClientOptions) {
147
+ this.identity = loadOrCreateDeviceIdentity();
148
+ }
149
+
150
+ start(): void {
151
+ if (this.stopped) return;
152
+ this.ws = new WebSocket(this.opts.url, { maxPayload: 25 * 1024 * 1024 });
153
+
154
+ this.ws.on("open", () => {
155
+ this.connectNonce = null;
156
+ this.connectSent = false;
157
+ // Fallback: send connect after 1 s if challenge hasn't arrived
158
+ this.connectTimer = setTimeout(() => this.sendConnect(), 1000);
159
+ });
160
+
161
+ this.ws.on("message", (data) => {
162
+ const raw = typeof data === "string" ? data : data.toString();
163
+ this.handleMessage(raw);
164
+ });
165
+
166
+ this.ws.on("close", (code, reason) => {
167
+ const reasonText = reason.toString() || `code ${code}`;
168
+ this.teardown();
169
+ this.opts.onDisconnected(reasonText);
170
+ this.scheduleReconnect();
171
+ });
172
+
173
+ this.ws.on("error", (err) => {
174
+ console.error(`[gateway-client] ws error: ${String(err)}`);
175
+ });
176
+ }
177
+
178
+ stop(): void {
179
+ this.stopped = true;
180
+ this.teardown();
181
+ this.ws?.close();
182
+ this.ws = null;
183
+ this.flushPending(new Error("gateway client stopped"));
184
+ }
185
+
186
+ send(method: string, params?: unknown): void {
187
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
188
+ throw new Error("gateway not connected");
189
+ }
190
+ const frame: ReqFrame = { type: "req", id: randomUUID(), method, params };
191
+ this.ws.send(JSON.stringify(frame));
192
+ }
193
+
194
+ async request<T = unknown>(method: string, params?: unknown): Promise<T> {
195
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
196
+ throw new Error("gateway not connected");
197
+ }
198
+ const id = randomUUID();
199
+ const frame: ReqFrame = { type: "req", id, method, params };
200
+ const p = new Promise<T>((resolve, reject) => {
201
+ this.pending.set(id, { resolve: (v) => resolve(v as T), reject });
202
+ });
203
+ this.ws.send(JSON.stringify(frame));
204
+ return p;
205
+ }
206
+
207
+ // -------------------------------------------------------------------------
208
+
209
+ private sendConnect(): void {
210
+ if (this.connectSent) return;
211
+ this.connectSent = true;
212
+ if (this.connectTimer) { clearTimeout(this.connectTimer); this.connectTimer = null; }
213
+
214
+ const role = "operator";
215
+ const scopes = ["operator.admin"];
216
+ const clientId = "gateway-client";
217
+ const clientMode = "backend";
218
+ const signedAtMs = Date.now();
219
+ const nonce = this.connectNonce ?? undefined;
220
+ const authToken = this.storedDeviceToken ?? this.opts.token;
221
+
222
+ const device = buildSignedDevice(this.identity, {
223
+ clientId, clientMode, role, scopes, signedAtMs,
224
+ token: authToken ?? undefined,
225
+ nonce,
226
+ });
227
+
228
+ const params = {
229
+ minProtocol: PROTOCOL_VERSION,
230
+ maxProtocol: PROTOCOL_VERSION,
231
+ role,
232
+ scopes,
233
+ caps: ["tool-events"],
234
+ commands: ["chat.push"],
235
+ client: {
236
+ id: clientId,
237
+ displayName: "ClawAI Relay",
238
+ version: "1.0.0",
239
+ platform: process.platform,
240
+ mode: clientMode,
241
+ },
242
+ device,
243
+ auth: authToken || this.opts.password
244
+ ? { token: authToken, password: this.opts.password }
245
+ : undefined,
246
+ };
247
+
248
+ this.request<{ policy?: { tickIntervalMs?: number }; auth?: { deviceToken?: string } }>("connect", params)
249
+ .then((helloOk) => {
250
+ const deviceToken = helloOk?.auth?.deviceToken;
251
+ if (typeof deviceToken === "string") {
252
+ this.storedDeviceToken = deviceToken;
253
+ }
254
+ if (typeof helloOk?.policy?.tickIntervalMs === "number") {
255
+ this.tickIntervalMs = helloOk.policy.tickIntervalMs;
256
+ }
257
+ this.backoffMs = 1000;
258
+ this.lastTick = Date.now();
259
+ this.startTickWatch();
260
+ this.opts.onConnected();
261
+ })
262
+ .catch((err: unknown) => {
263
+ console.error(`[gateway-client] connect failed: ${String(err)}`);
264
+ this.ws?.close(1008, "connect failed");
265
+ });
266
+ }
267
+
268
+ private handleMessage(raw: string): void {
269
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
270
+ let parsed: any;
271
+ try {
272
+ parsed = JSON.parse(raw);
273
+ } catch {
274
+ return;
275
+ }
276
+ if (typeof parsed?.type !== "string") return;
277
+
278
+ if (parsed.type === "event") {
279
+ const evt = parsed as EvtFrame;
280
+
281
+ if (evt.event === "connect.challenge") {
282
+ const nonce = (evt.payload as { nonce?: unknown } | undefined)?.nonce;
283
+ if (typeof nonce === "string") {
284
+ this.connectNonce = nonce;
285
+ this.sendConnect();
286
+ }
287
+ return;
288
+ }
289
+
290
+ if (evt.event === "tick") { this.lastTick = Date.now(); return; }
291
+
292
+ this.opts.onEvent(evt.event!, evt.payload ?? null);
293
+ return;
294
+ }
295
+
296
+ if (parsed.type === "res") {
297
+ const res = parsed as ResFrame;
298
+ const pending = this.pending.get(res.id);
299
+ if (!pending) return;
300
+ this.pending.delete(res.id);
301
+ if (res.ok) pending.resolve(res.payload);
302
+ else pending.reject(new Error(res.error?.message ?? "gateway error"));
303
+ }
304
+ }
305
+
306
+ private startTickWatch(): void {
307
+ if (this.tickTimer) clearInterval(this.tickTimer);
308
+ const interval = Math.max(this.tickIntervalMs, 1000);
309
+ this.tickTimer = setInterval(() => {
310
+ if (this.stopped || !this.lastTick) return;
311
+ if (Date.now() - this.lastTick > this.tickIntervalMs * 2) {
312
+ this.ws?.close(4000, "tick timeout");
313
+ }
314
+ }, interval);
315
+ }
316
+
317
+ private scheduleReconnect(): void {
318
+ if (this.stopped) return;
319
+ const delay = this.backoffMs;
320
+ this.backoffMs = Math.min(this.backoffMs * 2, 30_000);
321
+ setTimeout(() => this.start(), delay).unref();
322
+ }
323
+
324
+ private teardown(): void {
325
+ if (this.connectTimer) { clearTimeout(this.connectTimer); this.connectTimer = null; }
326
+ if (this.tickTimer) { clearInterval(this.tickTimer); this.tickTimer = null; }
327
+ }
328
+
329
+ private flushPending(err: Error): void {
330
+ for (const p of this.pending.values()) p.reject(err);
331
+ this.pending.clear();
332
+ }
333
+ }
@@ -0,0 +1,37 @@
1
+ export interface ReconnectOptions {
2
+ initialDelayMs?: number;
3
+ maxDelayMs?: number;
4
+ onRetry?: (attempt: number, delayMs: number) => void;
5
+ }
6
+
7
+ /**
8
+ * Implements exponential backoff: 500ms → 1s → 2s → ... → 30s cap.
9
+ * Calls `connect` repeatedly until `connect` returns false (permanent failure)
10
+ * or the returned WebSocket connection stays alive.
11
+ *
12
+ * The `connect` callback should return `true` if it attempted a connection
13
+ * and `false` if it should stop retrying.
14
+ */
15
+ export async function withReconnect(
16
+ connect: () => Promise<boolean>,
17
+ opts: ReconnectOptions = {}
18
+ ): Promise<void> {
19
+ const initialDelay = opts.initialDelayMs ?? 500;
20
+ const maxDelay = opts.maxDelayMs ?? 30_000;
21
+ let attempt = 0;
22
+ let delay = initialDelay;
23
+
24
+ while (true) {
25
+ const shouldRetry = await connect();
26
+ if (!shouldRetry) break;
27
+
28
+ attempt++;
29
+ opts.onRetry?.(attempt, delay);
30
+ await sleep(delay);
31
+ delay = Math.min(delay * 2, maxDelay);
32
+ }
33
+ }
34
+
35
+ function sleep(ms: number): Promise<void> {
36
+ return new Promise((resolve) => setTimeout(resolve, ms));
37
+ }
@@ -0,0 +1,148 @@
1
+ import { WebSocket } from "ws";
2
+ import { OpenClawGatewayClient } from "./gateway-client.js";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Messages: relay client ↔ relay server
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /** Messages the relay client sends to the relay server. */
9
+ type ToServer =
10
+ | { type: "gateway_connected" }
11
+ | { type: "gateway_disconnected"; reason: string }
12
+ | { type: "event"; event: string; payload: unknown }
13
+ | { type: "res"; id: string; ok: boolean; payload?: unknown; error?: { message?: string } };
14
+
15
+ /** Messages the relay server sends to the relay client. */
16
+ interface FromServer {
17
+ type: "cmd";
18
+ id?: string;
19
+ method: string;
20
+ params: unknown;
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Options
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export interface RelayManagerOptions {
28
+ relayServerUrl: string;
29
+ gatewayId: string;
30
+ relaySecret: string;
31
+ gatewayUrl: string;
32
+ gatewayToken?: string;
33
+ gatewayPassword?: string;
34
+ onConnected?: () => void;
35
+ onDisconnected?: () => void;
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Main entry point
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Connects to the cloud relay server and the local OpenClaw Gateway,
44
+ * then bridges messages between them indefinitely.
45
+ *
46
+ * The gateway client runs for as long as this relay connection is alive.
47
+ * Returns a Promise that resolves `true` (retry) when the relay server
48
+ * connection closes.
49
+ */
50
+ export async function runRelayManager(opts: RelayManagerOptions): Promise<boolean> {
51
+ const wsUrl = buildRelayUrl(opts.relayServerUrl, opts.gatewayId, opts.relaySecret);
52
+
53
+ return new Promise<boolean>((resolve) => {
54
+ let relayWs: WebSocket;
55
+ try {
56
+ relayWs = new WebSocket(wsUrl);
57
+ } catch (err) {
58
+ console.error("Failed to create relay WebSocket:", err);
59
+ resolve(true);
60
+ return;
61
+ }
62
+
63
+ let gatewayClient: OpenClawGatewayClient | null = null;
64
+
65
+ function send(msg: ToServer): void {
66
+ if (relayWs.readyState === WebSocket.OPEN) {
67
+ relayWs.send(JSON.stringify(msg));
68
+ }
69
+ }
70
+
71
+ relayWs.on("open", () => {
72
+ console.log(`Connected to relay server (gatewayId=${opts.gatewayId})`);
73
+ opts.onConnected?.();
74
+
75
+ // Start the persistent gateway connection as soon as we're connected
76
+ // to the relay server. Its lifetime is tied to this relay session.
77
+ gatewayClient = new OpenClawGatewayClient({
78
+ url: opts.gatewayUrl,
79
+ token: opts.gatewayToken,
80
+ password: opts.gatewayPassword,
81
+
82
+ onConnected: () => {
83
+ console.log("Gateway connected.");
84
+ send({ type: "gateway_connected" });
85
+ },
86
+
87
+ onDisconnected: (reason) => {
88
+ console.log(`Gateway disconnected: ${reason}`);
89
+ send({ type: "gateway_disconnected", reason });
90
+ },
91
+
92
+ onEvent: (event, payload) => {
93
+ send({ type: "event", event, payload });
94
+ },
95
+ });
96
+
97
+ gatewayClient.start();
98
+ });
99
+
100
+ relayWs.on("message", (raw) => {
101
+ let msg: FromServer;
102
+ try {
103
+ msg = JSON.parse(raw.toString()) as FromServer;
104
+ } catch {
105
+ return;
106
+ }
107
+
108
+ if (msg.type !== "cmd" || !msg.method) return;
109
+
110
+ const requestId = msg.id;
111
+ console.log(`[relay] cmd received method=${msg.method} id=${requestId ?? "(no-id)"}`);
112
+ gatewayClient
113
+ ?.request(msg.method, msg.params)
114
+ .then((result) => {
115
+ console.log(`[relay] cmd ok method=${msg.method} id=${requestId ?? "(no-id)"}`);
116
+ if (requestId) {
117
+ send({ type: "res", id: requestId, ok: true, payload: result });
118
+ }
119
+ })
120
+ .catch((err: unknown) => {
121
+ console.error(`[relay] cmd failed method=${msg.method} id=${requestId ?? "(no-id)"}: ${String(err)}`);
122
+ if (requestId) {
123
+ send({ type: "res", id: requestId, ok: false, error: { message: String(err) } });
124
+ }
125
+ });
126
+ });
127
+
128
+ relayWs.on("close", (code, reason) => {
129
+ console.log(`Relay connection closed: ${code} ${reason.toString()}`);
130
+ opts.onDisconnected?.();
131
+ gatewayClient?.stop();
132
+ gatewayClient = null;
133
+ // Code 4000 = server kicked us because another relay client took over.
134
+ // Stop retrying so the two instances don't bounce each other forever.
135
+ resolve(code !== 4000);
136
+ });
137
+
138
+ relayWs.on("error", (err) => {
139
+ console.error("Relay WebSocket error:", err.message);
140
+ // close event will follow
141
+ });
142
+ });
143
+ }
144
+
145
+ function buildRelayUrl(serverUrl: string, gatewayId: string, relaySecret: string): string {
146
+ const base = serverUrl.replace(/\/+$/, "").replace(/^http/, "ws");
147
+ return `${base}/relay/${gatewayId}?secret=${encodeURIComponent(relaySecret)}`;
148
+ }
package/test-chat.mjs ADDED
@@ -0,0 +1,64 @@
1
+ import { WebSocket } from "ws";
2
+ import { randomUUID } from "crypto";
3
+ import { readFileSync } from "fs";
4
+
5
+ const token = readFileSync("/tmp/test_token.txt", "utf8").trim();
6
+ const url = `ws://localhost:3000/gw/295c6252c69746124af48ebbe7001f11?token=${token}`;
7
+
8
+ const ws = new WebSocket(url);
9
+
10
+ ws.on("open", () => {
11
+ console.log("[ios] connected to relay server");
12
+
13
+ ws.send(JSON.stringify({ method: "sessions.reset", params: { key: "main" } }));
14
+ console.log("[ios] → sessions.reset { key: 'main' }");
15
+
16
+ setTimeout(() => {
17
+ ws.send(JSON.stringify({
18
+ method: "chat.send",
19
+ params: {
20
+ sessionKey: "main",
21
+ message: "hello, please reply with just one short sentence",
22
+ idempotencyKey: randomUUID(),
23
+ },
24
+ }));
25
+ console.log("[ios] → chat.send");
26
+ }, 800);
27
+ });
28
+
29
+ let lastText = "";
30
+ ws.on("message", (raw) => {
31
+ const msg = JSON.parse(raw.toString());
32
+ if (msg.type === "connected") { console.log("[ios] ← gateway online"); return; }
33
+ if (msg.type === "disconnected") { console.log("[gateway] ← disconnected:", msg.reason); return; }
34
+ if (msg.type === "event" && msg.event === "chat") {
35
+ const p = msg.payload;
36
+ if (p.state === "delta") {
37
+ const text = p.message?.content?.[0]?.text ?? "";
38
+ if (text !== lastText) {
39
+ process.stdout.write("\r[delta] " + text.slice(-80).padEnd(80));
40
+ lastText = text;
41
+ }
42
+ } else if (p.state === "final") {
43
+ const text = p.message?.content?.[0]?.text ?? "(no text)";
44
+ console.log("\n\n[FINAL RESPONSE]\n" + text);
45
+ ws.close();
46
+ process.exit(0);
47
+ } else if (p.state === "error") {
48
+ console.log("\n[ERROR]", p.errorMessage);
49
+ ws.close();
50
+ process.exit(1);
51
+ }
52
+ return;
53
+ }
54
+ if (msg.event !== "health" && msg.event !== "presence") {
55
+ console.log("[event]", JSON.stringify(msg).slice(0, 120));
56
+ }
57
+ });
58
+
59
+ ws.on("error", (e) => console.error("[ws error]", e.message));
60
+ setTimeout(() => {
61
+ console.log("\n[timeout after 60s]");
62
+ ws.close();
63
+ process.exit(1);
64
+ }, 60000);