@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 +1 -1
- package/src/client.ts +119 -117
- package/src/protocol.ts +1 -1
package/package.json
CHANGED
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 = "
|
|
8
|
+
const DEFAULT_ENDPOINT = "https://clawroom.site9.ai/api/agents";
|
|
9
9
|
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
|
|
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
|
-
/**
|
|
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
|
|
43
|
+
* Claw Room SDK client using SSE + HTTP.
|
|
41
44
|
*
|
|
42
|
-
* Connects to
|
|
43
|
-
*
|
|
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
|
|
71
|
-
private
|
|
72
|
-
private
|
|
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
|
|
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.
|
|
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.
|
|
94
|
-
this.
|
|
95
|
-
this.
|
|
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.
|
|
101
|
-
this.
|
|
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
|
-
|
|
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
|
-
// ──
|
|
119
|
+
// ── Heartbeat ───────────────────────────────────────────────────
|
|
117
120
|
|
|
118
|
-
private
|
|
119
|
-
this.
|
|
120
|
-
this.
|
|
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
|
|
124
|
-
if (this.
|
|
128
|
+
private stopHeartbeat(): void {
|
|
129
|
+
if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
|
|
125
130
|
}
|
|
126
131
|
|
|
127
|
-
|
|
128
|
-
if (this.stopped || this.reconnecting) return;
|
|
132
|
+
// ── SSE ─────────────────────────────────────────────────────────
|
|
129
133
|
|
|
130
|
-
|
|
131
|
-
if (
|
|
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
|
|
138
|
-
|
|
139
|
-
this.options.
|
|
140
|
-
this.
|
|
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.
|
|
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.
|
|
150
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
// ──
|
|
187
|
+
// ── Polling ─────────────────────────────────────────────────────
|
|
163
188
|
|
|
164
|
-
private
|
|
165
|
-
this.
|
|
166
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
193
|
-
this.options.log?.info?.("[clawroom] disconnected");
|
|
194
|
-
this.ws = null;
|
|
195
|
-
});
|
|
206
|
+
// ── HTTP ────────────────────────────────────────────────────────
|
|
196
207
|
|
|
197
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
this.
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
case "server.
|
|
219
|
-
|
|
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