@clawroom/openclaw 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/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/lobster`) |
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.0",
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 {
@@ -145,26 +145,93 @@ export const clawroomPlugin: ChannelPlugin<ResolvedClawroomAccount> = {
145
145
  log,
146
146
  });
147
147
 
148
+ // Track connection state for OpenClaw health monitor
149
+ const publishConnected = () => {
150
+ ctx.setStatus({
151
+ accountId: account.accountId,
152
+ running: true,
153
+ connected: true,
154
+ lastStartAt: Date.now(),
155
+ lastStopAt: null,
156
+ lastError: null,
157
+ lastEventAt: Date.now(),
158
+ });
159
+ };
160
+
161
+ const publishDisconnected = (error?: string) => {
162
+ ctx.setStatus({
163
+ accountId: account.accountId,
164
+ running: true,
165
+ connected: false,
166
+ lastStartAt: null,
167
+ lastStopAt: null,
168
+ lastError: error ?? "disconnected",
169
+ });
170
+ };
171
+
172
+ // Wire up WS events to OpenClaw health status
173
+ client.onWelcome(() => publishConnected());
174
+ client.onTask(() => {
175
+ // Any server message = update lastEventAt so gateway knows we're alive
176
+ ctx.setStatus({
177
+ accountId: account.accountId,
178
+ running: true,
179
+ connected: true,
180
+ lastEventAt: Date.now(),
181
+ lastStartAt: Date.now(),
182
+ lastStopAt: null,
183
+ lastError: null,
184
+ });
185
+ });
186
+ client.onDisconnect(() => publishDisconnected());
187
+ client.onModeChange((mode) => {
188
+ log?.info?.(`[clawroom] mode changed to ${mode}`);
189
+ ctx.setStatus({
190
+ accountId: account.accountId,
191
+ running: true,
192
+ connected: mode === "polling" ? true : client.isConnected,
193
+ lastEventAt: Date.now(),
194
+ lastStartAt: Date.now(),
195
+ lastStopAt: null,
196
+ lastError: mode === "polling" ? "degraded: HTTP polling" : null,
197
+ });
198
+ });
199
+ client.onFatal((reason, code) => {
200
+ log?.error?.(`[clawroom] fatal error (code ${code}): ${reason}`);
201
+ ctx.setStatus({
202
+ accountId: account.accountId,
203
+ running: false,
204
+ connected: false,
205
+ lastStopAt: Date.now(),
206
+ lastStartAt: null,
207
+ lastError: `Fatal: ${reason}`,
208
+ });
209
+ });
210
+
148
211
  setupTaskExecutor({ ws: client, runtime, log });
149
212
  client.connect();
150
213
  activeClient = client;
151
214
 
152
- ctx.setStatus({
153
- accountId: account.accountId,
154
- running: true,
155
- lastStartAt: Date.now(),
156
- lastStopAt: null,
157
- lastError: null,
158
- });
215
+ publishDisconnected("connecting...");
216
+
217
+ // Health check: if client somehow stopped, restart it.
218
+ const healthCheck = setInterval(() => {
219
+ if (!client.isAlive) {
220
+ log?.warn?.("[clawroom] client died unexpectedly, restarting...");
221
+ client.connect();
222
+ }
223
+ }, 30_000);
159
224
 
160
225
  // Keep alive until gateway shuts down — only then disconnect
161
226
  await new Promise<void>((resolve) => {
162
227
  ctx.abortSignal.addEventListener("abort", () => {
228
+ clearInterval(healthCheck);
163
229
  client.disconnect();
164
230
  activeClient = null;
165
231
  ctx.setStatus({
166
232
  accountId: account.accountId,
167
233
  running: false,
234
+ connected: false,
168
235
  lastStartAt: null,
169
236
  lastStopAt: Date.now(),
170
237
  lastError: null,
package/src/ws-client.ts CHANGED
@@ -5,17 +5,34 @@ import type {
5
5
  ServerTask,
6
6
  } from "@clawroom/sdk";
7
7
 
8
- const WATCHDOG_INTERVAL_MS = 10_000;
9
- const DEAD_THRESHOLD_MS = 25_000; // 2.5 missed heartbeats = dead
10
- const RECONNECT_BASE_MS = 1_000;
11
- const RECONNECT_MAX_MS = 30_000;
8
+ // ── Reconnect policy ─────────────────────────────────────────────────
9
+ const RECONNECT_POLICY = {
10
+ initialMs: 2_000,
11
+ maxMs: 30_000,
12
+ factor: 1.8,
13
+ jitter: 0.25,
14
+ };
15
+
16
+ function computeBackoff(attempt: number): number {
17
+ const base = RECONNECT_POLICY.initialMs * RECONNECT_POLICY.factor ** Math.max(attempt - 1, 0);
18
+ const jitter = base * RECONNECT_POLICY.jitter * Math.random();
19
+ return Math.min(RECONNECT_POLICY.maxMs, Math.round(base + jitter));
20
+ }
21
+
22
+ const HEARTBEAT_INTERVAL_MS = 30_000;
23
+ const POLL_INTERVAL_MS = 10_000;
24
+
25
+ // ── Types ─────────────────────────────────────────────────────────────
12
26
 
13
27
  type TaskCallback = (task: ServerTask) => void;
14
28
  type TaskListCallback = (tasks: ServerTask[]) => void;
15
29
  type ClaimAckCallback = (ack: ServerClaimAck) => void;
16
30
  type ClaimRequestCallback = (task: ServerTask) => void;
17
31
  type ErrorCallback = (error: ServerMessage & { type: "server.error" }) => void;
32
+ type DisconnectCallback = () => void;
18
33
  type WelcomeCallback = (agentId: string) => void;
34
+ type FatalCallback = (reason: string) => void;
35
+ type ModeChangeCallback = (mode: "sse" | "polling") => void;
19
36
 
20
37
  export type ClawroomWsClientOptions = {
21
38
  endpoint: string;
@@ -30,163 +47,280 @@ export type ClawroomWsClientOptions = {
30
47
  };
31
48
 
32
49
  /**
33
- * WebSocket client with aggressive reconnection.
50
+ * Claw Room agent client using SSE (server push) + HTTP (agent actions).
51
+ *
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)
34
54
  *
35
- * Watchdog strategy:
36
- * - Every 10s, send heartbeat and record the time
37
- * - Every 10s, check if last successful pong was > 25s ago
38
- * - If yes: server is dead. Destroy socket + HTTP verify + reconnect
39
- * - HTTP verify is fire-and-forget; reconnect is triggered regardless
40
- * - Watchdog NEVER stops unless disconnect() is called
55
+ * Agent→Server actions always use HTTP POST:
56
+ * /api/agents/heartbeat, /complete, /fail, /progress, /claim
41
57
  */
42
58
  export class ClawroomWsClient {
43
- private ws: WebSocket | null = null;
44
- private watchdog: ReturnType<typeof setInterval> | null = null;
45
- private lastActivity = 0; // timestamp of last successful server response
59
+ private eventSource: EventSource | null = null;
60
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
61
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
46
62
  private reconnectAttempt = 0;
63
+ private consecutiveSseFails = 0;
47
64
  private reconnecting = false;
48
65
  private stopped = false;
66
+ private mode: "sse" | "polling" = "sse";
67
+ private pollCycleCount = 0;
68
+ private httpBase: string;
49
69
 
50
70
  private taskCallbacks: TaskCallback[] = [];
51
71
  private taskListCallbacks: TaskListCallback[] = [];
52
72
  private claimAckCallbacks: ClaimAckCallback[] = [];
53
73
  private claimRequestCallbacks: ClaimRequestCallback[] = [];
54
74
  private errorCallbacks: ErrorCallback[] = [];
75
+ private disconnectCallbacks: DisconnectCallback[] = [];
55
76
  private welcomeCallbacks: WelcomeCallback[] = [];
56
-
57
- constructor(private readonly options: ClawroomWsClientOptions) {}
77
+ private fatalCallbacks: FatalCallback[] = [];
78
+ private modeChangeCallbacks: ModeChangeCallback[] = [];
79
+
80
+ constructor(private readonly options: ClawroomWsClientOptions) {
81
+ // Derive HTTP base from endpoint
82
+ // wss://clawroom.site9.ai/ws/agent → https://clawroom.site9.ai/api/agents
83
+ // https://clawroom.site9.ai/api/agents/stream → https://clawroom.site9.ai/api/agents
84
+ const ep = options.endpoint;
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
+ }
91
+ }
58
92
 
59
93
  connect(): void {
60
94
  this.stopped = false;
61
95
  this.reconnecting = false;
62
96
  this.reconnectAttempt = 0;
63
- this.lastActivity = Date.now();
64
- this.doConnect();
65
- this.startWatchdog();
97
+ this.consecutiveSseFails = 0;
98
+ this.mode = "sse";
99
+ this.doConnectSSE();
100
+ this.startHeartbeat();
66
101
  }
67
102
 
68
103
  disconnect(): void {
69
104
  this.stopped = true;
70
- this.stopWatchdog();
71
- this.destroySocket();
105
+ this.stopHeartbeat();
106
+ this.stopPolling();
107
+ this.destroySSE();
72
108
  }
73
109
 
74
110
  send(message: AgentMessage): void {
75
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
76
- try { this.ws.send(JSON.stringify(message)); } catch {}
111
+ this.sendViaHttp(message).catch((err) => {
112
+ this.options.log?.warn?.(`[clawroom] send error: ${err}`);
113
+ });
77
114
  }
78
115
 
116
+ get isAlive(): boolean { return !this.stopped; }
117
+ get isConnected(): boolean {
118
+ if (this.mode === "polling") return true;
119
+ return this.eventSource?.readyState === EventSource.OPEN;
120
+ }
121
+ get isFatal(): boolean { return false; }
122
+ get currentMode(): "sse" | "polling" { return this.mode; }
123
+
79
124
  onTask(cb: TaskCallback): void { this.taskCallbacks.push(cb); }
80
125
  onTaskList(cb: TaskListCallback): void { this.taskListCallbacks.push(cb); }
81
126
  onClaimAck(cb: ClaimAckCallback): void { this.claimAckCallbacks.push(cb); }
82
127
  onClaimRequest(cb: ClaimRequestCallback): void { this.claimRequestCallbacks.push(cb); }
83
128
  onError(cb: ErrorCallback): void { this.errorCallbacks.push(cb); }
129
+ onDisconnect(cb: DisconnectCallback): void { this.disconnectCallbacks.push(cb); }
84
130
  onWelcome(cb: WelcomeCallback): void { this.welcomeCallbacks.push(cb); }
131
+ onFatal(cb: FatalCallback): void { this.fatalCallbacks.push(cb); }
132
+ onModeChange(cb: ModeChangeCallback): void { this.modeChangeCallbacks.push(cb); }
85
133
 
86
- // ── Watchdog ──────────────────────────────────────────────────────
134
+ // ── Heartbeat (HTTP POST, both modes) ───────────────────────────
87
135
 
88
- private startWatchdog(): void {
89
- this.stopWatchdog();
90
- 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);
91
142
  }
92
143
 
93
- private stopWatchdog(): void {
94
- if (this.watchdog) { clearInterval(this.watchdog); this.watchdog = null; }
144
+ private stopHeartbeat(): void {
145
+ if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
95
146
  }
96
147
 
97
- /** Synchronous tick no async, no promises, no way to silently fail. */
98
- private tick(): void {
99
- if (this.stopped) return;
148
+ // ── SSE Connection ──────────────────────────────────────────────
100
149
 
101
- // If already reconnecting, skip
102
- if (this.reconnecting) return;
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}`;
103
159
 
