@clawling/clawchat-plugin-openclaw 2026.5.12-28

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 (114) hide show
  1. package/INSTALL.md +64 -0
  2. package/README.md +227 -0
  3. package/dist/index.js +20 -0
  4. package/dist/setup-entry.js +3 -0
  5. package/dist/src/api-client.js +263 -0
  6. package/dist/src/api-types.js +17 -0
  7. package/dist/src/api-types.test-d.js +10 -0
  8. package/dist/src/buffered-stream.js +177 -0
  9. package/dist/src/channel.js +66 -0
  10. package/dist/src/channel.setup.js +119 -0
  11. package/dist/src/clawchat-memory.js +403 -0
  12. package/dist/src/clawchat-metadata.js +310 -0
  13. package/dist/src/client.js +35 -0
  14. package/dist/src/commands.js +35 -0
  15. package/dist/src/config.js +274 -0
  16. package/dist/src/group-message-coalescer.js +119 -0
  17. package/dist/src/inbound.js +170 -0
  18. package/dist/src/llm-context-debug.js +86 -0
  19. package/dist/src/login.runtime.js +204 -0
  20. package/dist/src/media-runtime.js +85 -0
  21. package/dist/src/message-mapper.js +146 -0
  22. package/dist/src/mock-transport.js +31 -0
  23. package/dist/src/outbound.js +628 -0
  24. package/dist/src/plugin-prompts.js +89 -0
  25. package/dist/src/profile-prompt.js +269 -0
  26. package/dist/src/profile-sync.js +110 -0
  27. package/dist/src/prompt-injection.js +25 -0
  28. package/dist/src/protocol-types.js +63 -0
  29. package/dist/src/protocol-types.typecheck.js +1 -0
  30. package/dist/src/protocol.js +33 -0
  31. package/dist/src/reply-dispatcher.js +422 -0
  32. package/dist/src/runtime.js +1254 -0
  33. package/dist/src/storage.js +525 -0
  34. package/dist/src/streaming.js +65 -0
  35. package/dist/src/terminal-send.js +36 -0
  36. package/dist/src/tools-schema.js +208 -0
  37. package/dist/src/tools.js +920 -0
  38. package/dist/src/ws-alignment.js +178 -0
  39. package/dist/src/ws-client.js +588 -0
  40. package/dist/src/ws-log.js +19 -0
  41. package/index.ts +24 -0
  42. package/openclaw.plugin.json +169 -0
  43. package/package.json +80 -0
  44. package/prompts/default-group-bio.md +19 -0
  45. package/prompts/default-owner-behavior.md +27 -0
  46. package/prompts/platform.md +13 -0
  47. package/setup-entry.ts +4 -0
  48. package/skills/clawchat/SKILL.md +91 -0
  49. package/src/api-client.test.ts +827 -0
  50. package/src/api-client.ts +414 -0
  51. package/src/api-types.ts +146 -0
  52. package/src/channel.outbound.test.ts +433 -0
  53. package/src/channel.setup.ts +145 -0
  54. package/src/channel.test.ts +262 -0
  55. package/src/channel.ts +81 -0
  56. package/src/clawchat-memory.test.ts +480 -0
  57. package/src/clawchat-memory.ts +533 -0
  58. package/src/clawchat-metadata.test.ts +477 -0
  59. package/src/clawchat-metadata.ts +429 -0
  60. package/src/client.test.ts +169 -0
  61. package/src/client.ts +56 -0
  62. package/src/commands.test.ts +39 -0
  63. package/src/commands.ts +41 -0
  64. package/src/config.test.ts +344 -0
  65. package/src/config.ts +404 -0
  66. package/src/group-message-coalescer.test.ts +237 -0
  67. package/src/group-message-coalescer.ts +171 -0
  68. package/src/inbound.test.ts +508 -0
  69. package/src/inbound.ts +278 -0
  70. package/src/llm-context-debug.test.ts +55 -0
  71. package/src/llm-context-debug.ts +139 -0
  72. package/src/login.runtime.test.ts +737 -0
  73. package/src/login.runtime.ts +277 -0
  74. package/src/manifest.test.ts +352 -0
  75. package/src/media-runtime.test.ts +207 -0
  76. package/src/media-runtime.ts +152 -0
  77. package/src/message-mapper.test.ts +201 -0
  78. package/src/message-mapper.ts +174 -0
  79. package/src/mock-transport.test.ts +35 -0
  80. package/src/mock-transport.ts +38 -0
  81. package/src/outbound.test.ts +1269 -0
  82. package/src/outbound.ts +803 -0
  83. package/src/plugin-entry.test.ts +38 -0
  84. package/src/plugin-prompts.test.ts +94 -0
  85. package/src/plugin-prompts.ts +107 -0
  86. package/src/profile-prompt.test.ts +274 -0
  87. package/src/profile-prompt.ts +351 -0
  88. package/src/profile-sync.test.ts +539 -0
  89. package/src/profile-sync.ts +191 -0
  90. package/src/prompt-injection.test.ts +39 -0
  91. package/src/prompt-injection.ts +45 -0
  92. package/src/protocol-types.test.ts +69 -0
  93. package/src/protocol-types.ts +296 -0
  94. package/src/protocol-types.typecheck.ts +89 -0
  95. package/src/protocol.test.ts +39 -0
  96. package/src/protocol.ts +42 -0
  97. package/src/reply-dispatcher.test.ts +1324 -0
  98. package/src/reply-dispatcher.ts +555 -0
  99. package/src/runtime.test.ts +4719 -0
  100. package/src/runtime.ts +1493 -0
  101. package/src/scripts.test.ts +85 -0
  102. package/src/storage.test.ts +560 -0
  103. package/src/storage.ts +807 -0
  104. package/src/terminal-send.test.ts +81 -0
  105. package/src/terminal-send.ts +56 -0
  106. package/src/tools-schema.ts +337 -0
  107. package/src/tools.test.ts +933 -0
  108. package/src/tools.ts +1185 -0
  109. package/src/ws-alignment.test.ts +103 -0
  110. package/src/ws-alignment.ts +275 -0
  111. package/src/ws-client.test.ts +1217 -0
  112. package/src/ws-client.ts +662 -0
  113. package/src/ws-log.test.ts +32 -0
  114. package/src/ws-log.ts +31 -0
