@clawroom/openclaw 0.1.1 → 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/README.md CHANGED
@@ -15,7 +15,7 @@ openclaw gateway restart
15
15
  | Key | Description |
16
16
  |---|---|
17
17
  | `channels.clawroom.token` | Lobster token from the ClawRoom dashboard |
18
- | `channels.clawroom.endpoint` | WebSocket endpoint (default: `ws://localhost:3000/ws/agent`) |
18
+ | `channels.clawroom.endpoint` | API endpoint (default: `https://clawroom.site9.ai/api/agents`) |
19
19
  | `channels.clawroom.skills` | Optional array of skill tags to advertise |
20
20
  | `channels.clawroom.enabled` | Enable/disable the channel (default: `true`) |
21
21
 
@@ -23,7 +23,7 @@ openclaw gateway restart
23
23
 
24
24
  1. Sign up at ClawRoom and create a lobster token in the dashboard.
25
25
  2. Install this plugin and configure the token.
26
- 3. Restart your gateway. The plugin connects to ClawRoom via WebSocket.
26
+ 3. Restart your gateway. The plugin connects to ClawRoom via SSE + HTTP.
27
27
  4. Claim tasks from the dashboard. Your lobster executes them using OpenClaw's subagent runtime and reports results back automatically.
28
28
 
29
29
  ## Release
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawroom/openclaw",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "OpenClaw channel plugin for the Claw Room task marketplace",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/channel.ts CHANGED
@@ -7,7 +7,7 @@ import { setupTaskExecutor } from "./task-executor.js";
7
7
 
8
8
  // ── Config resolution ────────────────────────────────────────────────
9
9
 
10
- const DEFAULT_ENDPOINT = "wss://clawroom.site9.ai/ws/agent";
10
+ const DEFAULT_ENDPOINT = "https://clawroom.site9.ai/api/agents";
11
11
  const DEFAULT_ACCOUNT_ID = "default";
12
12
 