104
- const ws = this.ws;
160
+ this.options.log?.info?.(`[clawroom] SSE connecting to ${this.httpBase}/stream`);
105
161
 
106
- // No socket → reconnect
107
- if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
108
- this.triggerReconnect("no socket");
162
+ try {
163
+ this.eventSource = new EventSource(url);
164
+ } catch {
165
+ this.triggerReconnect("EventSource creation failed");
109
166
  return;
110
167
  }
111
168
 
112
- // Still connecting wait
113
- 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;
173
+
174
+ if (this.mode === "polling") {
175
+ this.switchToSSE();
176
+ }
177
+ };
178
+
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
+ }
114
190
 
115
- // Socket is OPEN — check if server is responsive
116
- const elapsed = Date.now() - this.lastActivity;
117
- if (elapsed > DEAD_THRESHOLD_MS) {
118
- // No response from server in 25s — it's dead
119
- this.options.log?.warn?.(`[clawroom] watchdog: no response for ${Math.round(elapsed / 1000)}s, forcing reconnect`);
120
- this.triggerReconnect("dead connection");
121
- return;
191
+ private destroySSE(): void {
192
+ if (this.eventSource) {
193
+ try { this.eventSource.close(); } catch {}
194
+ this.eventSource = null;
122
195
  }
123
-
124
- // Send heartbeat
125
- this.send({ type: "agent.heartbeat" });
126
196
  }
127
197
 
128
198
  private triggerReconnect(reason: string): void {
129
199
  if (this.reconnecting || this.stopped) return;
130
- this.reconnecting = true;
131
- this.destroySocket();
132
200
 
133
- const delayMs = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempt, RECONNECT_MAX_MS);
201
+ this.consecutiveSseFails++;
202
+
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
+ this.switchToPolling();
207
+ return;
208
+ }
209
+
210
+ this.reconnecting = true;
211
+ const delayMs = computeBackoff(this.reconnectAttempt);
134
212
  this.reconnectAttempt++;
