@clawroom/openclaw 0.2.0 → 0.2.2
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 +1 -1
- package/package.json +2 -2
- package/src/channel.ts +10 -22
- package/src/client.ts +227 -0
- package/src/task-executor.ts +26 -16
- package/src/ws-client.ts +0 -359
package/README.md
CHANGED
|
@@ -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 HTTP polling.
|
|
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.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "OpenClaw channel plugin for the Claw Room task marketplace",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"openclaw.plugin.json"
|
|
21
21
|
],
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@clawroom/sdk": "
|
|
23
|
+
"@clawroom/sdk": "^0.2.2"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
26
|
"openclaw": "*"
|
package/src/channel.ts
CHANGED
|
@@ -2,7 +2,7 @@ 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 ────────────────────────────────────────────────
|
|
@@ -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,10 +169,10 @@ export const clawroomPlugin: ChannelPlugin<ResolvedClawroomAccount> = {
|
|
|
169
169
|
});
|
|
170
170
|
};
|
|
171
171
|
|
|
172
|
-
// Wire up
|
|
172
|
+
// Wire up HTTP polling events to OpenClaw health status
|
|
173
173
|
client.onWelcome(() => publishConnected());
|
|
174
174
|
client.onTask(() => {
|
|
175
|
-
// Any server
|
|
175
|
+
// Any server task = update lastEventAt so gateway knows we're alive
|
|
176
176
|
ctx.setStatus({
|
|
177
177
|
accountId: account.accountId,
|
|
178
178
|
running: true,
|
|
@@ -184,18 +184,6 @@ export const clawroomPlugin: ChannelPlugin<ResolvedClawroomAccount> = {
|
|
|
184
184
|
});
|
|
185
185
|
});
|
|
186
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
187
|
client.onFatal((reason, code) => {
|
|
200
188
|
log?.error?.(`[clawroom] fatal error (code ${code}): ${reason}`);
|
|
201
189
|
ctx.setStatus({
|
|
@@ -208,11 +196,11 @@ export const clawroomPlugin: ChannelPlugin<ResolvedClawroomAccount> = {
|
|
|
208
196
|
});
|
|
209
197
|
});
|
|
210
198
|
|
|
211
|
-
setupTaskExecutor({
|
|
199
|
+
setupTaskExecutor({ client: client, runtime, log });
|
|
212
200
|
client.connect();
|
|
213
201
|
activeClient = client;
|
|
214
202
|
|
|
215
|
-
publishDisconnected("connecting...");
|
|
203
|
+
publishDisconnected("connecting via HTTP polling...");
|
|
216
204
|
|
|
217
205
|
// Health check: if client somehow stopped, restart it.
|
|
218
206
|
const healthCheck = setInterval(() => {
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import type { AgentMessage, ServerClaimAck, ServerMessage, ServerTask } from "@clawroom/sdk";
|
|
2
|
+
|
|
3
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
4
|
+
const POLL_INTERVAL_MS = 10_000;
|
|
5
|
+
|
|
6
|
+
// ── Types ─────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
type TaskCallback = (task: ServerTask) => void;
|
|
9
|
+
type TaskListCallback = (tasks: ServerTask[]) => void;
|
|
10
|
+
type ClaimAckCallback = (ack: ServerClaimAck) => void;
|
|
11
|
+
type ClaimRequestCallback = (task: ServerTask) => void;
|
|
12
|
+
type ErrorCallback = (error: ServerMessage & { type: "server.error" }) => void;
|
|
13
|
+
type DisconnectCallback = () => void;
|
|
14
|
+
type WelcomeCallback = (agentId: string) => void;
|
|
15
|
+
type FatalCallback = (reason: string) => void;
|
|
16
|
+
type ModeChangeCallback = (mode: "sse" | "polling") => void;
|
|
17
|
+
|
|
18
|
+
export type ClawroomClientOptions = {
|
|
19
|
+
endpoint: string;
|
|
20
|
+
token: string;
|
|
21
|
+
deviceId: string;
|
|
22
|
+
skills: string[];
|
|
23
|
+
log?: {
|
|
24
|
+
info?: (...args: unknown[]) => void;
|
|
25
|
+
warn?: (...args: unknown[]) => void;
|
|
26
|
+
error?: (...args: unknown[]) => void;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Claw Room agent client using HTTP polling.
|
|
32
|
+
*
|
|
33
|
+
* Agent→Server actions use HTTP POST:
|
|
34
|
+
* /api/agents/heartbeat, /poll, /complete, /fail, /progress, /claim
|
|
35
|
+
*/
|
|
36
|
+
export class ClawroomClient {
|
|
37
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
38
|
+
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
39
|
+
private stopped = false;
|
|
40
|
+
private connected = false;
|
|
41
|
+
private httpBase: string;
|
|
42
|
+
|
|
43
|
+
private taskCallbacks: TaskCallback[] = [];
|
|
44
|
+
private taskListCallbacks: TaskListCallback[] = [];
|
|
45
|
+
private claimAckCallbacks: ClaimAckCallback[] = [];
|
|
46
|
+
private claimRequestCallbacks: ClaimRequestCallback[] = [];
|
|
47
|
+
private errorCallbacks: ErrorCallback[] = [];
|
|
48
|
+
private disconnectCallbacks: DisconnectCallback[] = [];
|
|
49
|
+
private welcomeCallbacks: WelcomeCallback[] = [];
|
|
50
|
+
private fatalCallbacks: FatalCallback[] = [];
|
|
51
|
+
private modeChangeCallbacks: ModeChangeCallback[] = [];
|
|
52
|
+
|
|
53
|
+
constructor(private readonly options: ClawroomClientOptions) {
|
|
54
|
+
const ep = options.endpoint;
|
|
55
|
+
if (ep.includes("/api/agents")) {
|
|
56
|
+
this.httpBase = ep.replace(/\/stream\/?$/, "");
|
|
57
|
+
} else {
|
|
58
|
+
const httpUrl = ep.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
|
|
59
|
+
this.httpBase = httpUrl.replace(/\/ws\/.*$/, "/api/agents");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
connect(): void {
|
|
64
|
+
this.stopped = false;
|
|
65
|
+
this.startHeartbeat();
|
|
66
|
+
this.startPolling();
|
|
67
|
+
void this.register();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
disconnect(): void {
|
|
71
|
+
this.stopped = true;
|
|
72
|
+
this.stopHeartbeat();
|
|
73
|
+
this.stopPolling();
|
|
74
|
+
this.markDisconnected();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
send(message: AgentMessage): void {
|
|
78
|
+
this.sendViaHttp(message).catch((err) => {
|
|
79
|
+
this.options.log?.warn?.(`[clawroom] send error: ${err}`);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get isAlive(): boolean { return !this.stopped; }
|
|
84
|
+
get isConnected(): boolean { return this.connected; }
|
|
85
|
+
get isFatal(): boolean { return false; }
|
|
86
|
+
get currentMode(): "sse" | "polling" { return "polling"; }
|
|
87
|
+
|
|
88
|
+
onTask(cb: TaskCallback): void { this.taskCallbacks.push(cb); }
|
|
89
|
+
onTaskList(cb: TaskListCallback): void { this.taskListCallbacks.push(cb); }
|
|
90
|
+
onClaimAck(cb: ClaimAckCallback): void { this.claimAckCallbacks.push(cb); }
|
|
91
|
+
onClaimRequest(cb: ClaimRequestCallback): void { this.claimRequestCallbacks.push(cb); }
|
|
92
|
+
onError(cb: ErrorCallback): void { this.errorCallbacks.push(cb); }
|
|
93
|
+
onDisconnect(cb: DisconnectCallback): void { this.disconnectCallbacks.push(cb); }
|
|
94
|
+
onWelcome(cb: WelcomeCallback): void { this.welcomeCallbacks.push(cb); }
|
|
95
|
+
onFatal(cb: FatalCallback): void { this.fatalCallbacks.push(cb); }
|
|
96
|
+
onModeChange(cb: ModeChangeCallback): void { this.modeChangeCallbacks.push(cb); }
|
|
97
|
+
|
|
98
|
+
// ── Heartbeat (HTTP POST, both modes) ───────────────────────────
|
|
99
|
+
|
|
100
|
+
private startHeartbeat(): void {
|
|
101
|
+
this.stopHeartbeat();
|
|
102
|
+
this.heartbeatTimer = setInterval(() => {
|
|
103
|
+
if (this.stopped) return;
|
|
104
|
+
void this.register();
|
|
105
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private stopHeartbeat(): void {
|
|
109
|
+
if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private markConnected(agentId?: string): void {
|
|
113
|
+
if (this.connected) return;
|
|
114
|
+
this.connected = true;
|
|
115
|
+
this.options.log?.info?.("[clawroom] polling connected");
|
|
116
|
+
for (const cb of this.modeChangeCallbacks) cb("polling");
|
|
117
|
+
if (agentId) {
|
|
118
|
+
for (const cb of this.welcomeCallbacks) cb(agentId);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private markDisconnected(): void {
|
|
123
|
+
if (!this.connected) return;
|
|
124
|
+
this.connected = false;
|
|
125
|
+
for (const cb of this.disconnectCallbacks) cb();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private startPolling(): void {
|
|
129
|
+
this.stopPolling();
|
|
130
|
+
this.options.log?.info?.(`[clawroom] polling ${this.httpBase}/poll`);
|
|
131
|
+
this.pollTimer = setInterval(() => {
|
|
132
|
+
void this.pollTick();
|
|
133
|
+
}, POLL_INTERVAL_MS);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private stopPolling(): void {
|
|
137
|
+
if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async register(): Promise<void> {
|
|
141
|
+
try {
|
|
142
|
+
const res = await this.httpRequest("POST", "/heartbeat", {
|
|
143
|
+
deviceId: this.options.deviceId,
|
|
144
|
+
skills: this.options.skills,
|
|
145
|
+
});
|
|
146
|
+
this.markConnected(typeof res?.agentId === "string" ? res.agentId : undefined);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
this.options.log?.warn?.(`[clawroom] heartbeat error: ${err}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private async pollTick(): Promise<void> {
|
|
153
|
+
if (this.stopped) return;
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const res = await this.httpRequest("POST", "/poll", {});
|
|
157
|
+
this.markConnected(typeof res?.agentId === "string" ? res.agentId : undefined);
|
|
158
|
+
if (res.task) {
|
|
159
|
+
const task = res.task as ServerTask;
|
|
160
|
+
this.options.log?.info?.(`[clawroom] received task ${task.taskId}: ${task.title}`);
|
|
161
|
+
for (const cb of this.taskCallbacks) cb(task);
|
|
162
|
+
for (const cb of this.claimAckCallbacks) cb({ type: "server.claim_ack", taskId: task.taskId, ok: true });
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
this.options.log?.warn?.(`[clawroom] poll error: ${err}`);
|
|
166
|
+
this.markDisconnected();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── HTTP send ───────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
private async sendViaHttp(message: AgentMessage): Promise<void> {
|
|
173
|
+
switch (message.type) {
|
|
174
|
+
case "agent.complete":
|
|
175
|
+
await this.httpRequest("POST", "/complete", {
|
|
176
|
+
taskId: message.taskId,
|
|
177
|
+
output: message.output,
|
|
178
|
+
attachments: message.attachments,
|
|
179
|
+
});
|
|
180
|
+
break;
|
|
181
|
+
case "agent.fail":
|
|
182
|
+
await this.httpRequest("POST", "/fail", {
|
|
183
|
+
taskId: message.taskId,
|
|
184
|
+
reason: message.reason,
|
|
185
|
+
});
|
|
186
|
+
break;
|
|
187
|
+
case "agent.progress":
|
|
188
|
+
await this.httpRequest("POST", "/progress", {
|
|
189
|
+
taskId: message.taskId,
|
|
190
|
+
message: message.message,
|
|
191
|
+
percent: message.percent,
|
|
192
|
+
});
|
|
193
|
+
break;
|
|
194
|
+
case "agent.heartbeat":
|
|
195
|
+
await this.httpRequest("POST", "/heartbeat", {});
|
|
196
|
+
break;
|
|
197
|
+
case "agent.claim":
|
|
198
|
+
await this.httpRequest("POST", "/claim", { taskId: message.taskId });
|
|
199
|
+
break;
|
|
200
|
+
default:
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async httpRequest(method: string, path: string, body: unknown): Promise<any> {
|
|
206
|
+
const res = await fetch(`${this.httpBase}${path}`, {
|
|
207
|
+
method,
|
|
208
|
+
headers: {
|
|
209
|
+
"Content-Type": "application/json",
|
|
210
|
+
"Authorization": `Bearer ${this.options.token}`,
|
|
211
|
+
},
|
|
212
|
+
body: JSON.stringify(body),
|
|
213
|
+
});
|
|
214
|
+
if (!res.ok) {
|
|
215
|
+
const text = await res.text().catch(() => "");
|
|
216
|
+
if (res.status === 401) {
|
|
217
|
+
this.stopped = true;
|
|
218
|
+
this.stopHeartbeat();
|
|
219
|
+
this.stopPolling();
|
|
220
|
+
this.markDisconnected();
|
|
221
|
+
for (const cb of this.fatalCallbacks) cb(`Unauthorized: ${text}`);
|
|
222
|
+
}
|
|
223
|
+
throw new Error(`HTTP ${res.status}: ${text}`);
|
|
224
|
+
}
|
|
225
|
+
return res.json();
|
|
226
|
+
}
|
|
227
|
+
}
|
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
|
|
|
@@ -12,13 +12,14 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Wire up the task execution pipeline:
|
|
15
|
-
* 1. On server.task / server.task_list -> store as available
|
|
15
|
+
* 1. On server.task / server.task_list -> store as available
|
|
16
16
|
* 2. On claim_ack(ok) -> invoke subagent -> send result/fail
|
|
17
17
|
*
|
|
18
|
-
*
|
|
18
|
+
* Tasks arrive through the polling client, either because the agent auto-claimed
|
|
19
|
+
* them or because the owner manually claimed them from the dashboard.
|
|
19
20
|
*/
|
|
20
21
|
export function setupTaskExecutor(opts: {
|
|
21
|
-
|
|
22
|
+
client: ClawroomClient;
|
|
22
23
|
runtime: PluginRuntime;
|
|
23
24
|
log?: {
|
|
24
25
|
info?: (...args: unknown[]) => void;
|
|
@@ -26,19 +27,20 @@ export function setupTaskExecutor(opts: {
|
|
|
26
27
|
error?: (...args: unknown[]) => void;
|
|
27
28
|
};
|
|
28
29
|
}): void {
|
|
29
|
-
const {
|
|
30
|
+
const { client, runtime, log } = opts;
|
|
30
31
|
|
|
31
|
-
// Track received tasks
|
|
32
|
+
// Track received tasks until they transition into an active execution.
|
|
32
33
|
const knownTasks = new Map<string, ServerTask>();
|
|
34
|
+
const activeTasks = new Set<string>();
|
|
33
35
|
|
|
34
36
|
// New task received — store it for potential execution
|
|
35
|
-
|
|
37
|
+
client.onTask((task: ServerTask) => {
|
|
36
38
|
log?.info?.(`[clawroom:executor] received task ${task.taskId}: ${task.title}`);
|
|
37
39
|
knownTasks.set(task.taskId, task);
|
|
38
40
|
});
|
|
39
41
|
|
|
40
42
|
// Task list on connect — store all
|
|
41
|
-
|
|
43
|
+
client.onTaskList((tasks: ServerTask[]) => {
|
|
42
44
|
log?.info?.(`[clawroom:executor] ${tasks.length} open task(s) available`);
|
|
43
45
|
for (const t of tasks) {
|
|
44
46
|
log?.info?.(`[clawroom:executor] - ${t.taskId}: ${t.title}`);
|
|
@@ -47,7 +49,7 @@ export function setupTaskExecutor(opts: {
|
|
|
47
49
|
});
|
|
48
50
|
|
|
49
51
|
// Claim ack — either from plugin-initiated claim or Dashboard-initiated claim
|
|
50
|
-
|
|
52
|
+
client.onClaimAck((ack: ServerClaimAck) => {
|
|
51
53
|
if (!ack.ok) {
|
|
52
54
|
log?.warn?.(
|
|
53
55
|
`[clawroom:executor] claim rejected for ${ack.taskId}: ${ack.reason ?? "unknown"}`,
|
|
@@ -59,6 +61,11 @@ export function setupTaskExecutor(opts: {
|
|
|
59
61
|
const task = knownTasks.get(ack.taskId);
|
|
60
62
|
knownTasks.delete(ack.taskId);
|
|
61
63
|
|
|
64
|
+
if (activeTasks.has(ack.taskId)) {
|
|
65
|
+
log?.info?.(`[clawroom:executor] task ${ack.taskId} is already running, ignoring duplicate dispatch`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
62
69
|
if (!task) {
|
|
63
70
|
log?.warn?.(
|
|
64
71
|
`[clawroom:executor] claim_ack for unknown task ${ack.taskId}`,
|
|
@@ -67,7 +74,10 @@ export function setupTaskExecutor(opts: {
|
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
log?.info?.(`[clawroom:executor] executing task ${task.taskId}`);
|
|
70
|
-
|
|
77
|
+
activeTasks.add(task.taskId);
|
|
78
|
+
void executeTask({ client, runtime, task, log }).finally(() => {
|
|
79
|
+
activeTasks.delete(task.taskId);
|
|
80
|
+
});
|
|
71
81
|
});
|
|
72
82
|
}
|
|
73
83
|
|
|
@@ -76,7 +86,7 @@ export function setupTaskExecutor(opts: {
|
|
|
76
86
|
* result back to the ClawRoom server.
|
|
77
87
|
*/
|
|
78
88
|
async function executeTask(opts: {
|
|
79
|
-
|
|
89
|
+
client: ClawroomClient;
|
|
80
90
|
runtime: PluginRuntime;
|
|
81
91
|
task: ServerTask;
|
|
82
92
|
log?: {
|
|
@@ -84,7 +94,7 @@ async function executeTask(opts: {
|
|
|
84
94
|
error?: (...args: unknown[]) => void;
|
|
85
95
|
};
|
|
86
96
|
}): Promise<void> {
|
|
87
|
-
const {
|
|
97
|
+
const { client, runtime, task, log } = opts;
|
|
88
98
|
const sessionKey = `clawroom:task:${task.taskId}`;
|
|
89
99
|
|
|
90
100
|
const agentMessage = buildAgentMessage(task);
|
|
@@ -116,7 +126,7 @@ async function executeTask(opts: {
|
|
|
116
126
|
log?.error?.(
|
|
117
127
|
`[clawroom:executor] subagent error for task ${task.taskId}: ${waitResult.error}`,
|
|
118
128
|
);
|
|
119
|
-
|
|
129
|
+
client.send({
|
|
120
130
|
type: "agent.fail",
|
|
121
131
|
taskId: task.taskId,
|
|
122
132
|
reason: waitResult.error ?? "Agent execution failed",
|
|
@@ -126,7 +136,7 @@ async function executeTask(opts: {
|
|
|
126
136
|
|
|
127
137
|
if (waitResult.status === "timeout") {
|
|
128
138
|
log?.error?.(`[clawroom:executor] subagent timeout for task ${task.taskId}`);
|
|
129
|
-
|
|
139
|
+
client.send({
|
|
130
140
|
type: "agent.fail",
|
|
131
141
|
taskId: task.taskId,
|
|
132
142
|
reason: "Agent execution timed out",
|
|
@@ -155,7 +165,7 @@ async function executeTask(opts: {
|
|
|
155
165
|
.trim();
|
|
156
166
|
|
|
157
167
|
log?.info?.(`[clawroom:executor] task ${task.taskId} completed${files.length > 0 ? ` (with ${files.length} file(s))` : ""}`);
|
|
158
|
-
|
|
168
|
+
client.send({
|
|
159
169
|
type: "agent.complete",
|
|
160
170
|
taskId: task.taskId,
|
|
161
171
|
output,
|
|
@@ -169,7 +179,7 @@ async function executeTask(opts: {
|
|
|
169
179
|
} catch (err) {
|
|
170
180
|
const reason = err instanceof Error ? err.message : String(err);
|
|
171
181
|
log?.error?.(`[clawroom:executor] unexpected error for task ${task.taskId}: ${reason}`);
|
|
172
|
-
|
|
182
|
+
client.send({
|
|
173
183
|
type: "agent.release",
|
|
174
184
|
taskId: task.taskId,
|
|
175
185
|
reason,
|
package/src/ws-client.ts
DELETED
|
@@ -1,359 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
AgentMessage,
|
|
3
|
-
ServerClaimAck,
|
|
4
|
-
ServerMessage,
|
|
5
|
-
ServerTask,
|
|
6
|
-
} from "@clawroom/sdk";
|
|
7
|
-
|
|
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 ─────────────────────────────────────────────────────────────
|
|
26
|
-
|
|
27
|
-
type TaskCallback = (task: ServerTask) => void;
|
|
28
|
-
type TaskListCallback = (tasks: ServerTask[]) => void;
|
|
29
|
-
type ClaimAckCallback = (ack: ServerClaimAck) => void;
|
|
30
|
-
type ClaimRequestCallback = (task: ServerTask) => void;
|
|
31
|
-
type ErrorCallback = (error: ServerMessage & { type: "server.error" }) => void;
|
|
32
|
-
type DisconnectCallback = () => void;
|
|
33
|
-
type WelcomeCallback = (agentId: string) => void;
|
|
34
|
-
type FatalCallback = (reason: string) => void;
|
|
35
|
-
type ModeChangeCallback = (mode: "sse" | "polling") => void;
|
|
36
|
-
|
|
37
|
-
export type ClawroomWsClientOptions = {
|
|
38
|
-
endpoint: string;
|
|
39
|
-
token: string;
|
|
40
|
-
deviceId: string;
|
|
41
|
-
skills: string[];
|
|
42
|
-
log?: {
|
|
43
|
-
info?: (...args: unknown[]) => void;
|
|
44
|
-
warn?: (...args: unknown[]) => void;
|
|
45
|
-
error?: (...args: unknown[]) => void;
|
|
46
|
-
};
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
/**
|
|
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)
|
|
54
|
-
*
|
|
55
|
-
* Agent→Server actions always use HTTP POST:
|
|
56
|
-
* /api/agents/heartbeat, /complete, /fail, /progress, /claim
|
|
57
|
-
*/
|
|
58
|
-
export class ClawroomWsClient {
|
|
59
|
-
private eventSource: EventSource | null = null;
|
|
60
|
-
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
61
|
-
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
62
|
-
private reconnectAttempt = 0;
|
|
63
|
-
private consecutiveSseFails = 0;
|
|
64
|
-
private reconnecting = false;
|
|
65
|
-
private stopped = false;
|
|
66
|
-
private mode: "sse" | "polling" = "sse";
|
|
67
|
-
private pollCycleCount = 0;
|
|
68
|
-
private httpBase: string;
|
|
69
|
-
|
|
70
|
-
private taskCallbacks: TaskCallback[] = [];
|
|
71
|
-
private taskListCallbacks: TaskListCallback[] = [];
|
|
72
|
-
private claimAckCallbacks: ClaimAckCallback[] = [];
|
|
73
|
-
private claimRequestCallbacks: ClaimRequestCallback[] = [];
|
|
74
|
-
private errorCallbacks: ErrorCallback[] = [];
|
|
75
|
-
private disconnectCallbacks: DisconnectCallback[] = [];
|
|
76
|
-
private welcomeCallbacks: WelcomeCallback[] = [];
|
|
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
|
-
}
|
|
92
|
-
|
|
93
|
-
connect(): void {
|
|
94
|
-
this.stopped = false;
|
|
95
|
-
this.reconnecting = false;
|
|
96
|
-
this.reconnectAttempt = 0;
|
|
97
|
-
this.consecutiveSseFails = 0;
|
|
98
|
-
this.mode = "sse";
|
|
99
|
-
this.doConnectSSE();
|
|
100
|
-
this.startHeartbeat();
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
disconnect(): void {
|
|
104
|
-
this.stopped = true;
|
|
105
|
-
this.stopHeartbeat();
|
|
106
|
-
this.stopPolling();
|
|
107
|
-
this.destroySSE();
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
send(message: AgentMessage): void {
|
|
111
|
-
this.sendViaHttp(message).catch((err) => {
|
|
112
|
-
this.options.log?.warn?.(`[clawroom] send error: ${err}`);
|
|
113
|
-
});
|
|
114
|
-
}
|
|
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
|
-
|
|
124
|
-
onTask(cb: TaskCallback): void { this.taskCallbacks.push(cb); }
|
|
125
|
-
onTaskList(cb: TaskListCallback): void { this.taskListCallbacks.push(cb); }
|
|
126
|
-
onClaimAck(cb: ClaimAckCallback): void { this.claimAckCallbacks.push(cb); }
|
|
127
|
-
onClaimRequest(cb: ClaimRequestCallback): void { this.claimRequestCallbacks.push(cb); }
|
|
128
|
-
onError(cb: ErrorCallback): void { this.errorCallbacks.push(cb); }
|
|
129
|
-
onDisconnect(cb: DisconnectCallback): void { this.disconnectCallbacks.push(cb); }
|
|
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); }
|
|
133
|
-
|
|
134
|
-
// ── Heartbeat (HTTP POST, both modes) ───────────────────────────
|
|
135
|
-
|
|
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);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
private stopHeartbeat(): void {
|
|
145
|
-
if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// ── SSE Connection ──────────────────────────────────────────────
|
|
149
|
-
|
|
150
|
-
private doConnectSSE(): void {
|
|
151
|
-
this.destroySSE();
|
|
152
|
-
const { token, deviceId, skills } = this.options;
|
|
153
|
-
const params = new URLSearchParams({
|
|
154
|
-
token,
|
|
155
|
-
deviceId,
|
|
156
|
-
skills: skills.join(","),
|
|
157
|
-
});
|
|
158
|
-
const url = `${this.httpBase}/stream?${params}`;
|
|
159
|
-
|
|
160
|
-
this.options.log?.info?.(`[clawroom] SSE connecting to ${this.httpBase}/stream`);
|
|
161
|
-
|
|
162
|
-
try {
|
|
163
|
-
this.eventSource = new EventSource(url);
|
|
164
|
-
} catch {
|
|
165
|
-
this.triggerReconnect("EventSource creation failed");
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
|
|
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
|
-
}
|
|
190
|
-
|
|
191
|
-
private destroySSE(): void {
|
|
192
|
-
if (this.eventSource) {
|
|
193
|
-
try { this.eventSource.close(); } catch {}
|
|
194
|
-
this.eventSource = null;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
private triggerReconnect(reason: string): void {
|
|
199
|
-
if (this.reconnecting || this.stopped) return;
|
|
200
|
-
|
|
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);
|
|
212
|
-
this.reconnectAttempt++;
|
|
213
|
-
this.options.log?.info?.(`[clawroom] reconnecting in ${delayMs}ms (${reason}, attempt ${this.reconnectAttempt})`);
|
|
214
|
-
|
|
215
|
-
setTimeout(() => {
|
|
216
|
-
this.reconnecting = false;
|
|
217
|
-
if (!this.stopped) this.doConnectSSE();
|
|
218
|
-
}, delayMs);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// ── Polling fallback ────────────────────────────────────────────
|
|
222
|
-
|
|
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");
|
|
228
|
-
|
|
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++;
|
|
248
|
-
|
|
249
|
-
try {
|
|
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}`);
|
|
259
|
-
}
|
|
260
|
-
|
|
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
|
-
}
|
|
267
|
-
|
|
268
|
-
// ── HTTP send ───────────────────────────────────────────────────
|
|
269
|
-
|
|
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
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
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}`);
|
|
322
|
-
}
|
|
323
|
-
return res.json();
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// ── Message handling ──────────────────────────────────────────────
|
|
327
|
-
|
|
328
|
-
private handleMessage(raw: string): void {
|
|
329
|
-
let msg: ServerMessage;
|
|
330
|
-
try { msg = JSON.parse(raw) as ServerMessage; } catch { return; }
|
|
331
|
-
|
|
332
|
-
switch (msg.type) {
|
|
333
|
-
case "server.welcome":
|
|
334
|
-
this.options.log?.info?.(`[clawroom] welcome, agentId=${msg.agentId}`);
|
|
335
|
-
for (const cb of this.welcomeCallbacks) cb(msg.agentId);
|
|
336
|
-
break;
|
|
337
|
-
case "server.pong":
|
|
338
|
-
break;
|
|
339
|
-
case "server.task":
|
|
340
|
-
this.options.log?.info?.(`[clawroom] received task ${msg.taskId}: ${msg.title}`);
|
|
341
|
-
for (const cb of this.taskCallbacks) cb(msg);
|
|
342
|
-
break;
|
|
343
|
-
case "server.task_list":
|
|
344
|
-
this.options.log?.info?.(`[clawroom] received ${msg.tasks.length} open task(s)`);
|
|
345
|
-
for (const cb of this.taskListCallbacks) cb(msg.tasks);
|
|
346
|
-
break;
|
|
347
|
-
case "server.claim_ack":
|
|
348
|
-
this.options.log?.info?.(`[clawroom] claim_ack taskId=${msg.taskId} ok=${msg.ok}${msg.reason ? ` reason=${msg.reason}` : ""}`);
|
|
349
|
-
for (const cb of this.claimAckCallbacks) cb(msg);
|
|
350
|
-
break;
|
|
351
|
-
case "server.error":
|
|
352
|
-
this.options.log?.error?.(`[clawroom] server error: ${msg.message}`);
|
|
353
|
-
for (const cb of this.errorCallbacks) cb(msg);
|
|
354
|
-
break;
|
|
355
|
-
default:
|
|
356
|
-
this.options.log?.warn?.("[clawroom] unknown message type", msg);
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
}
|