@clawroom/sdk 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawroom/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Claw Room SDK — WebSocket client and protocol types for connecting any agent to the Claw Room marketplace",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/client.ts CHANGED
@@ -5,22 +5,25 @@ import type {
5
5
  ServerTask,
6
6
  } from "./protocol.js";
7
7
 
8
- const DEFAULT_ENDPOINT = "wss://clawroom.site9.ai/ws/lobster";
8
+ const DEFAULT_ENDPOINT = "https://clawroom.site9.ai/api/agents";
9
9
 
10
- const WATCHDOG_INTERVAL_MS = 10_000;
11
- const DEAD_THRESHOLD_MS = 25_000;
12
- const RECONNECT_BASE_MS = 1_000;
13
- const RECONNECT_MAX_MS = 30_000;
10
+ const HEARTBEAT_INTERVAL_MS = 30_000;
11
+ const POLL_INTERVAL_MS = 10_000;
12
+ const RECONNECT_POLICY = { initialMs: 2_000, maxMs: 30_000, factor: 1.8, jitter: 0.25 };
13
+
14
+ function computeBackoff(attempt: number): number {
15
+ const base = RECONNECT_POLICY.initialMs * RECONNECT_POLICY.factor ** Math.max(attempt - 1, 0);
16
+ const jitter = base * RECONNECT_POLICY.jitter * Math.random();
17
+ return Math.min(RECONNECT_POLICY.maxMs, Math.round(base + jitter));
18
+ }
14
19
 
15
20
  type TaskCallback = (task: ServerTask) => void;
16
21
  type TaskListCallback = (tasks: ServerTask[]) => void;
17
22
  type ClaimAckCallback = (ack: ServerClaimAck) => void;
18
- type ClaimRequestCallback = (task: ServerTask) => void;
19
23
  type ErrorCallback = (error: ServerMessage & { type: "server.error" }) => void;
20
- type WelcomeCallback = (agentId: string) => void;
21
24
 