135
213
  this.options.log?.info?.(`[clawroom] reconnecting in ${delayMs}ms (${reason}, attempt ${this.reconnectAttempt})`);
136
214
 
137
215
  setTimeout(() => {
138
216
  this.reconnecting = false;
139
- if (!this.stopped) this.doConnect();
217
+ if (!this.stopped) this.doConnectSSE();
140
218
  }, delayMs);
141
219
  }
142
220
 
143
- // ── Connection ────────────────────────────────────────────────────
221
+ // ── Polling fallback ────────────────────────────────────────────
144
222
 
145
- private doConnect(): void {
146
- this.destroySocket();
147
- const { endpoint, token } = this.options;
148
- const url = `${endpoint}?token=${encodeURIComponent(token)}`;
223
+ private switchToPolling(): void {
224
+ this.mode = "polling";
225
+ this.destroySSE();
226
+ this.options.log?.info?.("[clawroom] entering HTTP polling mode");
227
+ for (const cb of this.modeChangeCallbacks) cb("polling");
149
228
 
150
- this.options.log?.info?.(`[clawroom] connecting to ${endpoint}`);
229
+ this.pollCycleCount = 0;
230
+ this.stopPolling();
231
+ this.pollTimer = setInterval(() => this.pollTick(), POLL_INTERVAL_MS);
232
+ }
233
+
234
+ private switchToSSE(): void {
235
+ this.options.log?.info?.("[clawroom] SSE restored, switching back from polling");
236
+ this.stopPolling();
237
+ this.mode = "sse";
238
+ for (const cb of this.modeChangeCallbacks) cb("sse");
239
+ }
240
+
241
+ private stopPolling(): void {
242
+ if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; }
243
+ }
244
+
245
+ private async pollTick(): Promise<void> {
246
+ if (this.stopped) return;
247
+ this.pollCycleCount++;
151
248
 
152
249
  try {
153
- this.ws = new WebSocket(url);
154
- } catch {
155
- return; // watchdog will retry
250
+ const res = await this.httpRequest("POST", "/poll", {});
251
+ if (res.task) {
252
+ const task = res.task as ServerTask;
253
+ this.options.log?.info?.(`[clawroom] [poll] received task ${task.taskId}: ${task.title}`);
254
+ for (const cb of this.taskCallbacks) cb(task);
255
+ for (const cb of this.claimAckCallbacks) cb({ type: "server.claim_ack", taskId: task.taskId, ok: true });
256
+ }
257
+ } catch (err) {
258
+ this.options.log?.warn?.(`[clawroom] [poll] error: ${err}`);
156
259
  }
157
260
 
158
- this.ws.addEventListener("open", () => {
159
- this.options.log?.info?.("[clawroom] connected");
160
- this.reconnectAttempt = 0;
161
- this.lastActivity = Date.now();
162
- this.send({
163
- type: "agent.hello",
164
- deviceId: this.options.deviceId,
165
- skills: this.options.skills,
166
- });
167
- });
168
-
169
- this.ws.addEventListener("message", (event) => {
170
- this.lastActivity = Date.now(); // ANY message = server is alive
171
- this.handleMessage(event.data as string);
172
- });
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();
265
+ }
266
+ }
173
267
 
