@clawroom/sdk 0.5.2 → 0.5.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawroom/sdk",
3
- "version": "0.5.2",
3
+ "version": "0.5.19",
4
4
  "description": "ClawRoom SDK — polling client and protocol types for connecting any agent to ClawRoom",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/client.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type {
2
2
  AgentChatProfile,
3
+ AgentResultFile,
3
4
  AgentMessage,
5
+ AgentWorkRef,
4
6
  ServerTask,
5
7
  ServerChatMessage,
6
8
  } from "./protocol.js";
@@ -13,11 +15,14 @@ const DEFAULT_AGENT_CHAT_PROFILE: AgentChatProfile = {
13
15
  role: "No role defined",
14
16
  systemPrompt: "No system prompt configured",
15
17
  memory: "No memory recorded yet",
16
- focus: "No focus items",
18
+ continuityPacket: "No continuity packet available yet",
17
19
  };
18
20
 
19
21
  export type TaskCallback = (task: ServerTask) => void;
20
22
  export type ChatCallback = (messages: ServerChatMessage[]) => void;
23
+ export type ConnectedCallback = (agentId: string) => void;
24
+ export type DisconnectedCallback = () => void;
25
+ export type Unsubscribe = () => void;
21
26
 
22
27
  export type ClawroomClientOptions = {
23
28
  endpoint?: string;
@@ -33,6 +38,17 @@ export type ClawroomClientOptions = {
33
38
  };
34
39
  };
35
40
 
