@clawroom/sdk 0.5.1 → 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.1",
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,5 +1,8 @@
1
1
  import type {
2
+ AgentChatProfile,
3
+ AgentResultFile,
2
4
  AgentMessage,
5
+ AgentWorkRef,
3
6
  ServerTask,
4
7
  ServerChatMessage,
5
8
  } from "./protocol.js";
@@ -8,9 +11,18 @@ import { WsTransport } from "./ws-transport.js";
8
11
  const DEFAULT_ENDPOINT = "https://clawroom.site9.ai/api/agents";
9
12
  const HEARTBEAT_INTERVAL_MS = 30_000;
10
13
  const POLL_INTERVAL_MS = 10_000;
14
+ const DEFAULT_AGENT_CHAT_PROFILE: AgentChatProfile = {
15
+ role: "No role defined",
16
+ systemPrompt: "No system prompt configured",
17
+ memory: "No memory recorded yet",
18
+ continuityPacket: "No continuity packet available yet",
19
+ };
11
20
 
12
21
  export type TaskCallback = (task: ServerTask) => void;
13
22
  export type ChatCallback = (messages: ServerChatMessage[]) => void;
23
+ export type ConnectedCallback = (agentId: string) => void;
24
+ export type DisconnectedCallback = () => void;
25
+ export type Unsubscribe = () => void;
14
26
 
15
27
  export type ClawroomClientOptions = {
16
28
  endpoint?: string;
@@ -26,6 +38,17 @@ export type ClawroomClientOptions = {
26
38
  };
27
39
  };
