@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 +2 -2
- package/package.json +1 -1
- package/src/channel.ts +9 -9
- package/src/{ws-client.ts → client.ts} +144 -184
- package/src/task-executor.ts +13 -13
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` |
|
|
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
|
|
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
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 {
|
|
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 = "
|
|
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
|
|
57
|
+
// ── Persistent client per gateway lifecycle ───────────────────────
|
|
58
58
|
|
|
59
|
-
let activeClient:
|
|
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
|
|
107
|
-
// The task-executor sends results directly through
|
|
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
|
|
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
|
|
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({
|
|
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
|
|
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
|
-
|
|
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
|
|
49
|
-
type ModeChangeCallback = (mode: "
|
|
34
|
+
type FatalCallback = (reason: string) => void;
|
|
35
|
+
type ModeChangeCallback = (mode: "sse" | "polling") => void;
|
|
50
36
|
|
|
51
|
-
export type
|
|
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
|
-
*
|
|
50
|
+
* Claw Room agent client using SSE (server push) + HTTP (agent actions).
|
|
65
51
|
*
|
|
66
|
-
* Primary mode:
|
|
67
|
-
* Fallback mode: HTTP
|
|
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
|
-
*
|
|
70
|
-
*
|
|
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
|
|
75
|
-
private
|
|
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
|
|
62
|
+
private consecutiveSseFails = 0;
|
|
81
63
|
private reconnecting = false;
|
|
82
64
|
private stopped = false;
|
|
83
|
-
private
|
|
84
|
-
private mode: "ws" | "polling" = "ws";
|
|
65
|
+
private mode: "sse" | "polling" = "sse";
|
|
85
66
|
private pollCycleCount = 0;
|
|
86
|
-
private httpBase
|
|
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:
|
|
99
|
-
// Derive HTTP base
|
|
100
|
-
// wss://
|
|
101
|
-
//
|
|
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/stream → https://clawroom.site9.ai/api/agents
|
|
102
83
|
const ep = options.endpoint;
|
|
103
|
-
|
|
104
|
-
|
|
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.
|
|
112
|
-
this.
|
|
113
|
-
this.
|
|
114
|
-
this.
|
|
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.
|
|
104
|
+
this.stopHeartbeat();
|
|
122
105
|
this.stopPolling();
|
|
123
|
-
this.
|
|
106
|
+
this.destroySSE();
|
|
124
107
|
}
|
|
125
108
|
|
|
126
109
|
send(message: AgentMessage): void {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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;
|
|
139
|
-
return this.
|
|
117
|
+
if (this.mode === "polling") return true;
|
|
118
|
+
return this.sseAbort !== null && !this.sseAbort.signal.aborted;
|
|
140
119
|
}
|
|
141
|
-
get isFatal(): boolean { return
|
|
142
|
-
get currentMode(): "
|
|
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
|
-
// ──
|
|
133
|
+
// ── Heartbeat (HTTP POST, both modes) ───────────────────────────
|
|
155
134
|
|
|
156
|
-
private
|
|
157
|
-
this.
|
|
158
|
-
this.
|
|
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
|
|
162
|
-
if (this.
|
|
143
|
+
private stopHeartbeat(): void {
|
|
144
|
+
if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
|
|
163
145
|
}
|
|
164
146
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
this.
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
if (
|
|
179
|
-
this.
|
|
180
|
-
this.
|
|
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
|
-
|
|
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
|
-
//
|
|
204
|
-
if (this.
|
|
205
|
-
this.options.log?.warn?.(`[clawroom]
|
|
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.
|
|
240
|
+
if (!this.stopped) this.doConnectSSE();
|
|
220
241
|
}, delayMs);
|
|
221
242
|
}
|
|
222
243
|
|
|
223
|
-
// ──
|
|
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.
|
|
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
|
|
300
|
-
this.options.log?.info?.("[clawroom]
|
|
257
|
+
private switchToSSE(): void {
|
|
258
|
+
this.options.log?.info?.("[clawroom] SSE restored, switching back from polling");
|
|
301
259
|
this.stopPolling();
|
|
302
|
-
this.mode = "
|
|
303
|
-
for (const cb of this.modeChangeCallbacks) cb("
|
|
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
|
-
//
|
|
334
|
-
if (this.pollCycleCount %
|
|
335
|
-
this.options.log?.info?.("[clawroom] [poll] attempting
|
|
336
|
-
this.
|
|
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
|
-
|
|
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.
|
|
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();
|
package/src/task-executor.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
+
client.send({
|
|
173
173
|
type: "agent.release",
|
|
174
174
|
taskId: task.taskId,
|
|
175
175
|
reason,
|