@@ -0,0 +1,662 @@
1
+ import { EventEmitter } from "node:events";
2
+ import {
3
+ AckTimeoutError,
4
+ AuthError,
5
+ EVENT,
6
+ MessageSendError,
7
+ ProtocolError,
8
+ StateError,
9
+ TransportError,
10
+ isBusinessDispatchEvent,
11
+ type ConnState,
12
+ type ConnectPayload,
13
+ type EmptyPayload,
14
+ type Envelope,
15
+ type MessageAckPayload,
16
+ type MessageErrorPayload,
17
+ type Transport,
18
+ type TransportEvents,
19
+ type TransportState,
20
+ } from "./protocol-types.ts";
21
+
22
+ export interface WebSocketLike {
23
+ addEventListener(type: string, listener: (event?: unknown) => void): void;
24
+ send(data: string): void;
25
+ close(code?: number, reason?: string): void;
26
+ }
27
+
28
+ export type WebSocketConstructor = new (url: string) => WebSocketLike;
29
+
30
+ export function createWebSocketTransport(
31
+ WebSocketCtor: WebSocketConstructor = globalThis.WebSocket as WebSocketConstructor,
32
+ ): Transport {
33
+ let currentState: TransportState = "closed";
34
+ let socket: WebSocketLike | undefined;
35
+
36
+ return {
37
+ get state(): TransportState {
38
+ return currentState;
39
+ },
40
+
41
+ async connect(url: string, handlers: TransportEvents): Promise<void> {
42
+ if (!WebSocketCtor) {
43
+ throw new TransportError("global WebSocket is not available");
44
+ }
45
+ currentState = "connecting";
46
+ const activeSocket = new WebSocketCtor(url);
47
+ socket = activeSocket;
48
+ const isCurrentSocket = () => socket === activeSocket;
49
+ await new Promise<void>((resolve, reject) => {
50
+ let opened = false;
51
+ let settled = false;
52
+ const rejectBeforeOpen = (err: Error) => {
53
+ if (opened || settled) return;
54
+ settled = true;
55
+ reject(err);
56
+ };
57
+ activeSocket.addEventListener("open", () => {
58
+ if (!isCurrentSocket()) return;
59
+ opened = true;
60
+ settled = true;
61
+ currentState = "open";
62
+ handlers.onOpen();
63
+ resolve();
64
+ });
65
+ activeSocket.addEventListener("message", (event) => {
66
+ if (!isCurrentSocket()) return;
67
+ const data = (event as { data?: unknown } | undefined)?.data;
68
+ if (typeof data === "string" || Buffer.isBuffer(data)) {
69
+ handlers.onMessage(data);
70
+ return;
71
+ }
72
+ if (data instanceof ArrayBuffer) {
73
+ handlers.onMessage(Buffer.from(data));
74
+ return;
75
+ }
76
+ if (ArrayBuffer.isView(data)) {
77
+ handlers.onMessage(Buffer.from(data.buffer, data.byteOffset, data.byteLength));
78
+ return;
79
+ }
80
+ handlers.onMessage(String(data ?? ""));
81
+ });
82
+ activeSocket.addEventListener("close", (event) => {
83
+ if (!isCurrentSocket()) return;
84
+ currentState = "closed";
85
+ socket = undefined;
86
+ const close = event as { code?: unknown; reason?: unknown } | undefined;
87
+ const code = typeof close?.code === "number" ? close.code : 1006;
88
+ const reason = typeof close?.reason === "string" ? close.reason : "closed";
89
+ handlers.onClose(code, reason);
90
+ rejectBeforeOpen(new TransportError(reason));
91
+ });
92
+ activeSocket.addEventListener("error", (event) => {
93
+ if (!isCurrentSocket()) return;
94
+ const err = event instanceof Error ? event : new Error("websocket error");
95
+ handlers.onError(err);
96
+ rejectBeforeOpen(err);
97
+ });
98
+ });
99
+ },
100
+
101
+ send(data: string): void {
102
+ if (currentState !== "open" || !socket) {
103
+ throw new StateError(`cannot send while transport=${currentState}`);
104
+ }
105
+ socket.send(data);
106
+ },
107
+
108
+ close(code = 1000, reason = "client close"): void {
109
+ if (currentState === "closed") return;
110
+ socket?.close(code, reason);
111
+ },
112
+ };
113
+ }
114
+
115
+ export interface ClawChatClientOptions {
116
+ url: string;
117
+ token: string;
118
+ deviceId?: string;
119
+ transport?: Transport;
120
+ traceIdFactory?: () => string;
121
+ reconnect?: {
122
+ enabled?: boolean;
123
+ initialDelay?: number;
124
+ maxDelay?: number;
125
+ maxRetries?: number;
126
+ jitterRatio?: number;
127
+ };
128
+ heartbeat?: { enabled?: boolean; interval?: number; timeout?: number };
129
+ ack?: { timeout?: number; autoResendOnTimeout?: boolean };
130
+ }
131
+
132
+ export interface NormalizedClawChatClientOptions {
133
+ url: string;
134
+ token: string;
135
+ deviceId?: string;
136
+ transport: Transport;
137
+ traceIdFactory: () => string;
138
+ reconnect: Required<NonNullable<ClawChatClientOptions["reconnect"]>>;
139
+ heartbeat: Required<NonNullable<ClawChatClientOptions["heartbeat"]>>;
140
+ ack: Required<NonNullable<ClawChatClientOptions["ack"]>>;
141
+ }
142
+
143
+ type PendingAck = {
144
+ timer: ReturnType<typeof setTimeout>;
145
+ resolve: (env: Envelope<MessageAckPayload>) => void;
146
+ reject: (err: Error) => void;
147
+ };
148
+
149
+ export class ClawChatClient extends EventEmitter {
150
+ private currentState: ConnState = "idle";
151
+ private connectResolve?: () => void;
152
+ private connectReject?: (err: Error) => void;
153
+ private heartbeatTimer?: ReturnType<typeof setInterval>;
154
+ private pongTimer?: ReturnType<typeof setTimeout>;
155
+ private reconnectTimer?: ReturnType<typeof setTimeout>;
156
+ private reconnectAttempts = 0;
157
+ private closing = false;
158
+ private authFailed = false;
159
+ private expectedConnectTraceId?: string;
160
+ private readonly pending = new Map<string, PendingAck>();
161
+ private readonly handledMessageErrorTraces = new Set<string>();
162
+ private readonly sendQueue: string[] = [];
163
+
164
+ constructor(private readonly opts: NormalizedClawChatClientOptions) {
165
+ super();
166
+ }
167
+
168
+ get state(): ConnState {
169
+ return this.currentState;
170
+ }
171
+
172
+ get transportState(): TransportState {
173
+ return this.opts.transport.state;
174
+ }
175
+
176
+ nextTraceId(): string {
177
+ return this.opts.traceIdFactory();
178
+ }
179
+
180
+ hasPendingAckTrace(traceId: string): boolean {
181
+ return this.pending.has(traceId);
182
+ }
183
+
184
+ markMessageErrorHandled(traceId: string): void {
185
+ this.handledMessageErrorTraces.add(traceId);
186
+ }
187
+
188
+ async connect(): Promise<void> {
189
+ this.closing = false;
190
+ this.authFailed = false;
191
+ const ready = new Promise<void>((resolve, reject) => {
192
+ this.connectResolve = resolve;
193
+ this.connectReject = reject;
194
+ });
195
+ void this.openTransport();
196
+ return await ready;
197
+ }
198
+
199
+ close(): void {
200
+ this.closing = true;
201
+ this.clearTimers();
202
+ this.rejectPending(new StateError("client closed"));
203
+ this.sendQueue.length = 0;
204
+ if (this.opts.transport.state === "closed") {
205
+ this.transition("disconnected");
206
+ return;
207
+ }
208
+ this.opts.transport.close(1000, "client close");
209
+ }
210
+
211
+ typing(chatId: string, isTyping = true): void {
212
+ this.sendRawEnvelope({
213
+ version: "2",
214
+ event: EVENT.TYPING_UPDATE,
215
+ trace_id: this.nextTraceId(),
216
+ emitted_at: Date.now(),
217
+ chat_id: chatId,
218
+ payload: { is_typing: isTyping },
219
+ });
220
+ }
221
+
222
+ emitRaw(event: string, payload: object, routing?: { chat_id?: string }): void {
223
+ this.sendRawEnvelope({
224
+ version: "2",
225
+ event,
226
+ trace_id: this.nextTraceId(),
227
+ emitted_at: Date.now(),
228
+ ...(routing?.chat_id ? { chat_id: routing.chat_id } : {}),
229
+ payload,
230
+ });
231
+ }
232
+
233
+ sendWire(wire: string, options: { bypassReconnectQueue?: boolean } = {}): void {
234
+ if (!options.bypassReconnectQueue && this.shouldQueueOutboundWire()) {
235
+ this.sendQueue.push(wire);
236
+ return;
237
+ }
238
+ if (this.opts.transport.state !== "open") {
239
+ throw new StateError(`cannot send while transport=${this.opts.transport.state}`);
240
+ }
241
+ this.opts.transport.send(wire);
242
+ }
243
+
244
+ sendRawEnvelope(env: Envelope): void {
245
+ this.sendWire(JSON.stringify(env), { bypassReconnectQueue: env.event === EVENT.CONNECT });
246
+ }
247
+
248
+ async sendAckableEnvelope(params: {
249
+ eventName: "message.send" | "message.reply";
250
+ chatId: string;
251
+ payload: object;
252
+ }): Promise<Envelope<MessageAckPayload>> {
253
+ const traceId = this.nextTraceId();
254
+ const env: Envelope = {
255
+ version: "2",
256
+ event: params.eventName,
257
+ trace_id: traceId,
258
+ emitted_at: Date.now(),
259
+ chat_id: params.chatId,
260
+ payload: params.payload,
261
+ };
262
+ return await new Promise<Envelope<MessageAckPayload>>((resolve, reject) => {
263
+ const entry: PendingAck = {
264
+ timer: setTimeout(() => {}, 0),
265
+ resolve,
266
+ reject,
267
+ };
268
+ const armTimer = () => {
269
+ entry.timer = setTimeout(() => {
270
+ if (this.opts.ack.autoResendOnTimeout && this.opts.transport.state === "open") {
271
+ try {
272
+ this.sendRawEnvelope(env);
273
+ armTimer();
274
+ return;
275
+ } catch (err) {
276
+ this.pending.delete(traceId);
277
+ reject(err instanceof Error ? err : new Error(String(err)));
278
+ return;
279
+ }
280
+ }
281
+ this.pending.delete(traceId);
282
+ reject(new AckTimeoutError(traceId, this.opts.ack.timeout));
283
+ }, this.opts.ack.timeout);
284
+ };
285
+ armTimer();
286
+ this.pending.set(traceId, entry);
287
+ try {
288
+ this.sendRawEnvelope(env);
289
+ } catch (err) {
290
+ clearTimeout(entry.timer);
291
+ this.pending.delete(traceId);
292
+ reject(err instanceof Error ? err : new Error(String(err)));
293
+ }
294
+ });
295
+ }
296
+
297
+ private async openTransport(): Promise<void> {
298
+ this.transition("connecting");
299
+ try {
300
+ await this.opts.transport.connect(this.opts.url, {
301
+ onOpen: () => this.transition("challenging"),
302
+ onMessage: (data) => this.handleWireMessage(data),
303
+ onClose: (code, reason) => this.handleClose(code, reason),
304
+ onError: (err) => this.emitError(new TransportError(err.message)),
305
+ });
306
+ } catch (err) {
307
+ const error = err instanceof Error ? new TransportError(err.message) : new TransportError(String(err));
308
+ this.emitError(error);
309
+ if (this.closing || this.authFailed || !this.opts.reconnect.enabled) {
310
+ this.transition("disconnected");
311
+ this.connectReject?.(error);
312
+ return;
313
+ }
314
+ this.scheduleReconnect(error.message);
315
+ }
316
+ }
317
+
318
+ private transition(next: ConnState): void {
319
+ const from = this.currentState;
320
+ if (from === next) return;
321
+ this.currentState = next;
322
+ this.emit("state", { from, to: next });
323
+ }
324
+
325
+ private handleWireMessage(data: string | Buffer): void {
326
+ let env: Envelope;
327
+ try {
328
+ const parsed = JSON.parse(String(data)) as unknown;
329
+ if (!parsed || typeof parsed !== "object") {
330
+ throw new ProtocolError("invalid envelope", parsed);
331
+ }
332
+ env = parsed as Envelope;
333
+ if (
334
+ env.version !== "2" ||
335
+ typeof env.event !== "string" ||
336
+ typeof env.trace_id !== "string" ||
337
+ typeof env.emitted_at !== "number" ||
338
+ !Object.prototype.hasOwnProperty.call(env, "payload")
339
+ ) {
340
+ throw new ProtocolError("invalid envelope", env);
341
+ }
342
+ } catch (err) {
343
+ this.emitError(err instanceof Error ? err : new ProtocolError(String(err)));
344
+ if (this.isHandshaking()) {
345
+ this.failHandshake(
346
+ err instanceof ProtocolError ? err : new ProtocolError(err instanceof Error ? err.message : String(err)),
347
+ 4002,
348
+ "protocol error",
349
+ );
350
+ }
351
+ return;
352
+ }
353
+ this.emit("raw", env);
354
+ this.dispatchInbound(env);
355
+ }
356
+
357
+ private dispatchInbound(env: Envelope): void {
358
+ if (env.event === EVENT.CONNECT_CHALLENGE) return this.onChallenge(env);
359
+ if (env.event === EVENT.HELLO_OK) return this.onHelloOk(env);
360
+ if (env.event === EVENT.HELLO_FAIL) return this.onHelloFail(env);
361
+ if (env.event === EVENT.PING) return this.onPing(env);
362
+ if (env.event === EVENT.PONG) return this.onPong();
363
+ if (env.event === EVENT.MESSAGE_ACK) return this.onAck(env as Envelope<MessageAckPayload>);
364
+ if (env.event === EVENT.MESSAGE_ERROR) return this.onMessageError(env as Envelope<MessageErrorPayload>);
365
+ if (isBusinessDispatchEvent(env.event)) this.emit("message", env);
366
+ if (env.event === EVENT.MESSAGE_CREATED) this.emit("message:created", env);
367
+ if (env.event === EVENT.MESSAGE_ADD) this.emit("message:add", env);
368
+ if (env.event === EVENT.MESSAGE_DONE) this.emit("message:done", env);
369
+ if (env.event === EVENT.MESSAGE_FAILED) this.emit("message:failed", env);
370
+ if (env.event === EVENT.TYPING_UPDATE) this.emit("typing", env);
371
+ if (env.event === EVENT.CHAT_METADATA_INVALIDATED) this.emit("metadata:invalidated", env);
372
+ if (env.event === EVENT.OFFLINE_DONE) this.emit("offline:done");
373
+ }
374
+
375
+ private onChallenge(env: Envelope): void {
376
+ if (this.currentState !== "challenging") {
377
+ if (this.currentState === "connected" || this.currentState === "reconnecting") return;
378
+ this.failHandshake(new ProtocolError("unexpected challenge", env), 4002, "protocol error");
379
+ return;
380
+ }
381
+ const payload = env.payload && typeof env.payload === "object" ? env.payload as { nonce?: unknown } : {};
382
+ const nonce = payload.nonce;
383
+ if (typeof nonce !== "string" || !nonce) {
384
+ this.failHandshake(new ProtocolError("missing challenge nonce", env), 4002, "protocol error");
385
+ return;
386
+ }
387
+ this.transition("authenticating");
388
+ const connectPayload: ConnectPayload = {
389
+ token: this.opts.token,
390
+ nonce,
391
+ ...(this.opts.deviceId ? { device_id: this.opts.deviceId } : {}),
392
+ capabilities: { multi_device: true, device_replay: true, chat_meta_events: true },
393
+ };
394
+ const traceId = this.nextTraceId();
395
+ this.expectedConnectTraceId = traceId;
396
+ try {
397
+ this.sendRawEnvelope({
398
+ version: "2",
399
+ event: EVENT.CONNECT,
400
+ trace_id: traceId,
401
+ emitted_at: Date.now(),
402
+ payload: connectPayload,
403
+ });
404
+ } catch (err) {
405
+ this.failHandshake(err instanceof Error ? err : new TransportError(String(err)), 4003, "connect send failed");
406
+ }
407
+ }
408
+
409
+ private onHelloOk(env: Envelope): void {
410
+ const helloError = this.validateHelloEnvelope(env, "hello-ok");
411
+ if (helloError) {
412
+ this.failHandshake(helloError, 4002, "protocol error");
413
+ return;
414
+ }
415
+ this.expectedConnectTraceId = undefined;
416
+ this.emit("hello:ok", env);
417
+ this.transition("connected");
418
+ this.reconnectAttempts = 0;
419
+ this.startHeartbeat();
420
+ try {
421
+ this.flushSendQueue();
422
+ } catch (err) {
423
+ this.emitError(err instanceof Error ? err : new TransportError(String(err)));
424
+ this.opts.transport.close(4000, "queued send failed");
425
+ return;
426
+ }
427
+ this.connectResolve?.();
428
+ this.connectResolve = undefined;
429
+ this.connectReject = undefined;
430
+ }
431
+
432
+ private onHelloFail(env: Envelope): void {
433
+ const helloError = this.validateHelloEnvelope(env, "hello-fail");
434
+ if (helloError) {
435
+ this.failHandshake(helloError, 4002, "protocol error");
436
+ return;
437
+ }
438
+ const payload = env.payload && typeof env.payload === "object" ? env.payload as { reason?: unknown } : {};
439
+ const reason = payload.reason;
440
+ if (typeof reason !== "string" || !reason) {
441
+ this.failHandshake(new ProtocolError("invalid hello-fail payload", env), 4002, "protocol error");
442
+ return;
443
+ }
444
+ const err = new AuthError(typeof reason === "string" ? reason : "authentication failed");
445
+ this.authFailed = true;
446
+ this.expectedConnectTraceId = undefined;
447
+ this.sendQueue.length = 0;
448
+ this.clearTimers();
449
+ this.rejectPending(err);
450
+ this.connectReject?.(err);
451
+ this.connectResolve = undefined;
452
+ this.connectReject = undefined;
453
+ this.emitError(err);
454
+ this.transition("disconnected");
455
+ if (this.opts.transport.state !== "closed") {
456
+ this.opts.transport.close(4001, "auth failed");
457
+ }
458
+ }
459
+
460
+ private onPing(env: Envelope): void {
461
+ this.sendRawEnvelope({
462
+ version: "2",
463
+ event: EVENT.PONG,
464
+ trace_id: env.trace_id,
465
+ emitted_at: env.emitted_at,
466
+ payload: {} satisfies EmptyPayload,
467
+ });
468
+ }
469
+
470
+ private onPong(): void {
471
+ if (this.pongTimer) clearTimeout(this.pongTimer);
472
+ this.pongTimer = undefined;
473
+ }
474
+
475
+ private onAck(env: Envelope<MessageAckPayload>): void {
476
+ const entry = this.pending.get(env.trace_id);
477
+ if (!entry) return;
478
+ clearTimeout(entry.timer);
479
+ this.pending.delete(env.trace_id);
480
+ entry.resolve(env);
481
+ }
482
+
483
+ private onMessageError(env: Envelope<MessageErrorPayload>): void {
484
+ const entry = this.pending.get(env.trace_id);
485
+ if (!entry) {
486
+ if (this.handledMessageErrorTraces.delete(env.trace_id)) return;
487
+ console.warn(`clawchat.ws unmatched message.error trace_id=${env.trace_id} chat_id=${env.chat_id ?? "-"}`);
488
+ return;
489
+ }
490
+ clearTimeout(entry.timer);
491
+ this.pending.delete(env.trace_id);
492
+ const payload = env.payload && typeof env.payload === "object" ? env.payload : undefined;
493
+ const code = typeof payload?.code === "string" && payload.code ? payload.code : "unknown";
494
+ const message = typeof payload?.message === "string" && payload.message ? payload.message : "message send failed";
495
+ entry.reject(new MessageSendError(env.trace_id, code, message, env.chat_id));
496
+ }
497
+
498
+ private startHeartbeat(): void {
499
+ if (!this.opts.heartbeat.enabled) return;
500
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
501
+ this.heartbeatTimer = setInterval(() => {
502
+ try {
503
+ this.sendRawEnvelope({
504
+ version: "2",
505
+ event: EVENT.PING,
506
+ trace_id: this.nextTraceId(),
507
+ emitted_at: Date.now(),
508
+ payload: {} satisfies EmptyPayload,
509
+ });
510
+ } catch {
511
+ this.opts.transport.close(4000, "heartbeat send failed");
512
+ return;
513
+ }
514
+ if (this.pongTimer) clearTimeout(this.pongTimer);
515
+ this.pongTimer = setTimeout(
516
+ () => this.opts.transport.close(4000, "heartbeat timeout"),
517
+ this.opts.heartbeat.timeout,
518
+ );
519
+ }, this.opts.heartbeat.interval);
520
+ }
521
+
522
+ private handleClose(code: number, reason: string): void {
523
+ if (this.currentState === "reconnecting" && this.reconnectTimer) {
524
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
525
+ if (this.pongTimer) clearTimeout(this.pongTimer);
526
+ this.heartbeatTimer = undefined;
527
+ this.pongTimer = undefined;
528
+ this.emit("close", { code, reason });
529
+ return;
530
+ }
531
+ this.clearTimers();
532
+ this.emit("close", { code, reason });
533
+ const closeError = new TransportError(reason || "connection closed");
534
+ if (!this.closing) this.rejectPending(closeError);
535
+ if (this.closing || this.authFailed || !this.opts.reconnect.enabled) {
536
+ this.transition("disconnected");
537
+ this.connectReject?.(closeError);
538
+ return;
539
+ }
540
+ this.scheduleReconnect(reason || `close ${code}`);
541
+ }
542
+
543
+ private failHandshake(err: Error, code: number, reason: string): void {
544
+ this.clearTimers();
545
+ this.rejectPending(err);
546
+ this.expectedConnectTraceId = undefined;
547
+ this.sendQueue.length = 0;
548
+ this.connectReject?.(err);
549
+ this.connectResolve = undefined;
550
+ this.connectReject = undefined;
551
+ this.emitError(err);
552
+ this.transition("disconnected");
553
+ if (this.opts.transport.state !== "closed") {
554
+ this.closing = true;
555
+ this.opts.transport.close(code, reason);
556
+ }
557
+ }
558
+
559
+ private scheduleReconnect(reason: string): void {
560
+ if (this.reconnectTimer) return;
561
+ if (this.reconnectAttempts >= this.opts.reconnect.maxRetries) {
562
+ this.sendQueue.length = 0;
563
+ this.transition("disconnected");
564
+ this.connectReject?.(new TransportError(reason));
565
+ return;
566
+ }
567
+ this.reconnectAttempts += 1;
568
+ const baseDelay = Math.min(
569
+ this.opts.reconnect.maxDelay,
570
+ this.opts.reconnect.initialDelay * 2 ** Math.max(0, this.reconnectAttempts - 1),
571
+ );
572
+ const jitter = baseDelay * this.opts.reconnect.jitterRatio * Math.random();
573
+ const delay = Math.round(baseDelay + jitter);
574
+ this.transition("reconnecting");
575
+ this.emit("reconnect:scheduled", { reason, delay });
576
+ this.reconnectTimer = setTimeout(() => {
577
+ this.reconnectTimer = undefined;
578
+ void this.openTransport();
579
+ }, delay);
580
+ }
581
+
582
+ private clearTimers(): void {
583
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
584
+ if (this.pongTimer) clearTimeout(this.pongTimer);
585
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
586
+ this.heartbeatTimer = undefined;
587
+ this.pongTimer = undefined;
588
+ this.reconnectTimer = undefined;
589
+ }
590
+
591
+ private rejectPending(err: Error): void {
592
+ for (const [traceId, entry] of this.pending) {
593
+ clearTimeout(entry.timer);
594
+ this.pending.delete(traceId);
595
+ entry.reject(err);
596
+ }
597
+ }
598
+
599
+ private emitError(err: Error): void {
600
+ if (this.listenerCount("error") > 0) this.emit("error", err);
601
+ }
602
+
603
+ private shouldQueueOutboundWire(): boolean {
604
+ if (!this.opts.reconnect.enabled || this.closing || this.authFailed) return false;
605
+ if (this.currentState === "reconnecting") return true;
606
+ return this.reconnectAttempts > 0 && this.isHandshaking();
607
+ }
608
+
609
+ private flushSendQueue(): void {
610
+ while (this.sendQueue.length > 0) {
611
+ const wire = this.sendQueue.shift()!;
612
+ try {
613
+ this.sendWire(wire);
614
+ } catch (err) {
615
+ this.sendQueue.unshift(wire);
616
+ throw err;
617
+ }
618
+ }
619
+ }
620
+
621
+ private isHandshaking(): boolean {
622
+ return this.currentState === "connecting" || this.currentState === "challenging" || this.currentState === "authenticating";
623
+ }
624
+
625
+ private validateHelloEnvelope(env: Envelope, eventName: "hello-ok" | "hello-fail"): ProtocolError | null {
626
+ if (this.currentState !== "authenticating" || env.trace_id !== this.expectedConnectTraceId) {
627
+ return new ProtocolError(`unexpected ${eventName}`, env);
628
+ }
629
+ if (!env.payload || typeof env.payload !== "object") {
630
+ return new ProtocolError(`invalid ${eventName} payload`, env);
631
+ }
632
+ return null;
633
+ }
634
+ }
635
+
636
+ export function createClawChatClient(options: ClawChatClientOptions): ClawChatClient {
637
+ return new ClawChatClient({
638
+ ...options,
639
+ transport: options.transport ?? createWebSocketTransport(),
640
+ traceIdFactory:
641
+ options.traceIdFactory ??
642
+ (() => `trace-${Date.now()}-${Math.random().toString(36).slice(2)}`),
643
+ reconnect: {
644
+ enabled: options.reconnect?.enabled ?? true,
645
+ initialDelay: options.reconnect?.initialDelay ?? 500,
646
+ maxDelay: options.reconnect?.maxDelay ?? 15000,
647
+ maxRetries: options.reconnect?.maxRetries ?? Number.POSITIVE_INFINITY,
648
+ jitterRatio: options.reconnect?.jitterRatio ?? 0.3,
649
+ },
650
+ heartbeat: {
651
+ enabled: options.heartbeat?.enabled ?? true,
652
+ interval: options.heartbeat?.interval ?? 20000,
653
+ timeout: options.heartbeat?.timeout ?? 10000,
654
+ },
655
+ ack: {
656
+ timeout: options.ack?.timeout ?? 15000,
657
+ autoResendOnTimeout: options.ack?.autoResendOnTimeout ?? false,
658
+ },
659
+ });
660
+ }
661
+
662
+ export type ClawlingChatClient = ClawChatClient;
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatWsLog, optionalField } from "./ws-log.ts";
3
+
4
+ describe("clawchat ws log formatter", () => {
5
+ it("renders fixed field order and optional placeholders", () => {
6
+ expect(
7
+ formatWsLog({
8
+ event: "auth_failed",
9
+ accountId: "default",
10
+ attempt: 2,
11
+ reconnectCount: 1,
12
+ state: "auth_failed",
13
+ action: "stop_reconnect",
14
+ fields: [
15
+ ["trace_id", "trace-1"],
16
+ ["reason", ""],
17
+ ],
18
+ }),
19
+ ).toBe(
20
+ "clawchat.ws event=auth_failed account_id=default attempt=2 reconnect_count=1 state=auth_failed action=stop_reconnect trace_id=trace-1 reason=-",
21
+ );
22
+ });
23
+
24
+ it("normalizes absent values", () => {
25
+ expect(optionalField(undefined)).toBe("-");
26
+ expect(optionalField(null)).toBe("-");
27
+ expect(optionalField("")).toBe("-");
28
+ expect(optionalField("hello")).toBe("hello");
29
+ expect(optionalField(0)).toBe("0");
30
+ expect(optionalField(true)).toBe("true");
31
+ });
32
+ });