28
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
+
29
52
  /**
30
53
  * ClawRoom SDK client.
31
54
  * WebSocket primary for real-time push, HTTP polling as fallback.
@@ -33,12 +56,19 @@ export type ClawroomClientOptions = {
33
56
  export class ClawroomClient {
34
57
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
35
58
  private pollTimer: ReturnType<typeof setInterval> | null = null;
59
+ private heartbeatInFlight = false;
60
+ private pollInFlight = false;
36
61
  protected stopped = false;
37
62
  protected readonly httpBase: string;
38
63
  protected readonly options: ClawroomClientOptions;
39
64
  protected taskCallbacks: TaskCallback[] = [];
40
65
  protected chatCallbacks: ChatCallback[] = [];
66
+ private connectedCallbacks: ConnectedCallback[] = [];
67
+ private disconnectedCallbacks: DisconnectedCallback[] = [];
41
68
  private wsTransport: WsTransport | null = null;
69
+ private recentChatIds = new Set<string>();
70
+ private connectedAgentId: string | null = null;
71
+ private isConnectedValue = false;
42
72
 
43
73
  constructor(options: ClawroomClientOptions) {
44
74
  this.options = options;
@@ -50,12 +80,26 @@ export class ClawroomClient {
50
80
  return this.httpBase.replace(/\/api\/agents$/, "").replace(/^http/, "ws") + "/api/ws";
51
81
  }
52
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
+
53
95
  connect(): void {
54
96
  this.stopped = false;
55
97
  this.startHeartbeat();
56
98
  void this.register();
57
99
 
58
100
  // WebSocket transport
101
+ this.wsTransport?.disconnect();
102
+ this.wsTransport = null;
59
103
  this.wsTransport = new WsTransport({
60
104
  url: this.wsUrl,
61
105
  token: this.options.token,
@@ -64,21 +108,48 @@ export class ClawroomClient {
64
108
  onDisconnected: () => { this.options.log?.info?.("[clawroom] WebSocket disconnected"); },
65
109
  onMessage: (msg) => {
66
110
  if (msg.type === "task" && msg.task) {
67
- for (const cb of this.taskCallbacks) cb(msg.task);
111
+ const task = msg.task as ServerTask;
112
+ void this.dispatchTask(task);
68
113
  }
69
114
  if (msg.type === "chat" && Array.isArray(msg.messages)) {
70
- for (const cb of this.chatCallbacks) cb(msg.messages);
115
+ const fresh = msg.messages.filter((m: ServerChatMessage) => this.rememberChat(m.workId ?? m.messageId));
116
+ if (fresh.length > 0) {
117
+ for (const cb of this.chatCallbacks) cb(fresh);
118
+ }
119
+ }
120
+ const message = getRecord(msg.message);
121
+ if (msg.type === "message" && message) {
122
+ const agentProfile = resolveAgentChatProfile(msg.agentProfile, message.agentProfile);
123
+ const delivered = [{
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),
134
+ agentProfile,
135
+ }] as ServerChatMessage[];
136
+ const fresh = delivered.filter((m) => this.rememberChat(m.workId ?? m.messageId));
137
+ if (fresh.length > 0) {
138
+ for (const cb of this.chatCallbacks) cb(fresh);
139
+ }
71
140
  }
72
141
  },
73
142
  });
74
143
  this.wsTransport.connect();
75
144
 
76
- // HTTP poll fallback only when WS is down
145
+ // Keep HTTP polling active even when WS is up.
146
+ // WS is the fast path; polling is the durable delivery path.
77
147
  this.stopPolling();
78
148
  this.pollTimer = setInterval(() => {
79
- if (this.wsTransport?.connected) return;
80
149
  void this.pollTick();
81
150
  }, POLL_INTERVAL_MS);
151
+
152
+ void this.pollTick();
82
153
  }
83
154
 
84
155
  disconnect(): void {
@@ -87,14 +158,84 @@ export class ClawroomClient {
87
158
  this.stopPolling();
88
159
  this.wsTransport?.disconnect();
89
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 });
216
+ }
217
+
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 });
90
220
  }
91
221
 
92
- send(message: AgentMessage): void {
93
- this.sendViaHttp(message).catch(() => {});
222
+ async sendTyping(channelId: string): Promise<void> {
223
+ await this.send({ type: "agent.typing", channelId });
94
224
  }
95
225
 
96
- onTask(cb: TaskCallback): void { this.taskCallbacks.push(cb); }
97
- onChatMessage(cb: ChatCallback): void { this.chatCallbacks.push(cb); }
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
+ }
98
239
 
99
240
  // ── Heartbeat ─────────────────────────────────────────
100
241
 
@@ -114,67 +255,176 @@ export class ClawroomClient {
114
255
  }
115
256
 
116
257
  protected async register(): Promise<void> {
258
+ if (this.heartbeatInFlight) return;
259
+ this.heartbeatInFlight = true;
117
260
  try {
118
- await this.httpRequest("POST", "/heartbeat", {
261
+ const response = await this.httpRequest("POST", "/heartbeat", {
119
262
  deviceId: this.options.deviceId,
120
263
  skills: this.options.skills,
121
264
  kind: this.options.kind ?? "openclaw",
122
- });
123
- this.onPollSuccess(undefined);
265
+ }) as AgentHeartbeatResponse;
266
+ this.onPollSuccess(response.agentId);
124
267
  } catch (err) {
125
268
  this.options.log?.warn?.(`[clawroom] heartbeat error: ${err}`);
126
- this.onPollError(err);
269
+ if (!this.wsTransport?.connected) {
270
+ this.onPollError(err);
271
+ }
272
+ } finally {
273
+ this.heartbeatInFlight = false;
127
274
  }
128
275
  }
129
276
 
130
277
  protected async pollTick(): Promise<void> {
131
- if (this.stopped) return;
278
+ if (this.stopped || this.pollInFlight) return;
279
+ this.pollInFlight = true;
132
280
  try {
133
- const res = await this.httpRequest("POST", "/poll", {});
281
+ const res = await this.httpRequest("POST", "/poll", {}) as AgentPollResponse;
134
282
  this.onPollSuccess(res?.agentId);
135
283
  if (res.task) {
136
- this.options.log?.info?.(`[clawroom] received task ${res.task.taskId}: ${res.task.title}`);
137
- for (const cb of this.taskCallbacks) cb(res.task);
284
+ await this.dispatchTask(res.task);
138
285
  }
139
286
  if (res.chat && Array.isArray(res.chat) && res.chat.length > 0) {
140
- this.options.log?.info?.(`[clawroom] received ${res.chat.length} chat mention(s)`);
141
- for (const cb of this.chatCallbacks) cb(res.chat);
287
+ const fresh = res.chat.filter((m: ServerChatMessage) => this.rememberChat(m.workId ?? m.messageId));
288
+ if (fresh.length > 0) {
289
+ this.options.log?.info?.(`[clawroom] received ${fresh.length} chat mention(s)`);
290
+ for (const cb of this.chatCallbacks) cb(fresh);
291
+ }
142
292
  }
143
293
  } catch (err) {
144
294
  this.options.log?.warn?.(`[clawroom] poll error: ${err}`);
145
295
  this.onPollError(err);
296
+ } finally {
297
+ this.pollInFlight = false;
146
298
  }
147
299
  }
148
300
 
149
- protected onPollSuccess(_agentId: string | undefined): void {}
150
- 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
+ }
151
310
 
152
311
  // ── HTTP ──────────────────────────────────────────────
153
312
 
154
313
  private async sendViaHttp(message: AgentMessage): Promise<void> {
155
314
  switch (message.type) {
156
- case "agent.complete": await this.httpRequest("POST", "/complete", { taskId: message.taskId, output: message.output, attachments: message.attachments }); break;
157
- case "agent.fail": await this.httpRequest("POST", "/fail", { taskId: message.taskId, reason: message.reason }); break;
158
- 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;
159
318
  case "agent.heartbeat": await this.httpRequest("POST", "/heartbeat", {}); break;
160
- 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;
161
325
  case "agent.typing": await this.httpRequest("POST", "/typing", { channelId: message.channelId }); break;
162
326
  }
163
327
  }
164
328
 
165
- protected async httpRequest(method: string, path: string, body: unknown): Promise<any> {
329
+ protected async httpRequest(method: string, path: string, body: unknown): Promise<unknown> {
166
330
  const res = await fetch(`${this.httpBase}${path}`, {
167
331
  method,
168
332
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${this.options.token}` },
169
333
  body: JSON.stringify(body),
170
334
  });
335
+ const text = await res.text().catch(() => "");
171
336
  if (!res.ok) {
172
- const text = await res.text().catch(() => "");
173
337
  this.onHttpError(res.status, text);
174
338
  throw new Error(`HTTP ${res.status}: ${text}`);
175
339
  }
176
- 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
+ }
346
+ }
347
+
348
+ protected onHttpError(status: number, text: string): void {
349
+ void status;
350
+ void text;
351
+ }
352
+
353
+ private rememberChat(messageId?: string): boolean {
354
+ if (!messageId) return false;
355
+ if (this.recentChatIds.has(messageId)) return false;
356
+ this.recentChatIds.add(messageId);
357
+ if (this.recentChatIds.size > 1000) {
358
+ const oldest = this.recentChatIds.values().next().value;
359
+ if (oldest) this.recentChatIds.delete(oldest);
360
+ }
361
+ return true;
362
+ }
363
+
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);
367
+ }
368
+
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();
177
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
+ }
178
400
 
179
- protected onHttpError(_status: number, _text: string): void {}
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;
428
+ }
429
+ return DEFAULT_AGENT_CHAT_PROFILE;
180
430
  }
package/src/index.ts CHANGED
@@ -6,6 +6,8 @@ export { WsTransport } from "./ws-transport.js";
6
6
  export type { WsTransportOptions } from "./ws-transport.js";
7
7
 
8
8
  export type {
9
+ AgentChatProfile,
10
+ ChatAttachmentRef,
9
11
  AgentMessage,
10
12
  AgentHeartbeat,
11
13
  AgentComplete,
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import * as os from "node:os";
7
+ import type { AgentChatProfile, AgentWorkRef, ServerChatMessage, ServerTask } from "./protocol.js";
7
8
  import { WsTransport } from "./ws-transport.js";
8
9
 
9
10
  export type ClawroomMachineClientOptions = {
@@ -19,12 +20,32 @@ export type ClawroomMachineClientOptions = {
19
20
  };
20
21
  };
21
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
+
22
35
  type AgentWork = {
23
36
  agentId: string;
24
37
  agentName: string;
25
- task: { type: "server.task"; taskId: string; title: string; description: string; input: string; skillTags: string[] } | null;
26
- chat: Array<{ messageId: string; channelId: string; content: string; isMention: boolean; context: unknown[] }> | null;
38
+ task: ServerTask | null;
39
+ chat: ServerChatMessage[] | null;
40
+ };
41
+
42
+ const DEFAULT_AGENT_CHAT_PROFILE: AgentChatProfile = {
43
+ role: "No role defined",
44
+ systemPrompt: "No system prompt configured",
45
+ memory: "No memory recorded yet",
46
+ continuityPacket: "No continuity packet available yet",
27
47
  };
48
+ const MAX_RECENT_CHAT_IDS = 1000;
28
49
 
29
50
  type MachineHeartbeatResponse = {
30
51
  machineId: string;
@@ -40,12 +61,15 @@ export class ClawroomMachineClient {
40
61
  private options: ClawroomMachineClientOptions;
41
62
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
42
63
  private pollTimer: ReturnType<typeof setInterval> | null = null;
64
+ private heartbeatInFlight = false;
65
+ private pollInFlight = false;
43
66
  private taskHandler: ((agentId: string, task: AgentWork["task"]) => void) | null = null;
44
67
  private chatHandler: ((agentId: string, messages: NonNullable<AgentWork["chat"]>) => void) | null = null;
68
+ private connectHandler: ((machineId: string) => void) | null = null;
69
+ private disconnectHandler: (() => void) | null = null;
45
70
  private _connected = false;
46
71
  private _stopped = false;
47
72
  private wsTransport: WsTransport | null = null;
48
- private subscribedAgentIds = new Set<string>();
49
73
  private recentChatIds = new Map<string, number>();
50
74
 
51
75
  constructor(options: ClawroomMachineClientOptions) {
@@ -69,91 +93,147 @@ export class ClawroomMachineClient {
69
93
  body: body ? JSON.stringify(body) : undefined,
70
94
  });
71
95
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
72
- 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
+ }
73
105
  }
74
106
 
75
107
  onAgentTask(handler: (agentId: string, task: AgentWork["task"]) => void) { this.taskHandler = handler; }
76
108
  onAgentChat(handler: (agentId: string, messages: NonNullable<AgentWork["chat"]>) => void) { this.chatHandler = handler; }
109
+ onConnected(handler: (machineId: string) => void) { this.connectHandler = handler; }
110
+ onDisconnected(handler: () => void) { this.disconnectHandler = handler; }
77
111
 
78
- async sendAgentComplete(agentId: string, taskId: string, output: string, attachments?: Array<{ filename: string; mimeType: string; data: string }>) {
79
- 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 });
80
114
  }
81
115
 
82
- async sendAgentFail(agentId: string, taskId: string, reason: string) {
83
- 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 });
84
118
  }
85
119
 
86
- async sendAgentChatReply(agentId: string, channelId: string, content: string) {
87
- 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 });
88
122
  }
89
123
 
90
124
  async sendAgentTyping(agentId: string, channelId: string) {
91
125
  await this.httpRequest("POST", "/typing", { agentId, channelId });
92
126
  }
93
127
 
128
+ async sendAgentReflection(agentId: string, reflection: AgentReflectionPayload) {
129
+ await this.httpRequest("POST", "/reflections", { agentId, ...reflection });
130
+ }
131
+
94
132
  get connected() { return this._connected; }
95
133
  get stopped() { return this._stopped; }
96
134
 
97
- private rememberChat(messageId: string): boolean {
98
- if (!messageId) return true;
135
+ private rememberChat(messageId?: string | null): boolean {
136
+ if (!messageId) return false;
99
137
  const now = Date.now();
100
138
  for (const [id, seenAt] of this.recentChatIds) {
101
139
  if (now - seenAt > 10 * 60_000) this.recentChatIds.delete(id);
102
140
  }
103
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
+ }
104
146
  this.recentChatIds.set(messageId, now);
105
147
  return true;
106
148
  }
107
149
 
108
- private syncAgentSubscriptions(agentIds: string[]) {
109
- if (agentIds.length === 0) return;
110
- for (const agentId of agentIds) {
111
- this.subscribedAgentIds.add(agentId);
112
- this.wsTransport?.send({ type: "subscribe_agent", agentId });
150
+ private markConnected(machineId: string) {
151
+ if (!this._connected) {
152
+ this._connected = true;
153
+ this.options.log?.info?.("[machine] connected");
154
+ this.connectHandler?.(machineId);
155
+ }
156
+ }
157
+
158
+ private markDisconnected() {
159
+ if (this._connected) {
160
+ this._connected = false;
161
+ this.options.log?.warn?.("[machine] disconnected");
162
+ this.disconnectHandler?.();
113
163
  }
114
164
  }
115
165
 
116
166
  private async pollOnce() {
117
- if (!this._connected) return;
167
+ if (!this._connected || this.pollInFlight) return;
168
+ this.pollInFlight = true;
118
169
  try {
119
170
  const result = (await this.httpRequest("POST", "/poll", {})) as MachinePollResponse;
120
- this.syncAgentSubscriptions(result.agents.map((agent) => agent.agentId));
121
171
  for (const agent of result.agents) {
122
- 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
+ }
123
181
  if (agent.chat && agent.chat.length > 0 && this.chatHandler) {
124
- const freshMessages = agent.chat.filter((message) => this.rememberChat(message.messageId));
125
- if (freshMessages.length > 0) this.chatHandler(agent.agentId, freshMessages);
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}`);
189
+ }
126
190
  }
127
191
  }
128
192
  } catch (err) {
129
193
  this.options.log?.warn?.(`[machine] poll error: ${err}`);
194
+ } finally {
195
+ this.pollInFlight = false;
130
196
  }
131
197
  }
132
198
 
133
199
  connect() {
200
+ this.stopHeartbeat();
201
+ this.stopPolling();
202
+ this.wsTransport?.disconnect();
203
+ this.wsTransport = null;
134
204
  this._stopped = false;
135
205
  const hostname = this.options.hostname ?? os.hostname();
136
- const hbBody = { hostname, capabilities: this.options.capabilities };
137
-
138
- // Heartbeat every 30s (always HTTP)
139
- 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;
140
216
  try {
141
217
  const result = (await this.httpRequest("POST", "/heartbeat", hbBody)) as MachineHeartbeatResponse;
142
- this.syncAgentSubscriptions((result.agents ?? []).map((agent) => agent.id));
143
- if (!this._connected) { this._connected = true; this.options.log?.info?.("[machine] connected"); }
144
- } catch {
145
- if (this._connected) { this._connected = false; this.options.log?.warn?.("[machine] disconnected"); }
218
+ this.markConnected(result.machineId);
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;
146
228
  }
229
+ };
230
+
231
+ // Heartbeat every 30s (always HTTP)
232
+ this.heartbeatTimer = setInterval(() => {
233
+ void sendHeartbeat().catch(() => {});
147
234
  }, 30_000);
148
235
 
149
- this.httpRequest("POST", "/heartbeat", hbBody)
150
- .then((result) => {
151
- const data = result as MachineHeartbeatResponse;
152
- this._connected = true;
153
- this.syncAgentSubscriptions((data.agents ?? []).map((agent) => agent.id));
154
- this.options.log?.info?.("[machine] connected");
155
- })
156
- .catch((err) => this.options.log?.warn?.(`[machine] initial heartbeat failed: ${err}`));
236
+ sendHeartbeat().catch((err) => this.options.log?.warn?.(`[machine] initial heartbeat failed: ${err}`));
157
237
 
158
238
  // WebSocket transport
159
239
  this.wsTransport = new WsTransport({
@@ -161,29 +241,37 @@ export class ClawroomMachineClient {
161
241
  token: this.options.apiKey,
162
242
  log: { info: (m) => this.options.log?.info?.(m), warn: (m) => this.options.log?.warn?.(m) },
163
243
  onConnected: () => {
164
- this.syncAgentSubscriptions([...this.subscribedAgentIds]);
165
244
  this.options.log?.info?.("[machine] WebSocket connected");
166
245
  },
167
246
  onDisconnected: () => {
168
247
  this.options.log?.info?.("[machine] WebSocket disconnected, HTTP polling active");
169
248
  },
170
249
  onMessage: (msg) => {
250
+ const message = getRecord(msg.message);
171
251
  if (msg.type === "message") {
172
- const m = msg.message;
173
- const targetAgentId = msg.agentId ?? m?.agentId;
174
- const messageId = m?.id ?? msg.messageId ?? "";
175
- if (m && targetAgentId && this.chatHandler && this.rememberChat(messageId)) {
176
- this.chatHandler(targetAgentId, [{
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);
256
+ const messages = [{
257
+ workId: getString(message?.workId),
258
+ leaseToken: getString(message?.leaseToken),
177
259
  messageId,
178
- channelId: m.channelId ?? "",
179
- content: m.content ?? "",
180
- isMention: msg.isMention ?? false,
181
- context: msg.context ?? [],
182
- }]);
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),
267
+ agentProfile,
268
+ }];
269
+ this.chatHandler(targetAgentId, messages);
183
270
  }
184
271
  }
185
- if (msg.type === "task" && msg.agentId && this.taskHandler) {
186
- 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);
187
275
  }
188
276
  },
189
277
  });
@@ -201,9 +289,68 @@ export class ClawroomMachineClient {
201
289
  disconnect() {
202
290
  this._stopped = true;
203
291
  this._connected = false;
204
- if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
205
- if (this.pollTimer) clearInterval(this.pollTimer);
292
+ this.stopHeartbeat();
293
+ this.stopPolling();
206
294
  this.wsTransport?.disconnect();
207
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;
208
354
  }
355
+ return DEFAULT_AGENT_CHAT_PROFILE;
209
356
  }
package/src/protocol.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export type {
2
+ AgentChatProfile,
3
+ ChatAttachmentRef,
2
4
  AgentHeartbeat,
3
5
  AgentResultFile,
4
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
  }