@clawroom/openclaw 0.1.1 → 0.2.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/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.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
@@ -2,12 +2,12 @@ import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
2
2
  import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
3
3
  import { collectSkills } from "./skill-reporter.js";
4
4
  import { getClawroomRuntime } from "./runtime.js";
5
- import { ClawroomWsClient } from "./ws-client.js";
5
+ import { ClawroomClient } from "./client.js";
6
6
  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 {
@@ -54,9 +54,9 @@ function resolveClawroomAccount(opts: {
54
54
  };
55
55
  }
56
56
 
57
- // ── Persistent WS client per gateway lifecycle ───────────────────────
57
+ // ── Persistent client per gateway lifecycle ───────────────────────
58
58
 
59
- let activeClient: ClawroomWsClient | null = null;
59
+ let activeClient: ClawroomClient | null = null;
60
60
 
61
61
  // ── Channel plugin definition ────────────────────────────────────────
62
62
 
@@ -103,8 +103,8 @@ export const clawroomPlugin: ChannelPlugin<ResolvedClawroomAccount> = {
103
103
  deliveryMode: "direct",
104
104
 
105
105
  sendText: async ({ to, text }) => {
106
- // Outbound messages are task results sent via the WS client.
107
- // The task-executor sends results directly through ws.send();
106
+ // Outbound messages are task results sent via HTTP.
107
+ // The task-executor sends results directly through client.send();
108
108
  // this adapter exists for completeness if openclaw routing tries
109
109
  // to deliver a reply through the channel outbound path.
110
110
  if (activeClient) {
@@ -137,7 +137,7 @@ export const clawroomPlugin: ChannelPlugin<ResolvedClawroomAccount> = {
137
137
  const deviceId = resolveDeviceId(ctx);
138
138
  const log = ctx.log ?? undefined;
139
139
 
140
- const client = new ClawroomWsClient({
140
+ const client = new ClawroomClient({
141
141
  endpoint: account.endpoint,
142
142
  token: account.token,
143
143
  deviceId,
@@ -169,7 +169,7 @@ export const clawroomPlugin: ChannelPlugin<ResolvedClawroomAccount> = {
169
169
  });
170
170
  };
171
171
 
172
- // Wire up WS events to OpenClaw health status
172
+ // Wire up SSE events to OpenClaw health status
173
173
  client.onWelcome(() => publishConnected());
174
174
  client.onTask(() => {
175
175
  // Any server message = update lastEventAt so gateway knows we're alive
@@ -208,7 +208,7 @@ export const clawroomPlugin: ChannelPlugin<ResolvedClawroomAccount> = {
208
208
  });
209
209
  });
210
210
 
211
- setupTaskExecutor({ ws: client, runtime, log });
211
+ setupTaskExecutor({ client: client, runtime, log });
212
212
  client.connect();
213
213
  activeClient = client;
214
214
 
@@ -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,10 +31,10 @@ 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
- export type ClawroomWsClientOptions = {
37
+ export type ClawroomClientOptions = {
52
38
  endpoint: string;
53
39
  token: string;
54
40
  deviceId: string;
@@ -61,29 +47,24 @@ 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
- export class ClawroomWsClient {
75
- private ws: WebSocket | null = null;
76
- private watchdog: ReturnType<typeof setInterval> | null = null;
58
+ export class ClawroomClient {
59
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
77
60
  private pollTimer: ReturnType<typeof setInterval> | null = null;
78
- private lastActivity = 0;
79
61
  private reconnectAttempt = 0;
80
- private consecutiveWsFails = 0;
62
+ private consecutiveSseFails = 0;
81
63
  private reconnecting = false;
82
64
  private stopped = false;
83
- private lastCloseCode = 0;
84
- private mode: "ws" | "polling" = "ws";
65
+ private mode: "sse" | "polling" = "sse";
85
66
  private pollCycleCount = 0;
86
- private httpBase = ""; // derived from WS endpoint
67
+ private httpBase: string;
87
68
 
88
69
  private taskCallbacks: TaskCallback[] = [];
89
70
  private taskListCallbacks: TaskListCallback[] = [];
@@ -95,51 +76,49 @@ export class ClawroomWsClient {
95
76
  private fatalCallbacks: FatalCallback[] = [];
96
77
  private modeChangeCallbacks: ModeChangeCallback[] = [];
97
78
 
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/agenthttp://localhost:3000/api/agents
79
+ constructor(private readonly options: ClawroomClientOptions) {
80
+ // Derive HTTP base from endpoint
81
+ // Legacy: wss://host/ws/agent → https://clawroom.site9.ai/api/agents
82
+ // https://clawroom.site9.ai/api/agents/streamhttps://clawroom.site9.ai/api/agents
102
83
  const ep = options.endpoint;
103
- const httpUrl = ep.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
104
- this.httpBase = httpUrl.replace(/\/ws\/.*$/, "/api/agents");
84
+ if (ep.includes("/api/agents")) {
85
+ this.httpBase = ep.replace(/\/stream\/?$/, "");
86
+ } else {
87
+ const httpUrl = ep.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
88
+ this.httpBase = httpUrl.replace(/\/ws\/.*$/, "/api/agents");
89
+ }
105
90
  }
106
91
 
107
92
  connect(): void {
108
93
  this.stopped = false;
109
94
  this.reconnecting = false;
110
95
  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();
96
+ this.consecutiveSseFails = 0;
97
+ this.mode = "sse";
98
+ this.doConnectSSE();
99
+ this.startHeartbeat();
117
100
  }
118
101
 
119
102
  disconnect(): void {
120
103
  this.stopped = true;
121
- this.stopWatchdog();
104
+ this.stopHeartbeat();
122
105
  this.stopPolling();
123
- this.destroySocket();
106
+ this.destroySSE();
124
107
  }
125
108
 
126
109
  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 {}
110
+ this.sendViaHttp(message).catch((err) => {
111
+ this.options.log?.warn?.(`[clawroom] send error: ${err}`);
112
+ });
134
113
  }
135
114
 
136
115
  get isAlive(): boolean { return !this.stopped; }
137
116
  get isConnected(): boolean {
138
- if (this.mode === "polling") return true; // polling is always "connected"
139
- return this.ws?.readyState === WebSocket.OPEN;
117
+ if (this.mode === "polling") return true;
118
+ return this.sseAbort !== null && !this.sseAbort.signal.aborted;
140
119
  }
141
- get isFatal(): boolean { return NON_RECOVERABLE_CODES.has(this.lastCloseCode); }
142
- get currentMode(): "ws" | "polling" { return this.mode; }
120
+ get isFatal(): boolean { return false; }
121
+ get currentMode(): "sse" | "polling" { return this.mode; }
143
122
 
144
123
  onTask(cb: TaskCallback): void { this.taskCallbacks.push(cb); }
145
124
  onTaskList(cb: TaskListCallback): void { this.taskListCallbacks.push(cb); }
@@ -151,156 +130,135 @@ export class ClawroomWsClient {
151
130
  onFatal(cb: FatalCallback): void { this.fatalCallbacks.push(cb); }
152
131
  onModeChange(cb: ModeChangeCallback): void { this.modeChangeCallbacks.push(cb); }
153
132
 
154
- // ── Watchdog (WS mode only) ─────────────────────────────────────
133
+ // ── Heartbeat (HTTP POST, both modes) ───────────────────────────
155
134
 
156
- private startWatchdog(): void {
157
- this.stopWatchdog();
158
- this.watchdog = setInterval(() => this.tick(), WATCHDOG_INTERVAL_MS);
135
+ private startHeartbeat(): void {
136
+ this.stopHeartbeat();
137
+ this.heartbeatTimer = setInterval(() => {
138
+ if (this.stopped) return;
139
+ this.httpRequest("POST", "/heartbeat", {}).catch(() => {});
140
+ }, HEARTBEAT_INTERVAL_MS);
159
141
  }
160
142
 
161
- private stopWatchdog(): void {
162
- if (this.watchdog) { clearInterval(this.watchdog); this.watchdog = null; }
143
+ private stopHeartbeat(): void {
144
+ if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
163
145
  }
164
146
 
165
- private tick(): void {
166
- if (this.stopped || this.reconnecting || this.mode === "polling") return;
167
-
168
- const ws = this.ws;
147
+ // ── SSE Connection (fetch-based, works in Node.js) ───────────────
148
+
149
+ private sseAbort: AbortController | null = null;
150
+
151
+ private doConnectSSE(): void {
152
+ this.destroySSE();
153
+ const { token, deviceId, skills } = this.options;
154
+ const params = new URLSearchParams({ token, deviceId, skills: skills.join(",") });
155
+ const url = `${this.httpBase}/stream?${params}`;
156
+
157
+ this.options.log?.info?.(`[clawroom] SSE connecting to ${this.httpBase}/stream`);
158
+
159
+ this.sseAbort = new AbortController();
160
+ const signal = this.sseAbort.signal;
161
+
162
+ fetch(url, {
163
+ headers: { "Accept": "text/event-stream", "Authorization": `Bearer ${token}` },
164
+ signal,
165
+ }).then(async (res) => {
166
+ if (!res.ok || !res.body) {
167
+ if (res.status === 401) {
168
+ this.stopped = true;
169
+ this.stopHeartbeat();
170
+ this.stopPolling();
171
+ for (const cb of this.fatalCallbacks) cb("Unauthorized");
172
+ return;
173
+ }
174
+ throw new Error(`HTTP ${res.status}`);
175
+ }
169
176
 
170
- if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
171
- this.triggerReconnect("no socket");
172
- return;
173
- }
177
+ this.options.log?.info?.("[clawroom] SSE connected");
178
+ this.reconnectAttempt = 0;
179
+ this.consecutiveSseFails = 0;
180
+ if (this.mode === "polling") this.switchToSSE();
181
+
182
+ const reader = res.body.getReader();
183
+ const decoder = new TextDecoder();
184
+ let buffer = "";
185
+
186
+ while (true) {
187
+ const { done, value } = await reader.read();
188
+ if (done) break;
189
+
190
+ buffer += decoder.decode(value, { stream: true });
191
+ const lines = buffer.split("\n");
192
+ buffer = lines.pop() ?? "";
193
+
194
+ for (const line of lines) {
195
+ if (line.startsWith("data: ")) {
196
+ this.handleMessage(line.slice(6));
197
+ }
198
+ // Ignore comments (: keepalive) and empty lines
199
+ }
200
+ }
174
201
 
175
- if (ws.readyState === WebSocket.CONNECTING) return;
202
+ // Stream ended
203
+ this.options.log?.info?.("[clawroom] SSE stream ended");
204
+ for (const cb of this.disconnectCallbacks) cb();
205
+ this.triggerReconnect("stream ended");
206
+ }).catch((err) => {
207
+ if (signal.aborted) return;
208
+ this.options.log?.warn?.(`[clawroom] SSE error: ${err}`);
209
+ for (const cb of this.disconnectCallbacks) cb();
210
+ this.triggerReconnect("SSE error");
211
+ });
212
+ }
176
213
 
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;
214
+ private destroySSE(): void {
215
+ if (this.sseAbort) {
216
+ try { this.sseAbort.abort(); } catch {}
217
+ this.sseAbort = null;
182
218
  }
183
-
184
- this.ws?.readyState === WebSocket.OPEN && this.ws.send(JSON.stringify({ type: "agent.heartbeat" }));
185
219
  }
186
220
 
187
221
  private triggerReconnect(reason: string): void {
188
222
  if (this.reconnecting || this.stopped) return;
189
223
 
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++;
224
+ this.consecutiveSseFails++;
202
225
 
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`);
226
+ // SSE keeps failing → degrade to polling
227
+ if (this.consecutiveSseFails >= 3 && this.mode !== "polling") {
228
+ this.options.log?.warn?.(`[clawroom] SSE failed ${this.consecutiveSseFails} times, switching to HTTP polling`);
206
229
  this.switchToPolling();
207
230
  return;
208
231
  }
209
232
 
210
233
  this.reconnecting = true;
211
- this.destroySocket();
212
-
213
234
  const delayMs = computeBackoff(this.reconnectAttempt);
214
235
  this.reconnectAttempt++;
215
236
  this.options.log?.info?.(`[clawroom] reconnecting in ${delayMs}ms (${reason}, attempt ${this.reconnectAttempt})`);
216
237
 
217
238
  setTimeout(() => {
218
239
  this.reconnecting = false;
219
- if (!this.stopped) this.doConnect();
240
+ if (!this.stopped) this.doConnectSSE();
220
241
  }, delayMs);
221
242
  }
222
243
 
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) ────────────────────────────────
244
+ // ── Polling fallback ────────────────────────────────────────────
284
245
 
285
246
  private switchToPolling(): void {
286
247
  this.mode = "polling";
287
- this.destroySocket();
248
+ this.destroySSE();
288
249
  this.options.log?.info?.("[clawroom] entering HTTP polling mode");
289
250
  for (const cb of this.modeChangeCallbacks) cb("polling");
290
251
 
291
- // Send initial heartbeat
292
- this.httpHeartbeat().catch(() => {});
293
-
294
252
  this.pollCycleCount = 0;
295
253
  this.stopPolling();
296
254
  this.pollTimer = setInterval(() => this.pollTick(), POLL_INTERVAL_MS);
297
255
  }
298
256
 
299
- private switchToWs(): void {
300
- this.options.log?.info?.("[clawroom] WS restored, switching back from polling");
257
+ private switchToSSE(): void {
258
+ this.options.log?.info?.("[clawroom] SSE restored, switching back from polling");
301
259
  this.stopPolling();
302
- this.mode = "ws";
303
- for (const cb of this.modeChangeCallbacks) cb("ws");
260
+ this.mode = "sse";
261
+ for (const cb of this.modeChangeCallbacks) cb("sse");
304
262
  }
305
263
 
306
264
  private stopPolling(): void {
@@ -309,20 +267,13 @@ export class ClawroomWsClient {
309
267
 
310
268
  private async pollTick(): Promise<void> {
311
269
  if (this.stopped) return;
312
-
313
270
  this.pollCycleCount++;
314
271
 
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
272
  try {
321
273
  const res = await this.httpRequest("POST", "/poll", {});
322
274
  if (res.task) {
323
275
  const task = res.task as ServerTask;
324
276
  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
277
  for (const cb of this.taskCallbacks) cb(task);
327
278
  for (const cb of this.claimAckCallbacks) cb({ type: "server.claim_ack", taskId: task.taskId, ok: true });
328
279
  }
@@ -330,16 +281,14 @@ export class ClawroomWsClient {
330
281
  this.options.log?.warn?.(`[clawroom] [poll] error: ${err}`);
331
282
  }
332
283
 
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
284
+ // Try to restore SSE every 60s
285
+ if (this.pollCycleCount % 6 === 0) {
286
+ this.options.log?.info?.("[clawroom] [poll] attempting SSE restore...");
287
+ this.doConnectSSE();
337
288
  }
338
289
  }
339
290
 
340
- private async httpHeartbeat(): Promise<void> {
341
- await this.httpRequest("POST", "/heartbeat", {}).catch(() => {});
342
- }
291
+ // ── HTTP send ───────────────────────────────────────────────────
343
292
 
344
293
  private async sendViaHttp(message: AgentMessage): Promise<void> {
345
294
  switch (message.type) {
@@ -364,9 +313,13 @@ export class ClawroomWsClient {
364
313
  });
365
314
  break;
366
315
  case "agent.heartbeat":
367
- await this.httpHeartbeat();
316
+ await this.httpRequest("POST", "/heartbeat", {});
317
+ break;
318
+ case "agent.claim":
319
+ await this.httpRequest("POST", "/claim", { taskId: message.taskId });
320
+ break;
321
+ default:
368
322
  break;
369
- // claim, hello, release — not needed in polling mode
370
323
  }
371
324
  }
372
325
 
@@ -381,6 +334,13 @@ export class ClawroomWsClient {
381
334
  });
382
335
  if (!res.ok) {
383
336
  const text = await res.text().catch(() => "");
337
+ if (res.status === 401) {
338
+ this.stopped = true;
339
+ this.stopHeartbeat();
340
+ this.stopPolling();
341
+ this.destroySSE();
342
+ for (const cb of this.fatalCallbacks) cb(`Unauthorized: ${text}`);
343
+ }
384
344
  throw new Error(`HTTP ${res.status}: ${text}`);
385
345
  }
386
346
  return res.json();
@@ -1,6 +1,6 @@
1
1
  import type { ServerClaimAck, ServerTask, AgentResultFile } from "@clawroom/sdk";
2
2
  import type { PluginRuntime } from "openclaw/plugin-sdk/core";
3
- import type { ClawroomWsClient } from "./ws-client.js";
3
+ import type { ClawroomClient } from "./client.js";
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
6
 
@@ -18,7 +18,7 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024;
18
18
  * Claiming is triggered externally (by the owner via Dashboard).
19
19
  */
20
20
  export function setupTaskExecutor(opts: {
21
- ws: ClawroomWsClient;
21
+ client: ClawroomClient;
22
22
  runtime: PluginRuntime;
23
23
  log?: {
24
24
  info?: (...args: unknown[]) => void;
@@ -26,19 +26,19 @@ export function setupTaskExecutor(opts: {
26
26
  error?: (...args: unknown[]) => void;
27
27
  };
28
28
  }): void {
29
- const { ws, runtime, log } = opts;
29
+ const { client, runtime, log } = opts;
30
30
 
31
31
  // Track received tasks (from broadcast or server push after claim)
32
32
  const knownTasks = new Map<string, ServerTask>();
33
33
 
34
34
  // New task received — store it for potential execution
35
- ws.onTask((task: ServerTask) => {
35
+ client.onTask((task: ServerTask) => {
36
36
  log?.info?.(`[clawroom:executor] received task ${task.taskId}: ${task.title}`);
37
37
  knownTasks.set(task.taskId, task);
38
38
  });
39
39
 
40
40
  // Task list on connect — store all
41
- ws.onTaskList((tasks: ServerTask[]) => {
41
+ client.onTaskList((tasks: ServerTask[]) => {
42
42
  log?.info?.(`[clawroom:executor] ${tasks.length} open task(s) available`);
43
43
  for (const t of tasks) {
44
44
  log?.info?.(`[clawroom:executor] - ${t.taskId}: ${t.title}`);
@@ -47,7 +47,7 @@ export function setupTaskExecutor(opts: {
47
47
  });
48
48
 
49
49
  // Claim ack — either from plugin-initiated claim or Dashboard-initiated claim
50
- ws.onClaimAck((ack: ServerClaimAck) => {
50
+ client.onClaimAck((ack: ServerClaimAck) => {
51
51
  if (!ack.ok) {
52
52
  log?.warn?.(
53
53
  `[clawroom:executor] claim rejected for ${ack.taskId}: ${ack.reason ?? "unknown"}`,
@@ -67,7 +67,7 @@ export function setupTaskExecutor(opts: {
67
67
  }
68
68
 
69
69
  log?.info?.(`[clawroom:executor] executing task ${task.taskId}`);
70
- void executeTask({ ws, runtime, task, log });
70
+ void executeTask({ client, runtime, task, log });
71
71
  });
72
72
  }
73
73
 
@@ -76,7 +76,7 @@ export function setupTaskExecutor(opts: {
76
76
  * result back to the ClawRoom server.
77
77
  */
78
78
  async function executeTask(opts: {
79
- ws: ClawroomWsClient;
79
+ client: ClawroomClient;
80
80
  runtime: PluginRuntime;
81
81
  task: ServerTask;
82
82
  log?: {
@@ -84,7 +84,7 @@ async function executeTask(opts: {
84
84
  error?: (...args: unknown[]) => void;
85
85
  };
86
86
  }): Promise<void> {
87
- const { ws, runtime, task, log } = opts;
87
+ const { client, runtime, task, log } = opts;
88
88
  const sessionKey = `clawroom:task:${task.taskId}`;
89
89
 
90
90
  const agentMessage = buildAgentMessage(task);
@@ -116,7 +116,7 @@ async function executeTask(opts: {
116
116
  log?.error?.(
117
117
  `[clawroom:executor] subagent error for task ${task.taskId}: ${waitResult.error}`,
118
118
  );
119
- ws.send({
119
+ client.send({
120
120
  type: "agent.fail",
121
121
  taskId: task.taskId,
122
122
  reason: waitResult.error ?? "Agent execution failed",
@@ -126,7 +126,7 @@ async function executeTask(opts: {
126
126
 
127
127
  if (waitResult.status === "timeout") {
128
128
  log?.error?.(`[clawroom:executor] subagent timeout for task ${task.taskId}`);
129
- ws.send({
129
+ client.send({
130
130
  type: "agent.fail",
131
131
  taskId: task.taskId,
132
132
  reason: "Agent execution timed out",
@@ -155,7 +155,7 @@ async function executeTask(opts: {
155
155
  .trim();
156
156
 
157
157
  log?.info?.(`[clawroom:executor] task ${task.taskId} completed${files.length > 0 ? ` (with ${files.length} file(s))` : ""}`);
158
- ws.send({
158
+ client.send({
159
159
  type: "agent.complete",
160
160
  taskId: task.taskId,
161
161
  output,
@@ -169,7 +169,7 @@ async function executeTask(opts: {
169
169
  } catch (err) {
170
170
  const reason = err instanceof Error ? err.message : String(err);
171
171
  log?.error?.(`[clawroom:executor] unexpected error for task ${task.taskId}: ${reason}`);
172
- ws.send({
172
+ client.send({
173
173
  type: "agent.release",
174
174
  taskId: task.taskId,
175
175
  reason,