13
13
  interface ClawroomAccountConfig {
package/src/ws-client.ts CHANGED
@@ -5,7 +5,7 @@ import type {
5
5
  ServerTask,
6
6
  } from "@clawroom/sdk";
7
7
 
8
- // ── Reconnect policy (matches Telegram/Slack pattern) ─────────────────
8
+ // ── Reconnect policy ─────────────────────────────────────────────────
9
9
  const RECONNECT_POLICY = {
10
10
  initialMs: 2_000,
11
11
  maxMs: 30_000,
@@ -19,22 +19,8 @@ function computeBackoff(attempt: number): number {
19
19
  return Math.min(RECONNECT_POLICY.maxMs, Math.round(base + jitter));
20
20
  }
21
21
 
22
- // ── Non-recoverable close codes ───────────────────────────────────────
23
- const NON_RECOVERABLE_CODES = new Set([
24
- 4001, // Missing token
25
- 4002, // Invalid token
26
- 4003, // Token revoked / agent deleted
27
- ]);
28
-
29
- const WATCHDOG_INTERVAL_MS = 10_000;
30
- const DEAD_THRESHOLD_MS = 25_000;
31
-
32
- // WS fails this many times in a row → switch to HTTP polling
33
- const WS_FAIL_THRESHOLD = 5;
34
- // HTTP poll interval when in degraded mode
22
+ const HEARTBEAT_INTERVAL_MS = 30_000;
35
23
  const POLL_INTERVAL_MS = 10_000;
36
- // Try to restore WS every N poll cycles
37
- const WS_RESTORE_INTERVAL = 6; // = 60s
38
24
 
39
25
  // ── Types ─────────────────────────────────────────────────────────────
40
26
 
@@ -45,8 +31,8 @@ type ClaimRequestCallback = (task: ServerTask) => void;
45
31
  type ErrorCallback = (error: ServerMessage & { type: "server.error" }) => void;
46
32
  type DisconnectCallback = () => void;
47
33
  type WelcomeCallback = (agentId: string) => void;
48
- type FatalCallback = (reason: string, code: number) => void;
49
- type ModeChangeCallback = (mode: "ws" | "polling") => void;
34
+ type FatalCallback = (reason: string) => void;
35
+ type ModeChangeCallback = (mode: "sse" | "polling") => void;
50
36
 
51
37
  export type ClawroomWsClientOptions = {
52
38
  endpoint: string;
@@ -61,29 +47,25 @@ export type ClawroomWsClientOptions = {
61
47
  };
62
48
 
63
49
  /**
64
- * Hybrid WebSocket + HTTP Polling client.
50
+ * Claw Room agent client using SSE (server push) + HTTP (agent actions).
65
51
  *
66
- * Primary mode: WebSocket (real-time task push)
67
- * Fallback mode: HTTP Polling (when WS fails 5+ times consecutively)
52
+ * Primary mode: SSE stream at /api/agents/stream (real-time task push)
53
+ * Fallback mode: HTTP polling at /api/agents/poll (when SSE fails 3+ times)
68
54
  *
69
- * Automatically switches between modes:
70
- * - WS fails 5 times → degrade to polling
71
- * - While polling, try to restore WS every 60s
72
- * - WS reconnects successfully → switch back to WS, stop polling
55
+ * Agent→Server actions always use HTTP POST:
56
+ * /api/agents/heartbeat, /complete, /fail, /progress, /claim
73
57
  */
74
58
  export class ClawroomWsClient {
75
- private ws: WebSocket | null = null;
76
- private watchdog: ReturnType<typeof setInterval> | null = null;
59
+ private eventSource: EventSource | null = null;
60
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
77
61
  private pollTimer: ReturnType<typeof setInterval> | null = null;
78
- private lastActivity = 0;
79
62
  private reconnectAttempt = 0;
80
- private consecutiveWsFails = 0;
63
+ private consecutiveSseFails = 0;
81
64
  private reconnecting = false;
82
65
  private stopped = false;
83
- private lastCloseCode = 0;
84
- private mode: "ws" | "polling" = "ws";
66
+ private mode: "sse" | "polling" = "sse";
85
67
  private pollCycleCount = 0;
86
- private httpBase = ""; // derived from WS endpoint
68
+ private httpBase: string;
87
69
 
88
70
  private taskCallbacks: TaskCallback[] = [];
89
71
  private taskListCallbacks: TaskListCallback[] = [];
@@ -96,50 +78,48 @@ export class ClawroomWsClient {
96
78
  private modeChangeCallbacks: ModeChangeCallback[] = [];
97
79
 
98
80
  constructor(private readonly options: ClawroomWsClientOptions) {
99
- // Derive HTTP base URL from WS endpoint
81
+ // Derive HTTP base from endpoint
100
82
  // wss://clawroom.site9.ai/ws/agent → https://clawroom.site9.ai/api/agents
101
- // ws://localhost:3000/ws/agenthttp://localhost:3000/api/agents
83
+ // https://clawroom.site9.ai/api/agents/streamhttps://clawroom.site9.ai/api/agents
102
84
  const ep = options.endpoint;
103
- const httpUrl = ep.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
104
- this.httpBase = httpUrl.replace(/\/ws\/.*$/, "/api/agents");
85
+ if (ep.includes("/api/agents")) {
86
+ this.httpBase = ep.replace(/\/stream\/?$/, "");
87
+ } else {
88
+ const httpUrl = ep.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
89
+ this.httpBase = httpUrl.replace(/\/ws\/.*$/, "/api/agents");
90
+ }
105
91
  }
106
92
 
107
93
  connect(): void {
108
94
  this.stopped = false;
109
95
  this.reconnecting = false;
110
96
  this.reconnectAttempt = 0;
111
- this.consecutiveWsFails = 0;
112
- this.lastActivity = Date.now();
113
- this.lastCloseCode = 0;
114
- this.mode = "ws";
115
- this.doConnect();
116
- this.startWatchdog();
97
+ this.consecutiveSseFails = 0;
98
+ this.mode = "sse";
99
+ this.doConnectSSE();
100
+ this.startHeartbeat();
117
101
  }
118
102
 
119
103
  disconnect(): void {
120
104
  this.stopped = true;
121
- this.stopWatchdog();
105
+ this.stopHeartbeat();
122
106
  this.stopPolling();
123
- this.destroySocket();
107
+ this.destroySSE();
124
108
  }
125
109
 
126
110
  send(message: AgentMessage): void {
127
- if (this.mode === "polling") {
128
- // In polling mode, send via HTTP
129
- this.sendViaHttp(message).catch(() => {});
130
- return;
131
- }
132
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
133
- try { this.ws.send(JSON.stringify(message)); } catch {}
111
+ this.sendViaHttp(message).catch((err) => {
112
+ this.options.log?.warn?.(`[clawroom] send error: ${err}`);
113
+ });
134
114
  }
135
115
 
136
116
  get isAlive(): boolean { return !this.stopped; }
137
117
  get isConnected(): boolean {
138
- if (this.mode === "polling") return true; // polling is always "connected"
139
- return this.ws?.readyState === WebSocket.OPEN;
118
+ if (this.mode === "polling") return true;
119
+ return this.eventSource?.readyState === EventSource.OPEN;
140
120
  }
141
- get isFatal(): boolean { return NON_RECOVERABLE_CODES.has(this.lastCloseCode); }
142
- get currentMode(): "ws" | "polling" { return this.mode; }
121
+ get isFatal(): boolean { return false; }
122
+ get currentMode(): "sse" | "polling" { return this.mode; }
143
123
 
144
124
  onTask(cb: TaskCallback): void { this.taskCallbacks.push(cb); }
145
125
  onTaskList(cb: TaskListCallback): void { this.taskListCallbacks.push(cb); }
@@ -151,156 +131,111 @@ export class ClawroomWsClient {
151
131
  onFatal(cb: FatalCallback): void { this.fatalCallbacks.push(cb); }
152
132
  onModeChange(cb: ModeChangeCallback): void { this.modeChangeCallbacks.push(cb); }
153
133
 
154
- // ── Watchdog (WS mode only) ─────────────────────────────────────
134
+ // ── Heartbeat (HTTP POST, both modes) ───────────────────────────
155
135
 
156
- private startWatchdog(): void {
157
- this.stopWatchdog();
158
- this.watchdog = setInterval(() => this.tick(), WATCHDOG_INTERVAL_MS);
136
+ private startHeartbeat(): void {
137
+ this.stopHeartbeat();
138
+ this.heartbeatTimer = setInterval(() => {
139
+ if (this.stopped) return;
140
+ this.httpRequest("POST", "/heartbeat", {}).catch(() => {});
141
+ }, HEARTBEAT_INTERVAL_MS);
159
142
  }
160
143
 
161
- private stopWatchdog(): void {
162
- if (this.watchdog) { clearInterval(this.watchdog); this.watchdog = null; }
144
+ private stopHeartbeat(): void {
145
+ if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
163
146
  }
164
147
 
165
- private tick(): void {
166
- if (this.stopped || this.reconnecting || this.mode === "polling") return;
148
+ // ── SSE Connection ──────────────────────────────────────────────
149
+
150
+ private doConnectSSE(): void {
151
+ this.destroySSE();
152
+ const { token, deviceId, skills } = this.options;
153
+ const params = new URLSearchParams({
154
+ token,
155
+ deviceId,
156
+ skills: skills.join(","),
157
+ });
158
+ const url = `${this.httpBase}/stream?${params}`;
167
159
 
168
- const ws = this.ws;
160
+ this.options.log?.info?.(`[clawroom] SSE connecting to ${this.httpBase}/stream`);
169
161
 
170
- if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
171
- this.triggerReconnect("no socket");
162
+ try {
163
+ this.eventSource = new EventSource(url);
164
+ } catch {
165
+ this.triggerReconnect("EventSource creation failed");
172
166
  return;
173
167
  }
174
168
 
175
- if (ws.readyState === WebSocket.CONNECTING) return;
169
+ this.eventSource.onopen = () => {
170
+ this.options.log?.info?.("[clawroom] SSE connected");
171
+ this.reconnectAttempt = 0;
172
+ this.consecutiveSseFails = 0;
176
173
 
177
- const elapsed = Date.now() - this.lastActivity;
178
- if (elapsed > DEAD_THRESHOLD_MS) {
179
- this.options.log?.warn?.(`[clawroom] watchdog: no response for ${Math.round(elapsed / 1000)}s, forcing reconnect`);
180
- this.triggerReconnect("dead connection");
181
- return;
182
- }
174
+ if (this.mode === "polling") {
175
+ this.switchToSSE();
176
+ }
177
+ };
183
178
 
184
- this.ws?.readyState === WebSocket.OPEN && this.ws.send(JSON.stringify({ type: "agent.heartbeat" }));
179
+ this.eventSource.onmessage = (event) => {
180
+ this.handleMessage(event.data);
181
+ };
182
+
183
+ this.eventSource.onerror = () => {
184
+ this.options.log?.info?.("[clawroom] SSE error/disconnected");
185
+ this.destroySSE();
186
+ for (const cb of this.disconnectCallbacks) cb();
187
+ this.triggerReconnect("SSE error");
188
+ };
189
+ }
190
+
191
+ private destroySSE(): void {
192
+ if (this.eventSource) {
193
+ try { this.eventSource.close(); } catch {}
194
+ this.eventSource = null;
195
+ }
185
196
  }
186
197
 
187
198
  private triggerReconnect(reason: string): void {
188
199
  if (this.reconnecting || this.stopped) return;
189
200
 
190
- // Non-recoverable error
191
- if (NON_RECOVERABLE_CODES.has(this.lastCloseCode)) {
192
- const msg = `non-recoverable close code ${this.lastCloseCode}, giving up`;
193
- this.options.log?.error?.(`[clawroom] ${msg}`);
194
- this.stopped = true;
195
- this.stopWatchdog();
196
- this.stopPolling();
197
- for (const cb of this.fatalCallbacks) cb(msg, this.lastCloseCode);
198
- return;
199
- }
200
-
201
- this.consecutiveWsFails++;
201
+ this.consecutiveSseFails++;
202
202
 
203
- // Too many WS failures → degrade to HTTP polling
204
- if (this.consecutiveWsFails >= WS_FAIL_THRESHOLD && this.mode !== "polling") {
205
- this.options.log?.warn?.(`[clawroom] WS failed ${this.consecutiveWsFails} times, switching to HTTP polling`);
203
+ // SSE keeps failing → degrade to polling
204
+ if (this.consecutiveSseFails >= 3 && this.mode !== "polling") {
205
+ this.options.log?.warn?.(`[clawroom] SSE failed ${this.consecutiveSseFails} times, switching to HTTP polling`);
206
206
  this.switchToPolling();
207
207
  return;
208
208
  }
209
209
 
210
210
  this.reconnecting = true;
211
- this.destroySocket();
212
-
213
211
  const delayMs = computeBackoff(this.reconnectAttempt);
214
212
  this.reconnectAttempt++;
215
213
  this.options.log?.info?.(`[clawroom] reconnecting in ${delayMs}ms (${reason}, attempt ${this.reconnectAttempt})`);
216
214
 
217
215
  setTimeout(() => {
218
216
  this.reconnecting = false;
219
- if (!this.stopped) this.doConnect();
217
+ if (!this.stopped) this.doConnectSSE();
220
218
  }, delayMs);
221
219
  }
222
220
 
223
- // ── WS Connection ──────────────────────────────────────────────
224
-
225
- private doConnect(): void {
226
- this.destroySocket();
227
- const { endpoint, token } = this.options;
228
- const url = `${endpoint}?token=${encodeURIComponent(token)}`;
229
-
230
- this.options.log?.info?.(`[clawroom] connecting to ${endpoint}`);
231
-
232
- try {
233
- this.ws = new WebSocket(url);
234
- } catch {
235
- return; // watchdog will retry
236
- }
237
-
238
- this.ws.addEventListener("open", () => {
239
- this.options.log?.info?.("[clawroom] WS connected");
240
- this.reconnectAttempt = 0;
241
- this.consecutiveWsFails = 0;
242
- this.lastCloseCode = 0;
243
- this.lastActivity = Date.now();
244
-
245
- // If we were polling, switch back to WS
246
- if (this.mode === "polling") {
247
- this.switchToWs();
248
- }
249
-
250
- this.ws?.send(JSON.stringify({
251
- type: "agent.hello",
252
- deviceId: this.options.deviceId,
253
- skills: this.options.skills,
254
- }));
255
- });
256
-
257
- this.ws.addEventListener("message", (event) => {
258
- this.lastActivity = Date.now();
259
- this.handleMessage(event.data as string);
260
- });
261
-
262
- this.ws.addEventListener("close", (event) => {
263
- this.lastCloseCode = event.code;
264
- const reason = event.reason || `code ${event.code}`;
265
- this.options.log?.info?.(`[clawroom] WS disconnected (${reason})`);
266
- this.ws = null;
267
- for (const cb of this.disconnectCallbacks) cb();
268
- if (NON_RECOVERABLE_CODES.has(event.code)) {
269
- this.triggerReconnect(reason);
270
- }
271
- });
272
-
273
- this.ws.addEventListener("error", () => {});
274
- }
275
-
276
- private destroySocket(): void {
277
- if (this.ws) {
278
- try { this.ws.close(); } catch {}
279
- this.ws = null;
280
- }
281
- }
282
-
283
- // ── HTTP Polling (fallback mode) ────────────────────────────────
221
+ // ── Polling fallback ────────────────────────────────────────────
284
222
 
285
223
  private switchToPolling(): void {
286
224
  this.mode = "polling";
287
- this.destroySocket();
225
+ this.destroySSE();
288
226
  this.options.log?.info?.("[clawroom] entering HTTP polling mode");
289
227
  for (const cb of this.modeChangeCallbacks) cb("polling");
290
228
 
291
- // Send initial heartbeat
292
- this.httpHeartbeat().catch(() => {});
293
-
294
229
  this.pollCycleCount = 0;
295
230
  this.stopPolling();
296
231
  this.pollTimer = setInterval(() => this.pollTick(), POLL_INTERVAL_MS);
297
232
  }
298
233
 
299
- private switchToWs(): void {
300
- this.options.log?.info?.("[clawroom] WS restored, switching back from polling");
234
+ private switchToSSE(): void {
235
+ this.options.log?.info?.("[clawroom] SSE restored, switching back from polling");
301
236
  this.stopPolling();
302
- this.mode = "ws";
303
- for (const cb of this.modeChangeCallbacks) cb("ws");
237
+ this.mode = "sse";
238
+ for (const cb of this.modeChangeCallbacks) cb("sse");
304
239
  }
305
240
 
306
241
  private stopPolling(): void {
@@ -309,20 +244,13 @@ export class ClawroomWsClient {
309
244
 
310
245
  private async pollTick(): Promise<void> {
311
246
  if (this.stopped) return;
312
-
313
247
  this.pollCycleCount++;
314
248
 
315
- // Heartbeat
316
- await this.httpHeartbeat().catch(() => {});
317
-
318
- // Poll for tasks — task-executor handles execution via callbacks
319
- // (poll endpoint auto-claims, so we just need to trigger task callbacks)
320
249
  try {
321
250
  const res = await this.httpRequest("POST", "/poll", {});
322
251
  if (res.task) {
323
252
  const task = res.task as ServerTask;
324
253
  this.options.log?.info?.(`[clawroom] [poll] received task ${task.taskId}: ${task.title}`);
325
- // Fire task + claim_ack callbacks so task-executor picks it up
326
254
  for (const cb of this.taskCallbacks) cb(task);
327
255
  for (const cb of this.claimAckCallbacks) cb({ type: "server.claim_ack", taskId: task.taskId, ok: true });
328
256
  }
@@ -330,16 +258,14 @@ export class ClawroomWsClient {
330
258
  this.options.log?.warn?.(`[clawroom] [poll] error: ${err}`);
331
259
  }
332
260
 
333
- // Periodically try to restore WS
334
- if (this.pollCycleCount % WS_RESTORE_INTERVAL === 0) {
335
- this.options.log?.info?.("[clawroom] [poll] attempting WS restore...");
336
- this.doConnect(); // if WS connects, switchToWs() is called from open handler
261
+ // Try to restore SSE every 60s
262
+ if (this.pollCycleCount % 6 === 0) {
263
+ this.options.log?.info?.("[clawroom] [poll] attempting SSE restore...");
264
+ this.doConnectSSE();
337
265
  }
338
266
  }
339
267
 
340
- private async httpHeartbeat(): Promise<void> {
341
- await this.httpRequest("POST", "/heartbeat", {}).catch(() => {});
342
- }
268
+ // ── HTTP send ───────────────────────────────────────────────────
343
269
 
344
270
  private async sendViaHttp(message: AgentMessage): Promise<void> {
345
271
  switch (message.type) {
@@ -364,9 +290,13 @@ export class ClawroomWsClient {
364
290
  });
365
291
  break;
366
292
  case "agent.heartbeat":
367
- await this.httpHeartbeat();
293
+ await this.httpRequest("POST", "/heartbeat", {});
294
+ break;
295
+ case "agent.claim":
296
+ await this.httpRequest("POST", "/claim", { taskId: message.taskId });
297
+ break;
298
+ default:
368
299
  break;
369
- // claim, hello, release — not needed in polling mode
370
300
  }
371
301
  }
372
302
 
@@ -381,6 +311,13 @@ export class ClawroomWsClient {
381
311
  });
382
312
  if (!res.ok) {
383
313
  const text = await res.text().catch(() => "");
314
+ if (res.status === 401) {
315
+ this.stopped = true;
316
+ this.stopHeartbeat();
317
+ this.stopPolling();
318
+ this.destroySSE();
319
+ for (const cb of this.fatalCallbacks) cb(`Unauthorized: ${text}`);
320
+ }
384
321
  throw new Error(`HTTP ${res.status}: ${text}`);
385
322
  }
386
323
  return res.json();