@clawroom/openclaw 0.1.0 → 0.1.1

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` | WebSocket endpoint (default: `ws://localhost:3000/ws/agent`) |
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawroom/openclaw",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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
@@ -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,48 @@ import type {
5
5
  ServerTask,
6
6
  } from "@clawroom/sdk";
7
7
 
8
+ // ── Reconnect policy (matches Telegram/Slack pattern) ─────────────────
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
+ // ── 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
+
8
29
  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;
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
35
+ const POLL_INTERVAL_MS = 10_000;
36
+ // Try to restore WS every N poll cycles
37
+ const WS_RESTORE_INTERVAL = 6; // = 60s
38
+
39
+ // ── Types ─────────────────────────────────────────────────────────────
12
40
 
13
41
  type TaskCallback = (task: ServerTask) => void;
14
42
  type TaskListCallback = (tasks: ServerTask[]) => void;
15
43
  type ClaimAckCallback = (ack: ServerClaimAck) => void;
16
44
  type ClaimRequestCallback = (task: ServerTask) => void;
17
45
  type ErrorCallback = (error: ServerMessage & { type: "server.error" }) => void;
46
+ type DisconnectCallback = () => void;
18
47
  type WelcomeCallback = (agentId: string) => void;
48
+ type FatalCallback = (reason: string, code: number) => void;
49
+ type ModeChangeCallback = (mode: "ws" | "polling") => void;
19
50
 