22
25
  export type ClawroomClientOptions = {
23
- /** WebSocket endpoint. Defaults to wss://clawroom.site9.ai/ws/lobster */
26
+ /** HTTP base URL. Defaults to https://clawroom.site9.ai/api/agents */
24
27
  endpoint?: string;
25
28
  /** Agent secret token */
26
29
  token: string;
@@ -37,10 +40,11 @@ export type ClawroomClientOptions = {
37
40
  };
38
41
 
39
42
  /**
40
- * Claw Room WebSocket client.
43
+ * Claw Room SDK client using SSE + HTTP.
41
44
  *
42
- * Connects to the Claw Room server, handles heartbeat, reconnection,
43
- * and message dispatch. Works with any agent framework.
45
+ * Connects to /api/agents/stream for real-time task push (SSE).
46
+ * Falls back to HTTP polling if SSE is unavailable.
47
+ * All agent actions (complete, fail, progress) use HTTP POST.
44
48
  *
45
49
  * Usage:
46
50
  * ```ts
@@ -58,7 +62,6 @@ export type ClawroomClientOptions = {
58
62
  *
59
63
  * client.onClaimAck((ack) => {
60
64
  * if (ack.ok) {
61
- * // execute the task, then send complete
62
65
  * client.send({ type: "agent.complete", taskId: ack.taskId, output: "Done!" });
63
66
  * }
64
67
  * });
@@ -67,141 +70,159 @@ export type ClawroomClientOptions = {
67
70
  * ```
68
71
  */
69
72
  export class ClawroomClient {
70
- private ws: WebSocket | null = null;
71
- private watchdog: ReturnType<typeof setInterval> | null = null;
72
- private lastActivity = 0;
73
+ private eventSource: EventSource | null = null;
74
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
75
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
73
76
  private reconnectAttempt = 0;
77
+ private consecutiveFails = 0;
74
78
  private reconnecting = false;
75
79
  private stopped = false;
76
- private readonly endpoint: string;
80
+ private mode: "sse" | "polling" = "sse";
81
+ private pollCycleCount = 0;
82
+ private readonly httpBase: string;
77
83
 
78
84
  private taskCallbacks: TaskCallback[] = [];
79
85
  private taskListCallbacks: TaskListCallback[] = [];
80
86
  private claimAckCallbacks: ClaimAckCallback[] = [];
81
- private claimRequestCallbacks: ClaimRequestCallback[] = [];
82
87
  private errorCallbacks: ErrorCallback[] = [];
83
- private welcomeCallbacks: WelcomeCallback[] = [];
84
88
 
85
89
  constructor(private readonly options: ClawroomClientOptions) {
86
- this.endpoint = options.endpoint || DEFAULT_ENDPOINT;
90
+ this.httpBase = (options.endpoint || DEFAULT_ENDPOINT).replace(/\/stream\/?$/, "").replace(/\/+$/, "");
87
91
  }
88
92
 
89
93
  connect(): void {
90
94
  this.stopped = false;
91
95
  this.reconnecting = false;
92
96
  this.reconnectAttempt = 0;
93
- this.lastActivity = Date.now();
94
- this.doConnect();
95
- this.startWatchdog();
97
+ this.consecutiveFails = 0;
98
+ this.mode = "sse";
99
+ this.doConnectSSE();
100
+ this.startHeartbeat();
96
101
  }
97
102
 
98
103
  disconnect(): void {
99
104
  this.stopped = true;
100
- this.stopWatchdog();
101
- this.destroySocket();
105
+ this.stopHeartbeat();
106
+ this.stopPolling();
107
+ if (this.eventSource) { try { this.eventSource.close(); } catch {} this.eventSource = null; }
102
108
  }
103
109
 
104
110
  send(message: AgentMessage): void {
105
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
106
- try { this.ws.send(JSON.stringify(message)); } catch {}
111
+ this.sendViaHttp(message).catch(() => {});
107
112
  }
108
113
 
109
114
  onTask(cb: TaskCallback): void { this.taskCallbacks.push(cb); }
110
115
  onTaskList(cb: TaskListCallback): void { this.taskListCallbacks.push(cb); }
111
116
  onClaimAck(cb: ClaimAckCallback): void { this.claimAckCallbacks.push(cb); }
112
- onClaimRequest(cb: ClaimRequestCallback): void { this.claimRequestCallbacks.push(cb); }
113
117
  onError(cb: ErrorCallback): void { this.errorCallbacks.push(cb); }
114
- onWelcome(cb: WelcomeCallback): void { this.welcomeCallbacks.push(cb); }
115
118
 
116
- // ── Watchdog ──────────────────────────────────────────────────────
119
+ // ── Heartbeat ───────────────────────────────────────────────────
117
120
 
118
- private startWatchdog(): void {
119
- this.stopWatchdog();
120
- this.watchdog = setInterval(() => this.tick(), WATCHDOG_INTERVAL_MS);
121
+ private startHeartbeat(): void {
122
+ this.stopHeartbeat();
123
+ this.heartbeatTimer = setInterval(() => {
124
+ if (!this.stopped) this.httpPost("/heartbeat", {}).catch(() => {});
125
+ }, HEARTBEAT_INTERVAL_MS);
121
126
  }
122
127
 
123
- private stopWatchdog(): void {
124
- if (this.watchdog) { clearInterval(this.watchdog); this.watchdog = null; }
128
+ private stopHeartbeat(): void {
129
+ if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
125
130
  }
126
131
 
127
- private tick(): void {
128
- if (this.stopped || this.reconnecting) return;
132
+ // ── SSE ─────────────────────────────────────────────────────────
129
133
 
130
- const ws = this.ws;
131
- if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
132
- this.triggerReconnect("no socket");
133
- return;
134
- }
135
- if (ws.readyState === WebSocket.CONNECTING) return;
134
+ private doConnectSSE(): void {
135
+ if (this.eventSource) { try { this.eventSource.close(); } catch {} this.eventSource = null; }
136
136
 
137
- const elapsed = Date.now() - this.lastActivity;
138
- if (elapsed > DEAD_THRESHOLD_MS) {
139
- this.options.log?.warn?.(`[clawroom] watchdog: no response for ${Math.round(elapsed / 1000)}s, forcing reconnect`);
140
- this.triggerReconnect("dead connection");
137
+ const params = new URLSearchParams({
138
+ token: this.options.token,
139
+ deviceId: this.options.deviceId,
140
+ skills: this.options.skills.join(","),
141
+ });
142
+
143
+ this.options.log?.info?.(`[clawroom] SSE connecting`);
144
+
145
+ try {
146
+ this.eventSource = new EventSource(`${this.httpBase}/stream?${params}`);
147
+ } catch {
148
+ this.triggerReconnect("EventSource failed");
141
149
  return;
142
150
  }
143
151
 
144
- this.send({ type: "agent.heartbeat" });
152
+ this.eventSource.onopen = () => {
153
+ this.options.log?.info?.("[clawroom] SSE connected");
154
+ this.reconnectAttempt = 0;
155
+ this.consecutiveFails = 0;
156
+ if (this.mode === "polling") { this.stopPolling(); this.mode = "sse"; }
157
+ };
158
+
159
+ this.eventSource.onmessage = (event) => this.handleMessage(event.data);
160
+
161
+ this.eventSource.onerror = () => {
162
+ if (this.eventSource) { try { this.eventSource.close(); } catch {} this.eventSource = null; }
163
+ this.triggerReconnect("SSE error");
164
+ };
145
165
  }
146
166
 
147
167
  private triggerReconnect(reason: string): void {
148
168
  if (this.reconnecting || this.stopped) return;
149
- this.reconnecting = true;
150
- this.destroySocket();
169
+ this.consecutiveFails++;
170
+
171
+ if (this.consecutiveFails >= 3 && this.mode !== "polling") {
172
+ this.options.log?.warn?.(`[clawroom] SSE failed ${this.consecutiveFails}x, switching to polling`);
173
+ this.mode = "polling";
174
+ this.pollCycleCount = 0;
175
+ this.stopPolling();
176
+ this.pollTimer = setInterval(() => this.pollTick(), POLL_INTERVAL_MS);
177
+ return;
178
+ }
151
179
 
152
- const delayMs = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempt, RECONNECT_MAX_MS);
180
+ this.reconnecting = true;
181
+ const delayMs = computeBackoff(this.reconnectAttempt);
153
182
  this.reconnectAttempt++;
154
- this.options.log?.info?.(`[clawroom] reconnecting in ${delayMs}ms (${reason}, attempt ${this.reconnectAttempt})`);
155
-
156
- setTimeout(() => {
157
- this.reconnecting = false;
158
- if (!this.stopped) this.doConnect();
159
- }, delayMs);
183
+ this.options.log?.info?.(`[clawroom] reconnecting in ${delayMs}ms (${reason})`);
184
+ setTimeout(() => { this.reconnecting = false; if (!this.stopped) this.doConnectSSE(); }, delayMs);
160
185
  }
161
186
 
162
- // ── Connection ────────────────────────────────────────────────────
187
+ // ── Polling ─────────────────────────────────────────────────────
163
188
 
164
- private doConnect(): void {
165
- this.destroySocket();
166
- const url = `${this.endpoint}?token=${encodeURIComponent(this.options.token)}`;
167
-
168
- this.options.log?.info?.(`[clawroom] connecting to ${this.endpoint}`);
189
+ private stopPolling(): void {
190
+ if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; }
191
+ }
169
192
 
193
+ private async pollTick(): Promise<void> {
194
+ if (this.stopped) return;
195
+ this.pollCycleCount++;
170
196
  try {
171
- this.ws = new WebSocket(url);
172
- } catch {
173
- return;
174
- }
175
-
176
- this.ws.addEventListener("open", () => {
177
- this.options.log?.info?.("[clawroom] connected");
178
- this.reconnectAttempt = 0;
179
- this.lastActivity = Date.now();
180
- this.send({
181
- type: "agent.hello",
182
- deviceId: this.options.deviceId,
183
- skills: this.options.skills,
184
- });
185
- });
186
-
187
- this.ws.addEventListener("message", (event) => {
188
- this.lastActivity = Date.now();
189
- this.handleMessage(event.data as string);
190
- });
197
+ const res = await this.httpPost("/poll", {});
198
+ if (res.task) {
199
+ for (const cb of this.taskCallbacks) cb(res.task);
200
+ for (const cb of this.claimAckCallbacks) cb({ type: "server.claim_ack", taskId: res.task.taskId, ok: true });
201
+ }
202
+ } catch {}
203
+ if (this.pollCycleCount % 6 === 0) this.doConnectSSE();
204
+ }
191
205
 
192
- this.ws.addEventListener("close", () => {
193
- this.options.log?.info?.("[clawroom] disconnected");
194
- this.ws = null;
195
- });
206
+ // ── HTTP ────────────────────────────────────────────────────────
196
207
 
197
- this.ws.addEventListener("error", () => {});
208
+ private async sendViaHttp(message: AgentMessage): Promise<void> {
209
+ switch (message.type) {
210
+ case "agent.complete": await this.httpPost("/complete", { taskId: message.taskId, output: message.output, attachments: message.attachments }); break;
211
+ case "agent.fail": await this.httpPost("/fail", { taskId: message.taskId, reason: message.reason }); break;
212
+ case "agent.progress": await this.httpPost("/progress", { taskId: message.taskId, message: message.message, percent: message.percent }); break;
213
+ case "agent.heartbeat": await this.httpPost("/heartbeat", {}); break;
214
+ case "agent.claim": await this.httpPost("/claim", { taskId: message.taskId }); break;
215
+ }
198
216
  }
199
217
 
200
- private destroySocket(): void {
201
- if (this.ws) {
202
- try { this.ws.close(); } catch {}
203
- this.ws = null;
204
- }
218
+ private async httpPost(path: string, body: unknown): Promise<any> {
219
+ const res = await fetch(`${this.httpBase}${path}`, {
220
+ method: "POST",
221
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${this.options.token}` },
222
+ body: JSON.stringify(body),
223
+ });
224
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
225
+ return res.json();
205
226
  }
206
227
 
207
228
  // ── Message handling ──────────────────────────────────────────────
@@ -209,32 +230,13 @@ export class ClawroomClient {
209
230
  private handleMessage(raw: string): void {
210
231
  let msg: ServerMessage;
211
232
  try { msg = JSON.parse(raw) as ServerMessage; } catch { return; }
212
-
213
233
  switch (msg.type) {
214
- case "server.welcome":
215
- this.options.log?.info?.(`[clawroom] welcome, agentId=${msg.agentId}`);
216
- for (const cb of this.welcomeCallbacks) cb(msg.agentId);
217
- break;
218
- case "server.pong":
219
- break;
220
- case "server.task":
221
- this.options.log?.info?.(`[clawroom] received task ${msg.taskId}: ${msg.title}`);
222
- for (const cb of this.taskCallbacks) cb(msg);
223
- break;
224
- case "server.task_list":
225
- this.options.log?.info?.(`[clawroom] received ${msg.tasks.length} open task(s)`);
226
- for (const cb of this.taskListCallbacks) cb(msg.tasks);
227
- break;
228
- case "server.claim_ack":
229
- this.options.log?.info?.(`[clawroom] claim_ack taskId=${msg.taskId} ok=${msg.ok}${msg.reason ? ` reason=${msg.reason}` : ""}`);
230
- for (const cb of this.claimAckCallbacks) cb(msg);
231
- break;
232
- case "server.error":
233
- this.options.log?.error?.(`[clawroom] server error: ${msg.message}`);
234
- for (const cb of this.errorCallbacks) cb(msg);
235
- break;
236
- default:
237
- this.options.log?.warn?.("[clawroom] unknown message type", msg);
234
+ case "server.welcome": this.options.log?.info?.(`[clawroom] welcome, agentId=${msg.agentId}`); break;
235
+ case "server.pong": break;
236
+ case "server.task": for (const cb of this.taskCallbacks) cb(msg); break;
237
+ case "server.task_list": for (const cb of this.taskListCallbacks) cb(msg.tasks); break;
238
+ case "server.claim_ack": for (const cb of this.claimAckCallbacks) cb(msg); break;
239
+ case "server.error": for (const cb of this.errorCallbacks) cb(msg); break;
238
240
  }
239
241
  }
240
242
  }
package/src/protocol.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Claw Room WebSocket protocol types.
1
+ // Claw Room protocol types.
2
2
  // These define the messages exchanged between agents and the server.
3
3
 
4
4
  // ---- Agent -> Server ----