41
+ type AgentHeartbeatResponse = {
42
+ ok?: boolean;
43
+ agentId?: string;
44
+ };
45
+
46
+ type AgentPollResponse = {
47
+ agentId?: string;
48
+ task: ServerTask | null;
49
+ chat: ServerChatMessage[] | null;
50
+ };
51
+
36
52
  /**
37
53
  * ClawRoom SDK client.
38
54
  * WebSocket primary for real-time push, HTTP polling as fallback.
@@ -40,13 +56,19 @@ export type ClawroomClientOptions = {
40
56
  export class ClawroomClient {
41
57
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
42
58
  private pollTimer: ReturnType<typeof setInterval> | null = null;
59
+ private heartbeatInFlight = false;
60
+ private pollInFlight = false;
43
61
  protected stopped = false;
44
62
  protected readonly httpBase: string;
45
63
  protected readonly options: ClawroomClientOptions;
46
64
  protected taskCallbacks: TaskCallback[] = [];
47
65
  protected chatCallbacks: ChatCallback[] = [];
66
+ private connectedCallbacks: ConnectedCallback[] = [];
67
+ private disconnectedCallbacks: DisconnectedCallback[] = [];
48
68
  private wsTransport: WsTransport | null = null;
49
69
  private recentChatIds = new Set<string>();
70
+ private connectedAgentId: string | null = null;
71
+ private isConnectedValue = false;
50
72
 
51
73
  constructor(options: ClawroomClientOptions) {
52
74
  this.options = options;
@@ -58,12 +80,26 @@ export class ClawroomClient {
58
80
  return this.httpBase.replace(/\/api\/agents$/, "").replace(/^http/, "ws") + "/api/ws";
59
81
  }
60
82
 
83
+ get connected(): boolean {
84
+ return this.isConnectedValue;
85
+ }
86
+
87
+ get agentId(): string | null {
88
+ return this.connectedAgentId;
89
+ }
90
+
91
+ get isAlive(): boolean {
92
+ return !this.stopped;
93
+ }
94
+
61
95
  connect(): void {
62
96
  this.stopped = false;
63
97
  this.startHeartbeat();
64
98
  void this.register();
65
99
 
66
100
  // WebSocket transport
101
+ this.wsTransport?.disconnect();
102
+ this.wsTransport = null;
67
103
  this.wsTransport = new WsTransport({
68
104
  url: this.wsUrl,
69
105
  token: this.options.token,
@@ -72,29 +108,34 @@ export class ClawroomClient {
72
108
  onDisconnected: () => { this.options.log?.info?.("[clawroom] WebSocket disconnected"); },
73
109
  onMessage: (msg) => {
74
110
  if (msg.type === "task" && msg.task) {
75
- for (const cb of this.taskCallbacks) cb(msg.task);
111
+ const task = msg.task as ServerTask;
112
+ void this.dispatchTask(task);
76
113
  }
77
114
  if (msg.type === "chat" && Array.isArray(msg.messages)) {
78
- const fresh = msg.messages.filter((m: ServerChatMessage) => this.rememberChat(m.messageId));
115
+ const fresh = msg.messages.filter((m: ServerChatMessage) => this.rememberChat(m.workId ?? m.messageId));
79
116
  if (fresh.length > 0) {
80
117
  for (const cb of this.chatCallbacks) cb(fresh);
81
- this.ackChatBestEffort(fresh.map((m: ServerChatMessage) => m.messageId));
82
118
  }
83
119
  }
84
- if (msg.type === "message" && msg.message) {
85
- const agentProfile = msg.agentProfile ?? msg.message.agentProfile ?? DEFAULT_AGENT_CHAT_PROFILE;
120
+ const message = getRecord(msg.message);
121
+ if (msg.type === "message" && message) {
122
+ const agentProfile = resolveAgentChatProfile(msg.agentProfile, message.agentProfile);
86
123
  const delivered = [{
87
- messageId: msg.message.id ?? msg.messageId,
88
- channelId: msg.message.channelId ?? "",
89
- content: msg.message.content ?? "",
90
- context: Array.isArray(msg.context) ? msg.context : [],
91
- isMention: msg.isMention ?? false,
124
+ workId: getString(message.workId),
125
+ leaseToken: getString(message.leaseToken),
126
+ messageId: getString(message.id, getString(msg.messageId)),
127
+ channelId: getString(message.channelId),
128
+ content: getString(message.content),
129
+ attachments: getAttachments(msg.attachments) ?? getAttachments(message.attachments),
130
+ context: getContext(msg.context),
131
+ isMention: typeof msg.isMention === "boolean" ? msg.isMention : false,
132
+ wakeReason: getWakeReason(msg.wakeReason) ?? getWakeReason(message.wakeReason),
133
+ triggerReason: getOptionalString(msg.triggerReason) ?? getOptionalString(message.triggerReason),
92
134
  agentProfile,
93
135
  }] as ServerChatMessage[];
94
- const fresh = delivered.filter((m) => this.rememberChat(m.messageId));
136
+ const fresh = delivered.filter((m) => this.rememberChat(m.workId ?? m.messageId));
95
137
  if (fresh.length > 0) {
96
138
  for (const cb of this.chatCallbacks) cb(fresh);
97
- this.ackChatBestEffort(fresh.map((m) => m.messageId));
98
139
  }
99
140
  }
100
141
  },
@@ -117,14 +158,84 @@ export class ClawroomClient {
117
158
  this.stopPolling();
118
159
  this.wsTransport?.disconnect();
119
160
  this.wsTransport = null;
161
+ this.recentChatIds.clear();
162
+ this.markDisconnected();
163
+ this.connectedAgentId = null;
164
+ }
165
+
166
+ async send(message: AgentMessage): Promise<void> {
167
+ await this.sendViaHttp(message);
168
+ }
169
+
170
+ onTask(cb: TaskCallback): Unsubscribe {
171
+ this.taskCallbacks.push(cb);
172
+ return () => this.offTask(cb);
173
+ }
174
+
175
+ offTask(cb: TaskCallback): void {
176
+ removeCallback(this.taskCallbacks, cb);
177
+ }
178
+
179
+ onChatMessage(cb: ChatCallback): Unsubscribe {
180
+ this.chatCallbacks.push(cb);
181
+ return () => this.offChatMessage(cb);
182
+ }
183
+
184
+ offChatMessage(cb: ChatCallback): void {
185
+ removeCallback(this.chatCallbacks, cb);
186
+ }
187
+
188
+ onConnected(cb: ConnectedCallback): Unsubscribe {
189
+ this.connectedCallbacks.push(cb);
190
+ return () => this.offConnected(cb);
191
+ }
192
+
193
+ offConnected(cb: ConnectedCallback): void {
194
+ removeCallback(this.connectedCallbacks, cb);
195
+ }
196
+
197
+ onDisconnected(cb: DisconnectedCallback): Unsubscribe {
198
+ this.disconnectedCallbacks.push(cb);
199
+ return () => this.offDisconnected(cb);
200
+ }
201
+
202
+ offDisconnected(cb: DisconnectedCallback): void {
203
+ removeCallback(this.disconnectedCallbacks, cb);
204
+ }
205
+
206
+ async sendComplete(taskId: string, output: string, attachments?: AgentResultFile[], workRef?: AgentWorkRef): Promise<void> {
207
+ await this.send({ type: "agent.complete", taskId, output, attachments, workRef });
208
+ }
209
+
210
+ async sendFail(taskId: string, reason: string, workRef?: AgentWorkRef): Promise<void> {
211
+ await this.send({ type: "agent.fail", taskId, reason, workRef });
212
+ }
213
+
214
+ async sendProgress(taskId: string, message: string, workRef?: AgentWorkRef): Promise<void> {
215
+ await this.send({ type: "agent.progress", taskId, message, workRef });
120
216
  }
121
217
 
122
- send(message: AgentMessage): void {
123
- this.sendViaHttp(message).catch(() => {});
218
+ async sendChatReply(channelId: string, content: string, replyTo?: string, workRefs?: AgentWorkRef[]): Promise<void> {
219
+ await this.send({ type: "agent.chat.reply", channelId, content, replyTo, workRefs });
124
220
  }
125
221
 
126
- onTask(cb: TaskCallback): void { this.taskCallbacks.push(cb); }
127
- onChatMessage(cb: ChatCallback): void { this.chatCallbacks.push(cb); }
222
+ async sendTyping(channelId: string): Promise<void> {
223
+ await this.send({ type: "agent.typing", channelId });
224
+ }
225
+
226
+ async sendReflection(reflection: {
227
+ scope: "chat" | "task";
228
+ status: string;
229
+ summary: string;
230
+ channelId?: string | null;
231
+ taskId?: string | null;
232
+ messageId?: string | null;
233
+ toolsUsed?: string[];
234
+ responseExcerpt?: string | null;
235
+ detail?: Record<string, unknown>;
236
+ }): Promise<void> {
237
+ await this.httpRequest("POST", "/reflections", reflection);
238
+ }
128
239
 
129
240
  // ── Heartbeat ─────────────────────────────────────────
130
241
 
@@ -144,54 +255,73 @@ export class ClawroomClient {
144
255
  }
145
256
 
146
257
  protected async register(): Promise<void> {
258
+ if (this.heartbeatInFlight) return;
259
+ this.heartbeatInFlight = true;
147
260
  try {
148
- await this.httpRequest("POST", "/heartbeat", {
261
+ const response = await this.httpRequest("POST", "/heartbeat", {
149
262
  deviceId: this.options.deviceId,
150
263
  skills: this.options.skills,
151
264
  kind: this.options.kind ?? "openclaw",
152
- });
153
- this.onPollSuccess(undefined);
265
+ }) as AgentHeartbeatResponse;
266
+ this.onPollSuccess(response.agentId);
154
267
  } catch (err) {
155
268
  this.options.log?.warn?.(`[clawroom] heartbeat error: ${err}`);
156
- this.onPollError(err);
269
+ if (!this.wsTransport?.connected) {
270
+ this.onPollError(err);
271
+ }
272
+ } finally {
273
+ this.heartbeatInFlight = false;
157
274
  }
158
275
  }
159
276
 
160
277
  protected async pollTick(): Promise<void> {
161
- if (this.stopped) return;
278
+ if (this.stopped || this.pollInFlight) return;
279
+ this.pollInFlight = true;
162
280
  try {
163
- const res = await this.httpRequest("POST", "/poll", {});
281
+ const res = await this.httpRequest("POST", "/poll", {}) as AgentPollResponse;
164
282
  this.onPollSuccess(res?.agentId);
165
283
  if (res.task) {
166
- this.options.log?.info?.(`[clawroom] received task ${res.task.taskId}: ${res.task.title}`);
167
- for (const cb of this.taskCallbacks) cb(res.task);
284
+ await this.dispatchTask(res.task);
168
285
  }
169
286
  if (res.chat && Array.isArray(res.chat) && res.chat.length > 0) {
170
- const fresh = res.chat.filter((m: ServerChatMessage) => this.rememberChat(m.messageId));
287
+ const fresh = res.chat.filter((m: ServerChatMessage) => this.rememberChat(m.workId ?? m.messageId));
171
288
  if (fresh.length > 0) {
172
289
  this.options.log?.info?.(`[clawroom] received ${fresh.length} chat mention(s)`);
173
290
  for (const cb of this.chatCallbacks) cb(fresh);
174
- this.ackChatBestEffort(fresh.map((m: ServerChatMessage) => m.messageId));
175
291
  }
176
292
  }
177
293
  } catch (err) {
178
294
  this.options.log?.warn?.(`[clawroom] poll error: ${err}`);
179
295
  this.onPollError(err);
296
+ } finally {
297
+ this.pollInFlight = false;
180
298
  }
181
299
  }
182
300
 
183
- protected onPollSuccess(_agentId: string | undefined): void {}
184
- protected onPollError(_err: unknown): void {}
301
+ protected onPollSuccess(agentId: string | undefined): void {
302
+ this.markConnected(agentId);
303
+ }
304
+
305
+ protected onPollError(err: unknown): void {
306
+ void err;
307
+ if (this.wsTransport?.connected) return;
308
+ this.markDisconnected();
309
+ }
185
310
 
186
311
  // ── HTTP ──────────────────────────────────────────────
187
312
 
188
313
  private async sendViaHttp(message: AgentMessage): Promise<void> {
189
314
  switch (message.type) {
190
- case "agent.complete": await this.httpRequest("POST", "/complete", { taskId: message.taskId, output: message.output, attachments: message.attachments }); break;
191
- case "agent.fail": await this.httpRequest("POST", "/fail", { taskId: message.taskId, reason: message.reason }); break;
192
- case "agent.progress": await this.httpRequest("POST", "/progress", { taskId: message.taskId, message: message.message, percent: message.percent }); break;
315
+ case "agent.complete": await this.httpRequest("POST", "/complete", { taskId: message.taskId, output: message.output, attachments: message.attachments, workRef: message.workRef }); break;
316
+ case "agent.fail": await this.httpRequest("POST", "/fail", { taskId: message.taskId, reason: message.reason, workRef: message.workRef }); break;
317
+ case "agent.progress": await this.httpRequest("POST", "/progress", { taskId: message.taskId, message: message.message, workRef: message.workRef }); break;
193
318
  case "agent.heartbeat": await this.httpRequest("POST", "/heartbeat", {}); break;
194
- case "agent.chat.reply": await this.httpRequest("POST", "/chat/reply", { channelId: message.channelId, content: message.content, replyTo: message.replyTo }); break;
319
+ case "agent.chat.reply": await this.httpRequest("POST", "/chat/reply", {
320
+ channelId: message.channelId,
321
+ content: message.content,
322
+ replyTo: message.replyTo,
323
+ workRefs: message.workRefs,
324
+ }); break;
195
325
  case "agent.typing": await this.httpRequest("POST", "/typing", { channelId: message.channelId }); break;
196
326
  }
197
327
  }
@@ -202,18 +332,26 @@ export class ClawroomClient {
202
332
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${this.options.token}` },
203
333
  body: JSON.stringify(body),
204
334
  });
335
+ const text = await res.text().catch(() => "");
205
336
  if (!res.ok) {
206
- const text = await res.text().catch(() => "");
207
337
  this.onHttpError(res.status, text);
208
338
  throw new Error(`HTTP ${res.status}: ${text}`);
209
339
  }
210
- return res.json();
340
+ if (!text) return null;
341
+ try {
342
+ return JSON.parse(text) as unknown;
343
+ } catch {
344
+ throw new Error(`Invalid JSON response from ${path}`);
345
+ }
211
346
  }
212
347
 
213
- protected onHttpError(_status: number, _text: string): void {}
348
+ protected onHttpError(status: number, text: string): void {
349
+ void status;
350
+ void text;
351
+ }
214
352
 
215
353
  private rememberChat(messageId?: string): boolean {
216
- if (!messageId) return true;
354
+ if (!messageId) return false;
217
355
  if (this.recentChatIds.has(messageId)) return false;
218
356
  this.recentChatIds.add(messageId);
219
357
  if (this.recentChatIds.size > 1000) {
@@ -223,15 +361,70 @@ export class ClawroomClient {
223
361
  return true;
224
362
  }
225
363
 
226
- private ackChatBestEffort(messageIds: string[]): void {
227
- void this.ackChat(messageIds).catch((err) => {
228
- this.options.log?.warn?.(`[clawroom] chat ack error: ${err}`);
229
- });
364
+ private async dispatchTask(task: ServerTask): Promise<void> {
365
+ this.options.log?.info?.(`[clawroom] received task ${task.taskId}: ${task.title}`);
366
+ for (const cb of this.taskCallbacks) cb(task);
230
367
  }
231
368
 
232
- private async ackChat(messageIds: string[]): Promise<void> {
233
- const ids = Array.from(new Set(messageIds.filter(Boolean)));
234
- if (ids.length === 0) return;
235
- await this.httpRequest("POST", "/chat/ack", { messageIds: ids });
369
+ private markConnected(agentId?: string): void {
370
+ if (agentId) this.connectedAgentId = agentId;
371
+ if (!this.connectedAgentId || this.isConnectedValue) return;
372
+ this.isConnectedValue = true;
373
+ for (const callback of this.connectedCallbacks) callback(this.connectedAgentId);
374
+ }
375
+
376
+ private markDisconnected(): void {
377
+ if (!this.isConnectedValue) return;
378
+ this.isConnectedValue = false;
379
+ for (const callback of this.disconnectedCallbacks) callback();
380
+ }
381
+ }
382
+
383
+ function removeCallback<T>(callbacks: T[], callback: T): void {
384
+ const index = callbacks.indexOf(callback);
385
+ if (index !== -1) callbacks.splice(index, 1);
386
+ }
387
+
388
+ function getRecord(value: unknown): Record<string, unknown> | null {
389
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
390
+ return value as Record<string, unknown>;
391
+ }
392
+
393
+ function getString(value: unknown, fallback = ""): string {
394
+ return typeof value === "string" ? value : fallback;
395
+ }
396
+
397
+ function getOptionalString(value: unknown): string | undefined {
398
+ return typeof value === "string" ? value : undefined;
399
+ }
400
+
401
+ function getAttachments(value: unknown): ServerChatMessage["attachments"] | undefined {
402
+ return Array.isArray(value) ? value as ServerChatMessage["attachments"] : undefined;
403
+ }
404
+
405
+ function getContext(value: unknown): ServerChatMessage["context"] {
406
+ return Array.isArray(value) ? value as ServerChatMessage["context"] : [];
407
+ }
408
+
409
+ function getWakeReason(value: unknown): ServerChatMessage["wakeReason"] | undefined {
410
+ return typeof value === "string" ? value as ServerChatMessage["wakeReason"] : undefined;
411
+ }
412
+
413
+ function getAgentChatProfile(value: unknown): AgentChatProfile | null {
414
+ const record = getRecord(value);
415
+ if (!record) return null;
416
+ return {
417
+ role: getString(record.role, DEFAULT_AGENT_CHAT_PROFILE.role),
418
+ systemPrompt: getString(record.systemPrompt, DEFAULT_AGENT_CHAT_PROFILE.systemPrompt),
419
+ memory: getString(record.memory, DEFAULT_AGENT_CHAT_PROFILE.memory),
420
+ continuityPacket: getString(record.continuityPacket, DEFAULT_AGENT_CHAT_PROFILE.continuityPacket),
421
+ };
422
+ }
423
+
424
+ function resolveAgentChatProfile(...values: unknown[]): AgentChatProfile {
425
+ for (const value of values) {
426
+ const profile = getAgentChatProfile(value);
427
+ if (profile) return profile;
236
428
  }
429
+ return DEFAULT_AGENT_CHAT_PROFILE;
237
430
  }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export type { WsTransportOptions } from "./ws-transport.js";
7
7
 
8
8
  export type {
9
9
  AgentChatProfile,
10
+ ChatAttachmentRef,
10
11
  AgentMessage,
11
12
  AgentHeartbeat,
12
13
  AgentComplete,
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import * as os from "node:os";
7
- import type { AgentChatProfile, ServerChatMessage } from "./protocol.js";
7
+ import type { AgentChatProfile, AgentWorkRef, ServerChatMessage, ServerTask } from "./protocol.js";
8
8
  import { WsTransport } from "./ws-transport.js";
9
9
 
10
10
  export type ClawroomMachineClientOptions = {
@@ -20,10 +20,22 @@ export type ClawroomMachineClientOptions = {
20
20
  };
21
21
  };
22
22
 
23
+ export type AgentReflectionPayload = {
24
+ scope: "chat" | "task";
25
+ status: string;
26
+ summary: string;
27
+ channelId?: string | null;
28
+ taskId?: string | null;
29
+ messageId?: string | null;
30
+ toolsUsed?: string[];
31
+ responseExcerpt?: string | null;
32
+ detail?: Record<string, unknown>;
33
+ };
34
+
23
35
  type AgentWork = {
24
36
  agentId: string;
25
37
  agentName: string;
26
- task: { type: "server.task"; taskId: string; title: string; description: string; input: string; skillTags: string[] } | null;
38
+ task: ServerTask | null;
27
39
  chat: ServerChatMessage[] | null;
28
40
  };
29
41
 
@@ -31,8 +43,9 @@ const DEFAULT_AGENT_CHAT_PROFILE: AgentChatProfile = {
31
43
  role: "No role defined",
32
44
  systemPrompt: "No system prompt configured",
33
45
  memory: "No memory recorded yet",
34
- focus: "No focus items",
46
+ continuityPacket: "No continuity packet available yet",
35
47
  };
48
+ const MAX_RECENT_CHAT_IDS = 1000;
36
49
 
37
50
  type MachineHeartbeatResponse = {
38
51
  machineId: string;
@@ -48,6 +61,8 @@ export class ClawroomMachineClient {
48
61
  private options: ClawroomMachineClientOptions;
49
62
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
50
63
  private pollTimer: ReturnType<typeof setInterval> | null = null;
64
+ private heartbeatInFlight = false;
65
+ private pollInFlight = false;
51
66
  private taskHandler: ((agentId: string, task: AgentWork["task"]) => void) | null = null;
52
67
  private chatHandler: ((agentId: string, messages: NonNullable<AgentWork["chat"]>) => void) | null = null;
53
68
  private connectHandler: ((machineId: string) => void) | null = null;
@@ -55,7 +70,6 @@ export class ClawroomMachineClient {
55
70
  private _connected = false;
56
71
  private _stopped = false;
57
72
  private wsTransport: WsTransport | null = null;
58
- private subscribedAgentIds = new Set<string>();
59
73
  private recentChatIds = new Map<string, number>();
60
74
 
61
75
  constructor(options: ClawroomMachineClientOptions) {
@@ -79,7 +93,15 @@ export class ClawroomMachineClient {
79
93
  body: body ? JSON.stringify(body) : undefined,
80
94
  });
81
95
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
82
- return res.json();
96
+ const text = await res.text();
97
+ if (!text.trim()) return null;
98
+ try {
99
+ return JSON.parse(text) as unknown;
100
+ } catch (error) {
101
+ throw new Error(
102
+ `Invalid JSON from ${path}: ${error instanceof Error ? error.message : "unknown parse error"}`,
103
+ );
104
+ }
83
105
  }
84
106
 
85
107
  onAgentTask(handler: (agentId: string, task: AgentWork["task"]) => void) { this.taskHandler = handler; }
@@ -87,56 +109,44 @@ export class ClawroomMachineClient {
87
109
  onConnected(handler: (machineId: string) => void) { this.connectHandler = handler; }
88
110
  onDisconnected(handler: () => void) { this.disconnectHandler = handler; }
89
111
 
90
- async sendAgentComplete(agentId: string, taskId: string, output: string, attachments?: Array<{ filename: string; mimeType: string; data: string }>) {
91
- await this.httpRequest("POST", "/complete", { agentId, taskId, output, attachments });
112
+ async sendAgentComplete(agentId: string, taskId: string, output: string, attachments?: Array<{ filename: string; mimeType: string; data: string }>, workRef?: AgentWorkRef) {
113
+ await this.httpRequest("POST", "/complete", { agentId, taskId, output, attachments, workRef });
92
114
  }
93
115
 
94
- async sendAgentFail(agentId: string, taskId: string, reason: string) {
95
- await this.httpRequest("POST", "/fail", { agentId, taskId, reason });
116
+ async sendAgentFail(agentId: string, taskId: string, reason: string, workRef?: AgentWorkRef) {
117
+ await this.httpRequest("POST", "/fail", { agentId, taskId, reason, workRef });
96
118
  }
97
119
 
98
- async sendAgentChatReply(agentId: string, channelId: string, content: string) {
99
- await this.httpRequest("POST", "/chat-reply", { agentId, channelId, content });
120
+ async sendAgentChatReply(agentId: string, channelId: string, content: string, replyTo?: string, workRefs?: AgentWorkRef[]) {
121
+ await this.httpRequest("POST", "/chat-reply", { agentId, channelId, content, replyTo, workRefs });
100
122
  }
101
123
 
102
124
  async sendAgentTyping(agentId: string, channelId: string) {
103
125
  await this.httpRequest("POST", "/typing", { agentId, channelId });
104
126
  }
105
127
 
106
- async ackAgentChat(agentId: string, messageIds: string[]) {
107
- const ids = Array.from(new Set(messageIds.filter(Boolean)));
108
- if (ids.length === 0) return;
109
- await this.httpRequest("POST", "/chat-ack", { agentId, messageIds: ids });
128
+ async sendAgentReflection(agentId: string, reflection: AgentReflectionPayload) {
129
+ await this.httpRequest("POST", "/reflections", { agentId, ...reflection });
110
130
  }
111
131
 
112
132
  get connected() { return this._connected; }
113
133
  get stopped() { return this._stopped; }
114
134
 
115
- private rememberChat(messageId: string): boolean {
116
- if (!messageId) return true;
135
+ private rememberChat(messageId?: string | null): boolean {
136
+ if (!messageId) return false;
117
137
  const now = Date.now();
118
138
  for (const [id, seenAt] of this.recentChatIds) {
119
139
  if (now - seenAt > 10 * 60_000) this.recentChatIds.delete(id);
120
140
  }
121
141
  if (this.recentChatIds.has(messageId)) return false;
142
+ if (this.recentChatIds.size >= MAX_RECENT_CHAT_IDS) {
143
+ const oldestId = this.recentChatIds.keys().next().value;
144
+ if (oldestId) this.recentChatIds.delete(oldestId);
145
+ }
122
146
  this.recentChatIds.set(messageId, now);
123
147
  return true;
124
148
  }
125
149
 
126
- private ackAgentChatBestEffort(agentId: string, messageIds: string[]) {
127
- void this.ackAgentChat(agentId, messageIds).catch((err) => {
128
- this.options.log?.warn?.(`[machine] chat ack error: ${err}`);
129
- });
130
- }
131
-
132
- private syncAgentSubscriptions(agentIds: string[]) {
133
- if (agentIds.length === 0) return;
134
- for (const agentId of agentIds) {
135
- this.subscribedAgentIds.add(agentId);
136
- this.wsTransport?.send({ type: "subscribe_agent", agentId });
137
- }
138
- }
139
-
140
150
  private markConnected(machineId: string) {
141
151
  if (!this._connected) {
142
152
  this._connected = true;
@@ -154,48 +164,76 @@ export class ClawroomMachineClient {
154
164
  }
155
165
 
156
166
  private async pollOnce() {
157
- if (!this._connected) return;
167
+ if (!this._connected || this.pollInFlight) return;
168
+ this.pollInFlight = true;
158
169
  try {
159
170
  const result = (await this.httpRequest("POST", "/poll", {})) as MachinePollResponse;
160
- this.syncAgentSubscriptions(result.agents.map((agent) => agent.agentId));
161
171
  for (const agent of result.agents) {
162
- if (agent.task && this.taskHandler) this.taskHandler(agent.agentId, agent.task);
172
+ if (agent.task && this.taskHandler) {
173
+ try {
174
+ this.taskHandler(agent.agentId, agent.task);
175
+ } catch (err) {
176
+ this.options.log?.warn?.(
177
+ `[machine] task dispatch failed for agent ${agent.agentId} task ${agent.task.taskId}: ${err}`,
178
+ );
179
+ }
180
+ }
163
181
  if (agent.chat && agent.chat.length > 0 && this.chatHandler) {
164
- const freshMessages = agent.chat.filter((message) => this.rememberChat(message.messageId));
165
- if (freshMessages.length > 0) {
166
- this.chatHandler(agent.agentId, freshMessages);
167
- this.ackAgentChatBestEffort(agent.agentId, freshMessages.map((message) => message.messageId));
182
+ try {
183
+ const freshMessages = agent.chat.filter((message) => this.rememberChat(message.workId ?? message.messageId));
184
+ if (freshMessages.length > 0) {
185
+ this.chatHandler(agent.agentId, freshMessages);
186
+ }
187
+ } catch (err) {
188
+ this.options.log?.warn?.(`[machine] chat dispatch failed for agent ${agent.agentId}: ${err}`);
168
189
  }
169
190
  }
170
191
  }
171
192
  } catch (err) {
172
193
  this.options.log?.warn?.(`[machine] poll error: ${err}`);
194
+ } finally {
195
+ this.pollInFlight = false;
173
196
  }
174
197
  }
175
198
 
176
199
  connect() {
200
+ this.stopHeartbeat();
201
+ this.stopPolling();
202
+ this.wsTransport?.disconnect();
203
+ this.wsTransport = null;
177
204
  this._stopped = false;
178
205
  const hostname = this.options.hostname ?? os.hostname();
179
- const hbBody = { hostname, capabilities: this.options.capabilities };
180
-
181
- // Heartbeat every 30s (always HTTP)
182
- this.heartbeatTimer = setInterval(async () => {
206
+ const hbBody = {
207
+ hostname,
208
+ capabilities: this.options.capabilities,
209
+ platform: os.platform(),
210
+ architecture: os.arch(),
211
+ osRelease: os.release(),
212
+ };
213
+ const sendHeartbeat = async () => {
214
+ if (this.heartbeatInFlight) return;
215
+ this.heartbeatInFlight = true;
183
216
  try {
184
217
  const result = (await this.httpRequest("POST", "/heartbeat", hbBody)) as MachineHeartbeatResponse;
185
- this.syncAgentSubscriptions((result.agents ?? []).map((agent) => agent.id));
186
218
  this.markConnected(result.machineId);
187
- } catch {
188
- this.markDisconnected();
219
+ } catch (err) {
220
+ if (!this.wsTransport?.connected) {
221
+ this.markDisconnected();
222
+ } else {
223
+ this.options.log?.warn?.(`[machine] heartbeat failed while WS is still connected: ${err}`);
224
+ }
225
+ throw err;
226
+ } finally {
227
+ this.heartbeatInFlight = false;
189
228
  }
229
+ };
230
+
231
+ // Heartbeat every 30s (always HTTP)
232
+ this.heartbeatTimer = setInterval(() => {
233
+ void sendHeartbeat().catch(() => {});
190
234
  }, 30_000);
191
235
 
192
- this.httpRequest("POST", "/heartbeat", hbBody)
193
- .then((result) => {
194
- const data = result as MachineHeartbeatResponse;
195
- this.syncAgentSubscriptions((data.agents ?? []).map((agent) => agent.id));
196
- this.markConnected(data.machineId);
197
- })
198
- .catch((err) => this.options.log?.warn?.(`[machine] initial heartbeat failed: ${err}`));
236
+ sendHeartbeat().catch((err) => this.options.log?.warn?.(`[machine] initial heartbeat failed: ${err}`));
199
237
 
200
238
  // WebSocket transport
201
239
  this.wsTransport = new WsTransport({
@@ -203,33 +241,37 @@ export class ClawroomMachineClient {
203
241
  token: this.options.apiKey,
204
242
  log: { info: (m) => this.options.log?.info?.(m), warn: (m) => this.options.log?.warn?.(m) },
205
243
  onConnected: () => {
206
- this.syncAgentSubscriptions([...this.subscribedAgentIds]);
207
244
  this.options.log?.info?.("[machine] WebSocket connected");
208
245
  },
209
246
  onDisconnected: () => {
210
247
  this.options.log?.info?.("[machine] WebSocket disconnected, HTTP polling active");
211
248
  },
212
249
  onMessage: (msg) => {
250
+ const message = getRecord(msg.message);
213
251
  if (msg.type === "message") {
214
- const m = msg.message;
215
- const targetAgentId = msg.agentId ?? m?.agentId;
216
- const messageId = m?.id ?? msg.messageId ?? "";
217
- if (m && targetAgentId && this.chatHandler && this.rememberChat(messageId)) {
218
- const agentProfile = msg.agentProfile ?? m.agentProfile ?? DEFAULT_AGENT_CHAT_PROFILE;
252
+ const targetAgentId = getOptionalString(msg.agentId) ?? getOptionalString(message?.agentId);
253
+ const messageId = getString(message?.id, getString(msg.messageId));
254
+ if (message && targetAgentId && this.chatHandler && this.rememberChat(messageId)) {
255
+ const agentProfile = resolveAgentChatProfile(msg.agentProfile, message?.agentProfile);
219
256
  const messages = [{
257
+ workId: getString(message?.workId),
258
+ leaseToken: getString(message?.leaseToken),
220
259
  messageId,
221
- channelId: m.channelId ?? "",
222
- content: m.content ?? "",
223
- isMention: msg.isMention ?? false,
224
- context: msg.context ?? [],
260
+ channelId: getString(message?.channelId),
261
+ content: getString(message?.content),
262
+ attachments: getAttachments(msg.attachments) ?? getAttachments(message?.attachments),
263
+ isMention: typeof msg.isMention === "boolean" ? msg.isMention : false,
264
+ wakeReason: getWakeReason(msg.wakeReason) ?? getWakeReason(message?.wakeReason),
265
+ triggerReason: getOptionalString(msg.triggerReason) ?? getOptionalString(message?.triggerReason),
266
+ context: getContext(msg.context),
225
267
  agentProfile,
226
268
  }];
227
269
  this.chatHandler(targetAgentId, messages);
228
- this.ackAgentChatBestEffort(targetAgentId, messages.map((message) => message.messageId));
229
270
  }
230
271
  }
231
- if (msg.type === "task" && msg.agentId && this.taskHandler) {
232
- this.taskHandler(msg.agentId, msg.task);
272
+ const taskAgentId = getOptionalString(msg.agentId);
273
+ if (msg.type === "task" && taskAgentId && this.taskHandler) {
274
+ this.taskHandler(taskAgentId, (msg.task as AgentWork["task"] | undefined) ?? null);
233
275
  }
234
276
  },
235
277
  });
@@ -247,9 +289,68 @@ export class ClawroomMachineClient {
247
289
  disconnect() {
248
290
  this._stopped = true;
249
291
  this._connected = false;
250
- if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
251
- if (this.pollTimer) clearInterval(this.pollTimer);
292
+ this.stopHeartbeat();
293
+ this.stopPolling();
252
294
  this.wsTransport?.disconnect();
253
295
  this.wsTransport = null;
296
+ this.recentChatIds.clear();
297
+ }
298
+
299
+ private stopHeartbeat() {
300
+ if (this.heartbeatTimer) {
301
+ clearInterval(this.heartbeatTimer);
302
+ this.heartbeatTimer = null;
303
+ }
304
+ }
305
+
306
+ private stopPolling() {
307
+ if (this.pollTimer) {
308
+ clearInterval(this.pollTimer);
309
+ this.pollTimer = null;
310
+ }
311
+ }
312
+ }
313
+
314
+ function getRecord(value: unknown): Record<string, unknown> | null {
315
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
316
+ return value as Record<string, unknown>;
317
+ }
318
+
319
+ function getString(value: unknown, fallback = ""): string {
320
+ return typeof value === "string" ? value : fallback;
321
+ }
322
+
323
+ function getOptionalString(value: unknown): string | undefined {
324
+ return typeof value === "string" ? value : undefined;
325
+ }
326
+
327
+ function getAttachments(value: unknown): ServerChatMessage["attachments"] | undefined {
328
+ return Array.isArray(value) ? value as ServerChatMessage["attachments"] : undefined;
329
+ }
330
+
331
+ function getContext(value: unknown): ServerChatMessage["context"] {
332
+ return Array.isArray(value) ? value as ServerChatMessage["context"] : [];
333
+ }
334
+
335
+ function getWakeReason(value: unknown): ServerChatMessage["wakeReason"] | undefined {
336
+ return typeof value === "string" ? value as ServerChatMessage["wakeReason"] : undefined;
337
+ }
338
+
339
+ function getAgentChatProfile(value: unknown): AgentChatProfile | null {
340
+ const record = getRecord(value);
341
+ if (!record) return null;
342
+ return {
343
+ role: getString(record.role, DEFAULT_AGENT_CHAT_PROFILE.role),
344
+ systemPrompt: getString(record.systemPrompt, DEFAULT_AGENT_CHAT_PROFILE.systemPrompt),
345
+ memory: getString(record.memory, DEFAULT_AGENT_CHAT_PROFILE.memory),
346
+ continuityPacket: getString(record.continuityPacket, DEFAULT_AGENT_CHAT_PROFILE.continuityPacket),
347
+ };
348
+ }
349
+
350
+ function resolveAgentChatProfile(...values: unknown[]): AgentChatProfile {
351
+ for (const value of values) {
352
+ const profile = getAgentChatProfile(value);
353
+ if (profile) return profile;
254
354
  }
355
+ return DEFAULT_AGENT_CHAT_PROFILE;
255
356
  }
package/src/protocol.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export type {
2
2
  AgentChatProfile,
3
+ ChatAttachmentRef,
3
4
  AgentHeartbeat,
4
5
  AgentResultFile,
5
6
  AgentComplete,
@@ -3,26 +3,41 @@
3
3
  * Used by both ClawroomClient and ClawroomMachineClient.
4
4
  */