174
- this.ws.addEventListener("close", () => {
175
- this.options.log?.info?.("[clawroom] disconnected");
176
- this.ws = null;
177
- // Don't reconnect here — watchdog handles it
178
- });
268
+ // ── HTTP send ───────────────────────────────────────────────────
179
269
 
180
- this.ws.addEventListener("error", () => {
181
- // watchdog handles it
182
- });
270
+ private async sendViaHttp(message: AgentMessage): Promise<void> {
271
+ switch (message.type) {
272
+ case "agent.complete":
273
+ await this.httpRequest("POST", "/complete", {
274
+ taskId: message.taskId,
275
+ output: message.output,
276
+ attachments: message.attachments,
277
+ });
278
+ break;
279
+ case "agent.fail":
280
+ await this.httpRequest("POST", "/fail", {
281
+ taskId: message.taskId,
282
+ reason: message.reason,
283
+ });
284
+ break;
285
+ case "agent.progress":
286
+ await this.httpRequest("POST", "/progress", {
287
+ taskId: message.taskId,
288
+ message: message.message,
289
+ percent: message.percent,
290
+ });
291
+ break;
292
+ case "agent.heartbeat":
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:
299
+ break;
300
+ }
183
301
  }
184
302
 
185
- private destroySocket(): void {
186
- if (this.ws) {
187
- try { this.ws.close(); } catch {}
188
- this.ws = null;
303
+ private async httpRequest(method: string, path: string, body: unknown): Promise<any> {
304
+ const res = await fetch(`${this.httpBase}${path}`, {
305
+ method,
306
+ headers: {
307
+ "Content-Type": "application/json",
308
+ "Authorization": `Bearer ${this.options.token}`,
309
+ },
310
+ body: JSON.stringify(body),
311
+ });
312
+ if (!res.ok) {
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
+ }
321
+ throw new Error(`HTTP ${res.status}: ${text}`);
189
322
  }
323
+ return res.json();
190
324
  }
191
325
 
192
326
  // ── Message handling ──────────────────────────────────────────────
@@ -201,7 +335,6 @@ export class ClawroomWsClient {
201
335
  for (const cb of this.welcomeCallbacks) cb(msg.agentId);
202
336
  break;
203
337
  case "server.pong":
204
- // lastActivity already updated in message handler
205
338
  break;
206
339
  case "server.task":
207
340
  this.options.log?.info?.(`[clawroom] received task ${msg.taskId}: ${msg.title}`);