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