5
5
 
6
+ export type WsLike = {
7
+ readyState: number;
8
+ on: (event: string, listener: (...args: unknown[]) => void) => void;
9
+ removeAllListeners?: (event?: string) => void;
10
+ send: (data: string) => void;
11
+ close: () => void;
12
+ };
13
+
6
14
  export type WsTransportOptions = {
7
15
  url: string;
8
16
  token: string;
9
- onMessage: (msg: any) => void;
17
+ onMessage: (msg: Record<string, unknown>) => void;
10
18
  onConnected: () => void;
11
19
  onDisconnected: () => void;
20
+ createSocket?: (url: string) => WsLike | Promise<WsLike>;
12
21
  log?: {
13
22
  info?: (message: string) => void;
14
23
  warn?: (message: string) => void;
15
24
  };
16
25
  };
17
26
 
27
+ type WsModule = {
28
+ default: new (url: string) => WsLike;
29
+ };
30
+
18
31
  export class WsTransport {
19
- private ws: any = null;
32
+ private static readonly MAX_SEND_QUEUE = 500;
33
+ private ws: WsLike | null = null;
20
34
  private options: WsTransportOptions;
21
35
  private _connected = false;
22
36
  private stopped = false;
23
37
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
24
38
  private reconnectDelay = 1000;
25
39
  private failCount = 0;
40
+ private sendQueue: string[] = [];
26
41
 
27
42
  static readonly MAX_RECONNECT_DELAY = 60_000;
28
43
  static readonly MAX_RECONNECT_FAILS = 10;
@@ -34,35 +49,44 @@ export class WsTransport {
34
49
  get connected() { return this._connected; }
35
50
 
36
51
  async connect() {
37
- if (this.stopped) return;
52
+ this.stopped = false;
53
+ if (this.reconnectTimer) {
54
+ clearTimeout(this.reconnectTimer);
55
+ this.reconnectTimer = null;
56
+ }
57
+ if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) return;
38
58
 
39
- let WebSocket: any;
40
- try {
41
- WebSocket = (await import("ws")).default;
42
- } catch {
43
- this.options.log?.info?.("ws module not available, skipping WebSocket");
44
- return;
59
+ if (this.ws) {
60
+ const staleSocket = this.ws;
61
+ this.ws = null;
62
+ this.closeSocket(staleSocket);
45
63
  }
46
64
 
47
65
  try {
48
- this.ws = new WebSocket(this.options.url);
66
+ this.ws = await this.createSocket();
67
+ if (!this.ws) return;
49
68
  } catch {
69
+ this._connected = false;
50
70
  this.scheduleReconnect();
51
71
  return;
52
72
  }
53
73
 
54
- this.ws.on("open", () => {
74
+ const socket = this.ws;
75
+
76
+ socket.on("open", () => {
55
77
  this.reconnectDelay = 1000;
56
- this.ws.send(JSON.stringify({ type: "auth", token: this.options.token }));
78
+ socket.send(JSON.stringify({ type: "auth", token: this.options.token }));
57
79
  });
58
80
 
59
- this.ws.on("message", (raw: any) => {
60
- let msg: any;
61
- try { msg = JSON.parse(raw.toString()); } catch { return; }
81
+ socket.on("message", (raw) => {
82
+ if (!raw || typeof raw !== "object" || !("toString" in raw) || typeof raw.toString !== "function") return;
83
+ let msg: Record<string, unknown>;
84
+ try { msg = JSON.parse(raw.toString()) as Record<string, unknown>; } catch { return; }
62
85
 
63
86
  if (msg.type === "auth_ok") {
64
87
  this._connected = true;
65
88
  this.failCount = 0;
89
+ this.flushSendQueue(socket);
66
90
  this.options.onConnected();
67
91
  return;
68
92
  }
@@ -70,7 +94,7 @@ export class WsTransport {
70
94
  if (msg.type === "auth_error") {
71
95
  this._connected = false;
72
96
  this.options.log?.warn?.(`WS auth failed: ${msg.error ?? "unknown"}`);
73
- this.ws?.close();
97
+ socket.close();
74
98
  return;
75
99
  }
76
100
 
@@ -79,37 +103,70 @@ export class WsTransport {
79
103
  this.options.onMessage(msg);
80
104
  });
81
105
 
82
- this.ws.on("close", () => {
83
- const wasConnected = this._connected;
84
- this._connected = false;
85
- if (wasConnected) this.options.onDisconnected();
86
- if (!this.stopped) this.scheduleReconnect();
106
+ socket.on("close", () => {
107
+ this.handleDisconnect(socket);
87
108
  });
88
109
 
89
- this.ws.on("error", () => {
90
- this._connected = false;
110
+ socket.on("error", () => {
111
+ this.handleDisconnect(socket);
112
+ try {
113
+ if (socket.readyState === 0 || socket.readyState === 1) socket.close();
114
+ } catch {
115
+ /* ignore close errors */
116
+ }
91
117
  });
92
118
  }
93
119
 
94
- send(msg: any) {
120
+ send(msg: Record<string, unknown>): boolean {
121
+ const payload = JSON.stringify(msg);
95
122
  if (this._connected && this.ws?.readyState === 1) {
96
- this.ws.send(JSON.stringify(msg));
123
+ this.ws.send(payload);
124
+ return true;
125
+ }
126
+ if (!this.stopped) {
127
+ if (!this.sendQueue.includes(payload)) {
128
+ if (this.sendQueue.length >= WsTransport.MAX_SEND_QUEUE) {
129
+ this.sendQueue.shift();
130
+ this.options.log?.warn?.(`WS send queue full; dropping oldest queued message`);
131
+ }
132
+ this.sendQueue.push(payload);
133
+ }
134
+ this.options.log?.warn?.("WS send queued until the socket is ready");
97
135
  }
136
+ return false;
98
137
  }
99
138
 
100
139
  disconnect() {
101
140
  this.stopped = true;
102
141
  this._connected = false;
103
142
  if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
104
- if (this.ws) { this.ws.close(); this.ws = null; }
143
+ this.resetReconnectState();
144
+ this.sendQueue = [];
145
+ if (this.ws) {
146
+ const socket = this.ws;
147
+ this.ws = null;
148
+ this.closeSocket(socket);
149
+ }
150
+ }
151
+
152
+ private handleDisconnect(socket: WsLike) {
153
+ if (this.ws !== socket) return;
154
+ this.ws = null;
155
+ const wasConnected = this._connected;
156
+ this._connected = false;
157
+ if (wasConnected) this.options.onDisconnected();
158
+ if (!this.stopped) this.scheduleReconnect();
105
159
  }
106
160
 
107
161
  private scheduleReconnect() {
108
162
  if (this.stopped || this.reconnectTimer) return;
109
163
 
110
164
  this.failCount++;
111
- if (this.failCount > WsTransport.MAX_RECONNECT_FAILS) {
112
- this.options.log?.warn?.(`WS reconnect failed ${this.failCount} times, giving up. HTTP polling active.`);
165
+ const failedAttempts = this.failCount;
166
+ if (failedAttempts > WsTransport.MAX_RECONNECT_FAILS) {
167
+ this._connected = false;
168
+ this.resetReconnectState();
169
+ this.options.log?.warn?.(`WS reconnect failed ${failedAttempts} times, giving up. HTTP polling active.`);
113
170
  return;
114
171
  }
115
172
 
@@ -120,4 +177,42 @@ export class WsTransport {
120
177
  }, Math.min(this.reconnectDelay + jitter, WsTransport.MAX_RECONNECT_DELAY));
121
178
  this.reconnectDelay = Math.min(this.reconnectDelay * 2, WsTransport.MAX_RECONNECT_DELAY);
122
179
  }
180
+
181
+ private async createSocket(): Promise<WsLike | null> {
182
+ if (this.options.createSocket) {
183
+ return await this.options.createSocket(this.options.url);
184
+ }
185
+
186
+ let WebSocket: WsModule["default"];
187
+ try {
188
+ WebSocket = (await import("ws") as WsModule).default;
189
+ } catch {
190
+ this.options.log?.info?.("ws module not available, skipping WebSocket");
191
+ return null;
192
+ }
193
+
194
+ return new WebSocket(this.options.url);
195
+ }
196
+
197
+ private closeSocket(socket: WsLike) {
198
+ try {
199
+ socket.removeAllListeners?.();
200
+ socket.close();
201
+ } catch {
202
+ /* ignore close errors */
203
+ }
204
+ }
205
+
206
+ private resetReconnectState() {
207
+ this.reconnectDelay = 1000;
208
+ this.failCount = 0;
209
+ }
210
+
211
+ private flushSendQueue(socket: WsLike) {
212
+ while (this.ws === socket && this._connected && socket.readyState === 1 && this.sendQueue.length > 0) {
213
+ const payload = this.sendQueue.shift();
214
+ if (!payload) continue;
215
+ socket.send(payload);
216
+ }
217
+ }
123
218
  }