20
51
  export type ClawroomWsClientOptions = {
21
52
  endpoint: string;
@@ -30,37 +61,57 @@ export type ClawroomWsClientOptions = {
30
61
  };
31
62
 
32
63
  /**
33
- * WebSocket client with aggressive reconnection.
64
+ * Hybrid WebSocket + HTTP Polling client.
65
+ *
66
+ * Primary mode: WebSocket (real-time task push)
67
+ * Fallback mode: HTTP Polling (when WS fails 5+ times consecutively)
34
68
  *
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
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
41
73
  */
42
74
  export class ClawroomWsClient {
43
75
  private ws: WebSocket | null = null;
44
76
  private watchdog: ReturnType<typeof setInterval> | null = null;
45
- private lastActivity = 0; // timestamp of last successful server response
77
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
78
+ private lastActivity = 0;
46
79
  private reconnectAttempt = 0;
80
+ private consecutiveWsFails = 0;
47
81
  private reconnecting = false;
48
82
  private stopped = false;
83
+ private lastCloseCode = 0;
84
+ private mode: "ws" | "polling" = "ws";
85
+ private pollCycleCount = 0;
86
+ private httpBase = ""; // derived from WS endpoint
49
87
 
50
88
  private taskCallbacks: TaskCallback[] = [];
51
89
  private taskListCallbacks: TaskListCallback[] = [];
52
90
  private claimAckCallbacks: ClaimAckCallback[] = [];
53
91
  private claimRequestCallbacks: ClaimRequestCallback[] = [];
54
92
  private errorCallbacks: ErrorCallback[] = [];
93
+ private disconnectCallbacks: DisconnectCallback[] = [];
55
94
  private welcomeCallbacks: WelcomeCallback[] = [];
56
-
57
- constructor(private readonly options: ClawroomWsClientOptions) {}
95
+ private fatalCallbacks: FatalCallback[] = [];
96
+ private modeChangeCallbacks: ModeChangeCallback[] = [];
97
+
98
+ constructor(private readonly options: ClawroomWsClientOptions) {
99
+ // Derive HTTP base URL from WS endpoint
100
+ // wss://clawroom.site9.ai/ws/agent → https://clawroom.site9.ai/api/agents
101
+ // ws://localhost:3000/ws/agent → http://localhost:3000/api/agents
102
+ const ep = options.endpoint;
103
+ const httpUrl = ep.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
104
+ this.httpBase = httpUrl.replace(/\/ws\/.*$/, "/api/agents");
105
+ }
58
106
 
59
107
  connect(): void {
60
108
  this.stopped = false;
61
109
  this.reconnecting = false;
62
110
  this.reconnectAttempt = 0;
111
+ this.consecutiveWsFails = 0;
63
112
  this.lastActivity = Date.now();
113
+ this.lastCloseCode = 0;
114
+ this.mode = "ws";
64
115
  this.doConnect();
65
116
  this.startWatchdog();
66
117
  }
@@ -68,22 +119,39 @@ export class ClawroomWsClient {
68
119
  disconnect(): void {
69
120
  this.stopped = true;
70
121
  this.stopWatchdog();
122
+ this.stopPolling();
71
123
  this.destroySocket();
72
124
  }
73
125
 
74
126
  send(message: AgentMessage): void {
127
+ if (this.mode === "polling") {
128
+ // In polling mode, send via HTTP
129
+ this.sendViaHttp(message).catch(() => {});
130
+ return;
131
+ }
75
132
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
76
133
  try { this.ws.send(JSON.stringify(message)); } catch {}
77
134
  }
78
135
 
136
+ get isAlive(): boolean { return !this.stopped; }
137
+ get isConnected(): boolean {
138
+ if (this.mode === "polling") return true; // polling is always "connected"
139
+ return this.ws?.readyState === WebSocket.OPEN;
140
+ }
141
+ get isFatal(): boolean { return NON_RECOVERABLE_CODES.has(this.lastCloseCode); }
142
+ get currentMode(): "ws" | "polling" { return this.mode; }
143
+
79
144
  onTask(cb: TaskCallback): void { this.taskCallbacks.push(cb); }
80
145
  onTaskList(cb: TaskListCallback): void { this.taskListCallbacks.push(cb); }
81
146
  onClaimAck(cb: ClaimAckCallback): void { this.claimAckCallbacks.push(cb); }
82
147
  onClaimRequest(cb: ClaimRequestCallback): void { this.claimRequestCallbacks.push(cb); }
83
148
  onError(cb: ErrorCallback): void { this.errorCallbacks.push(cb); }
149
+ onDisconnect(cb: DisconnectCallback): void { this.disconnectCallbacks.push(cb); }
84
150
  onWelcome(cb: WelcomeCallback): void { this.welcomeCallbacks.push(cb); }
151
+ onFatal(cb: FatalCallback): void { this.fatalCallbacks.push(cb); }
152
+ onModeChange(cb: ModeChangeCallback): void { this.modeChangeCallbacks.push(cb); }
85
153
 
86
- // ── Watchdog ──────────────────────────────────────────────────────
154
+ // ── Watchdog (WS mode only) ─────────────────────────────────────
87
155
 
88
156
  private startWatchdog(): void {
89
157
  this.stopWatchdog();
@@ -94,43 +162,55 @@ export class ClawroomWsClient {
94
162
  if (this.watchdog) { clearInterval(this.watchdog); this.watchdog = null; }
95
163
  }
96
164
 
97
- /** Synchronous tick — no async, no promises, no way to silently fail. */
98
165
  private tick(): void {
99
- if (this.stopped) return;
100
-
101
- // If already reconnecting, skip
102
- if (this.reconnecting) return;
166
+ if (this.stopped || this.reconnecting || this.mode === "polling") return;
103
167
 
104
168
  const ws = this.ws;
105
169
 
106
- // No socket → reconnect
107
170
  if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
108
171
  this.triggerReconnect("no socket");
109
172
  return;
110
173
  }
111
174
 
112
- // Still connecting → wait
113
175
  if (ws.readyState === WebSocket.CONNECTING) return;
114
176
 
115
- // Socket is OPEN — check if server is responsive
116
177
  const elapsed = Date.now() - this.lastActivity;
117
178
  if (elapsed > DEAD_THRESHOLD_MS) {
118
- // No response from server in 25s — it's dead
119
179
  this.options.log?.warn?.(`[clawroom] watchdog: no response for ${Math.round(elapsed / 1000)}s, forcing reconnect`);
120
180
  this.triggerReconnect("dead connection");
121
181
  return;
122
182
  }
123
183
 
124
- // Send heartbeat
125
- this.send({ type: "agent.heartbeat" });
184
+ this.ws?.readyState === WebSocket.OPEN && this.ws.send(JSON.stringify({ type: "agent.heartbeat" }));
126
185
  }
127
186
 
128
187
  private triggerReconnect(reason: string): void {
129
188
  if (this.reconnecting || this.stopped) return;
189
+
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++;
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`);
206
+ this.switchToPolling();
207
+ return;
208
+ }
209
+
130
210
  this.reconnecting = true;
131
211
  this.destroySocket();
132
212
 
133
- const delayMs = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempt, RECONNECT_MAX_MS);
213
+ const delayMs = computeBackoff(this.reconnectAttempt);
134
214
  this.reconnectAttempt++;
135
215
  this.options.log?.info?.(`[clawroom] reconnecting in ${delayMs}ms (${reason}, attempt ${this.reconnectAttempt})`);
136
216
 
@@ -140,7 +220,7 @@ export class ClawroomWsClient {
140
220
  }, delayMs);
141
221
  }
142
222
 
143
- // ── Connection ────────────────────────────────────────────────────
223
+ // ── WS Connection ──────────────────────────────────────────────
144
224
 
145
225
  private doConnect(): void {
146
226
  this.destroySocket();
@@ -156,30 +236,41 @@ export class ClawroomWsClient {
156
236
  }
157
237
 
158
238
  this.ws.addEventListener("open", () => {
159
- this.options.log?.info?.("[clawroom] connected");
239
+ this.options.log?.info?.("[clawroom] WS connected");
160
240
  this.reconnectAttempt = 0;
241
+ this.consecutiveWsFails = 0;
242
+ this.lastCloseCode = 0;
161
243
  this.lastActivity = Date.now();
162
- this.send({
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({
163
251
  type: "agent.hello",
164
252
  deviceId: this.options.deviceId,
165
253
  skills: this.options.skills,
166
- });
254
+ }));
167
255
  });
168
256
 
169
257
  this.ws.addEventListener("message", (event) => {
170
- this.lastActivity = Date.now(); // ANY message = server is alive
258
+ this.lastActivity = Date.now();
171
259
  this.handleMessage(event.data as string);
172
260
  });
173
261
 
174
- this.ws.addEventListener("close", () => {
175
- this.options.log?.info?.("[clawroom] disconnected");
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})`);
176
266
  this.ws = null;
177
- // Don't reconnect here watchdog handles it
267
+ for (const cb of this.disconnectCallbacks) cb();
268
+ if (NON_RECOVERABLE_CODES.has(event.code)) {
269
+ this.triggerReconnect(reason);
270
+ }
178
271
  });
179
272
 
180
- this.ws.addEventListener("error", () => {
181
- // watchdog handles it
182
- });
273
+ this.ws.addEventListener("error", () => {});
183
274
  }
184
275
 
185
276
  private destroySocket(): void {
@@ -189,6 +280,112 @@ export class ClawroomWsClient {
189
280
  }
190
281
  }
191
282
 
283
+ // ── HTTP Polling (fallback mode) ────────────────────────────────
284
+
285
+ private switchToPolling(): void {
286
+ this.mode = "polling";
287
+ this.destroySocket();
288
+ this.options.log?.info?.("[clawroom] entering HTTP polling mode");
289
+ for (const cb of this.modeChangeCallbacks) cb("polling");
290
+
291
+ // Send initial heartbeat
292
+ this.httpHeartbeat().catch(() => {});
293
+
294
+ this.pollCycleCount = 0;
295
+ this.stopPolling();
296
+ this.pollTimer = setInterval(() => this.pollTick(), POLL_INTERVAL_MS);
297
+ }
298
+
299
+ private switchToWs(): void {
300
+ this.options.log?.info?.("[clawroom] WS restored, switching back from polling");
301
+ this.stopPolling();
302
+ this.mode = "ws";
303
+ for (const cb of this.modeChangeCallbacks) cb("ws");
304
+ }
305
+
306
+ private stopPolling(): void {
307
+ if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; }
308
+ }
309
+
310
+ private async pollTick(): Promise<void> {
311
+ if (this.stopped) return;
312
+
313
+ this.pollCycleCount++;
314
+
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
+ try {
321
+ const res = await this.httpRequest("POST", "/poll", {});
322
+ if (res.task) {
323
+ const task = res.task as ServerTask;
324
+ 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
+ for (const cb of this.taskCallbacks) cb(task);
327
+ for (const cb of this.claimAckCallbacks) cb({ type: "server.claim_ack", taskId: task.taskId, ok: true });
328
+ }
329
+ } catch (err) {
330
+ this.options.log?.warn?.(`[clawroom] [poll] error: ${err}`);
331
+ }
332
+
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
337
+ }
338
+ }
339
+
340
+ private async httpHeartbeat(): Promise<void> {
341
+ await this.httpRequest("POST", "/heartbeat", {}).catch(() => {});
342
+ }
343
+
344
+ private async sendViaHttp(message: AgentMessage): Promise<void> {
345
+ switch (message.type) {
346
+ case "agent.complete":
347
+ await this.httpRequest("POST", "/complete", {
348
+ taskId: message.taskId,
349
+ output: message.output,
350
+ attachments: message.attachments,
351
+ });
352
+ break;
353
+ case "agent.fail":
354
+ await this.httpRequest("POST", "/fail", {
355
+ taskId: message.taskId,
356
+ reason: message.reason,
357
+ });
358
+ break;
359
+ case "agent.progress":
360
+ await this.httpRequest("POST", "/progress", {
361
+ taskId: message.taskId,
362
+ message: message.message,
363
+ percent: message.percent,
364
+ });
365
+ break;
366
+ case "agent.heartbeat":
367
+ await this.httpHeartbeat();
368
+ break;
369
+ // claim, hello, release — not needed in polling mode
370
+ }
371
+ }
372
+
373
+ private async httpRequest(method: string, path: string, body: unknown): Promise<any> {
374
+ const res = await fetch(`${this.httpBase}${path}`, {
375
+ method,
376
+ headers: {
377
+ "Content-Type": "application/json",
378
+ "Authorization": `Bearer ${this.options.token}`,
379
+ },
380
+ body: JSON.stringify(body),
381
+ });
382
+ if (!res.ok) {
383
+ const text = await res.text().catch(() => "");
384
+ throw new Error(`HTTP ${res.status}: ${text}`);
385
+ }
386
+ return res.json();
387
+ }
388
+
192
389
  // ── Message handling ──────────────────────────────────────────────
193
390
 
194
391
  private handleMessage(raw: string): void {
@@ -201,7 +398,6 @@ export class ClawroomWsClient {
201
398
  for (const cb of this.welcomeCallbacks) cb(msg.agentId);
202
399
  break;
203
400
  case "server.pong":
204
- // lastActivity already updated in message handler
205
401
  break;
206
402
  case "server.task":
207
403
  this.options.log?.info?.(`[clawroom] received task ${msg.taskId}: ${msg.title}`);