@clawroom/openclaw 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/channel.ts +74 -7
- package/src/ws-client.ts +234 -38
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/
|
|
18
|
+
| `channels.clawroom.endpoint` | WebSocket endpoint (default: `ws://localhost:3000/ws/agent`) |
|
|
19
19
|
| `channels.clawroom.skills` | Optional array of skill tags to advertise |
|
|
20
20
|
| `channels.clawroom.enabled` | Enable/disable the channel (default: `true`) |
|
|
21
21
|
|
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -145,26 +145,93 @@ export const clawroomPlugin: ChannelPlugin<ResolvedClawroomAccount> = {
|
|
|
145
145
|
log,
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
+
// Track connection state for OpenClaw health monitor
|
|
149
|
+
const publishConnected = () => {
|
|
150
|
+
ctx.setStatus({
|
|
151
|
+
accountId: account.accountId,
|
|
152
|
+
running: true,
|
|
153
|
+
connected: true,
|
|
154
|
+
lastStartAt: Date.now(),
|
|
155
|
+
lastStopAt: null,
|
|
156
|
+
lastError: null,
|
|
157
|
+
lastEventAt: Date.now(),
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const publishDisconnected = (error?: string) => {
|
|
162
|
+
ctx.setStatus({
|
|
163
|
+
accountId: account.accountId,
|
|
164
|
+
running: true,
|
|
165
|
+
connected: false,
|
|
166
|
+
lastStartAt: null,
|
|
167
|
+
lastStopAt: null,
|
|
168
|
+
lastError: error ?? "disconnected",
|
|
169
|
+
});
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Wire up WS events to OpenClaw health status
|
|
173
|
+
client.onWelcome(() => publishConnected());
|
|
174
|
+
client.onTask(() => {
|
|
175
|
+
// Any server message = update lastEventAt so gateway knows we're alive
|
|
176
|
+
ctx.setStatus({
|
|
177
|
+
accountId: account.accountId,
|
|
178
|
+
running: true,
|
|
179
|
+
connected: true,
|
|
180
|
+
lastEventAt: Date.now(),
|
|
181
|
+
lastStartAt: Date.now(),
|
|
182
|
+
lastStopAt: null,
|
|
183
|
+
lastError: null,
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
client.onDisconnect(() => publishDisconnected());
|
|
187
|
+
client.onModeChange((mode) => {
|
|
188
|
+
log?.info?.(`[clawroom] mode changed to ${mode}`);
|
|
189
|
+
ctx.setStatus({
|
|
190
|
+
accountId: account.accountId,
|
|
191
|
+
running: true,
|
|
192
|
+
connected: mode === "polling" ? true : client.isConnected,
|
|
193
|
+
lastEventAt: Date.now(),
|
|
194
|
+
lastStartAt: Date.now(),
|
|
195
|
+
lastStopAt: null,
|
|
196
|
+
lastError: mode === "polling" ? "degraded: HTTP polling" : null,
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
client.onFatal((reason, code) => {
|
|
200
|
+
log?.error?.(`[clawroom] fatal error (code ${code}): ${reason}`);
|
|
201
|
+
ctx.setStatus({
|
|
202
|
+
accountId: account.accountId,
|
|
203
|
+
running: false,
|
|
204
|
+
connected: false,
|
|
205
|
+
lastStopAt: Date.now(),
|
|
206
|
+
lastStartAt: null,
|
|
207
|
+
lastError: `Fatal: ${reason}`,
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
148
211
|
setupTaskExecutor({ ws: client, runtime, log });
|
|
149
212
|
client.connect();
|
|
150
213
|
activeClient = client;
|
|
151
214
|
|
|
152
|
-
|
|
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,48 @@ import type {
|
|
|
5
5
|
ServerTask,
|
|
6
6
|
} from "@clawroom/sdk";
|
|
7
7
|
|
|
8
|
+
// ── Reconnect policy (matches Telegram/Slack pattern) ─────────────────
|
|
9
|
+
const RECONNECT_POLICY = {
|
|
10
|
+
initialMs: 2_000,
|
|
11
|
+
maxMs: 30_000,
|
|
12
|
+
factor: 1.8,
|
|
13
|
+
jitter: 0.25,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function computeBackoff(attempt: number): number {
|
|
17
|
+
const base = RECONNECT_POLICY.initialMs * RECONNECT_POLICY.factor ** Math.max(attempt - 1, 0);
|
|
18
|
+
const jitter = base * RECONNECT_POLICY.jitter * Math.random();
|
|
19
|
+
return Math.min(RECONNECT_POLICY.maxMs, Math.round(base + jitter));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── Non-recoverable close codes ───────────────────────────────────────
|
|
23
|
+
const NON_RECOVERABLE_CODES = new Set([
|
|
24
|
+
4001, // Missing token
|
|
25
|
+
4002, // Invalid token
|
|
26
|
+
4003, // Token revoked / agent deleted
|
|
27
|
+
]);
|
|
28
|
+
|
|
8
29
|
const WATCHDOG_INTERVAL_MS = 10_000;
|
|
9
|
-
const DEAD_THRESHOLD_MS = 25_000;
|
|
10
|
-
|
|
11
|
-
|
|
30
|
+
const DEAD_THRESHOLD_MS = 25_000;
|
|
31
|
+
|
|
32
|
+
// WS fails this many times in a row → switch to HTTP polling
|
|
33
|
+
const WS_FAIL_THRESHOLD = 5;
|
|
34
|
+
// HTTP poll interval when in degraded mode
|
|
35
|
+
const POLL_INTERVAL_MS = 10_000;
|
|
36
|
+
// Try to restore WS every N poll cycles
|
|
37
|
+
const WS_RESTORE_INTERVAL = 6; // = 60s
|
|
38
|
+
|
|
39
|
+
// ── Types ─────────────────────────────────────────────────────────────
|
|
12
40
|
|
|
13
41
|
type TaskCallback = (task: ServerTask) => void;
|
|
14
42
|
type TaskListCallback = (tasks: ServerTask[]) => void;
|
|
15
43
|
type ClaimAckCallback = (ack: ServerClaimAck) => void;
|
|
16
44
|
type ClaimRequestCallback = (task: ServerTask) => void;
|
|
17
45
|
type ErrorCallback = (error: ServerMessage & { type: "server.error" }) => void;
|
|
46
|
+
type DisconnectCallback = () => void;
|
|
18
47
|
type WelcomeCallback = (agentId: string) => void;
|
|
48
|
+
type FatalCallback = (reason: string, code: number) => void;
|
|
49
|
+
type ModeChangeCallback = (mode: "ws" | "polling") => void;
|
|
19
50
|
|
|
20
51
|
export type ClawroomWsClientOptions = {
|
|
21
52
|
endpoint: string;
|
|
@@ -30,37 +61,57 @@ export type ClawroomWsClientOptions = {
|
|
|
30
61
|
};
|
|
31
62
|
|
|
32
63
|
/**
|
|
33
|
-
* WebSocket
|
|
64
|
+
* Hybrid WebSocket + HTTP Polling client.
|
|
65
|
+
*
|
|
66
|
+
* Primary mode: WebSocket (real-time task push)
|
|
67
|
+
* Fallback mode: HTTP Polling (when WS fails 5+ times consecutively)
|
|
34
68
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* - HTTP verify is fire-and-forget; reconnect is triggered regardless
|
|
40
|
-
* - Watchdog NEVER stops unless disconnect() is called
|
|
69
|
+
* Automatically switches between modes:
|
|
70
|
+
* - WS fails 5 times → degrade to polling
|
|
71
|
+
* - While polling, try to restore WS every 60s
|
|
72
|
+
* - WS reconnects successfully → switch back to WS, stop polling
|
|
41
73
|
*/
|
|
42
74
|
export class ClawroomWsClient {
|
|
43
75
|
private ws: WebSocket | null = null;
|
|
44
76
|
private watchdog: ReturnType<typeof setInterval> | null = null;
|
|
45
|
-
private
|
|
77
|
+
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
78
|
+
private lastActivity = 0;
|
|
46
79
|
private reconnectAttempt = 0;
|
|
80
|
+
private consecutiveWsFails = 0;
|
|
47
81
|
private reconnecting = false;
|
|
48
82
|
private stopped = false;
|
|
83
|
+
private lastCloseCode = 0;
|
|
84
|
+
private mode: "ws" | "polling" = "ws";
|
|
85
|
+
private pollCycleCount = 0;
|
|
86
|
+
private httpBase = ""; // derived from WS endpoint
|
|
49
87
|
|
|
50
88
|
private taskCallbacks: TaskCallback[] = [];
|
|
51
89
|
private taskListCallbacks: TaskListCallback[] = [];
|
|
52
90
|
private claimAckCallbacks: ClaimAckCallback[] = [];
|
|
53
91
|
private claimRequestCallbacks: ClaimRequestCallback[] = [];
|
|
54
92
|
private errorCallbacks: ErrorCallback[] = [];
|
|
93
|
+
private disconnectCallbacks: DisconnectCallback[] = [];
|
|
55
94
|
private welcomeCallbacks: WelcomeCallback[] = [];
|
|
56
|
-
|
|
57
|
-
|
|
95
|
+
private fatalCallbacks: FatalCallback[] = [];
|
|
96
|
+
private modeChangeCallbacks: ModeChangeCallback[] = [];
|
|
97
|
+
|
|
98
|
+
constructor(private readonly options: ClawroomWsClientOptions) {
|
|
99
|
+
// Derive HTTP base URL from WS endpoint
|
|
100
|
+
// wss://clawroom.site9.ai/ws/agent → https://clawroom.site9.ai/api/agents
|
|
101
|
+
// ws://localhost:3000/ws/agent → http://localhost:3000/api/agents
|
|
102
|
+
const ep = options.endpoint;
|
|
103
|
+
const httpUrl = ep.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
|
|
104
|
+
this.httpBase = httpUrl.replace(/\/ws\/.*$/, "/api/agents");
|
|
105
|
+
}
|
|
58
106
|
|
|
59
107
|
connect(): void {
|
|
60
108
|
this.stopped = false;
|
|
61
109
|
this.reconnecting = false;
|
|
62
110
|
this.reconnectAttempt = 0;
|
|
111
|
+
this.consecutiveWsFails = 0;
|
|
63
112
|
this.lastActivity = Date.now();
|
|
113
|
+
this.lastCloseCode = 0;
|
|
114
|
+
this.mode = "ws";
|
|
64
115
|
this.doConnect();
|
|
65
116
|
this.startWatchdog();
|
|
66
117
|
}
|
|
@@ -68,22 +119,39 @@ export class ClawroomWsClient {
|
|
|
68
119
|
disconnect(): void {
|
|
69
120
|
this.stopped = true;
|
|
70
121
|
this.stopWatchdog();
|
|
122
|
+
this.stopPolling();
|
|
71
123
|
this.destroySocket();
|
|
72
124
|
}
|
|
73
125
|
|
|
74
126
|
send(message: AgentMessage): void {
|
|
127
|
+
if (this.mode === "polling") {
|
|
128
|
+
// In polling mode, send via HTTP
|
|
129
|
+
this.sendViaHttp(message).catch(() => {});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
75
132
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
76
133
|
try { this.ws.send(JSON.stringify(message)); } catch {}
|
|
77
134
|
}
|
|
78
135
|
|
|
136
|
+
get isAlive(): boolean { return !this.stopped; }
|
|
137
|
+
get isConnected(): boolean {
|
|
138
|
+
if (this.mode === "polling") return true; // polling is always "connected"
|
|
139
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
140
|
+
}
|
|
141
|
+
get isFatal(): boolean { return NON_RECOVERABLE_CODES.has(this.lastCloseCode); }
|
|
142
|
+
get currentMode(): "ws" | "polling" { return this.mode; }
|
|
143
|
+
|
|
79
144
|
onTask(cb: TaskCallback): void { this.taskCallbacks.push(cb); }
|
|
80
145
|
onTaskList(cb: TaskListCallback): void { this.taskListCallbacks.push(cb); }
|
|
81
146
|
onClaimAck(cb: ClaimAckCallback): void { this.claimAckCallbacks.push(cb); }
|
|
82
147
|
onClaimRequest(cb: ClaimRequestCallback): void { this.claimRequestCallbacks.push(cb); }
|
|
83
148
|
onError(cb: ErrorCallback): void { this.errorCallbacks.push(cb); }
|
|
149
|
+
onDisconnect(cb: DisconnectCallback): void { this.disconnectCallbacks.push(cb); }
|
|
84
150
|
onWelcome(cb: WelcomeCallback): void { this.welcomeCallbacks.push(cb); }
|
|
151
|
+
onFatal(cb: FatalCallback): void { this.fatalCallbacks.push(cb); }
|
|
152
|
+
onModeChange(cb: ModeChangeCallback): void { this.modeChangeCallbacks.push(cb); }
|
|
85
153
|
|
|
86
|
-
// ── Watchdog
|
|
154
|
+
// ── Watchdog (WS mode only) ─────────────────────────────────────
|
|
87
155
|
|
|
88
156
|
private startWatchdog(): void {
|
|
89
157
|
this.stopWatchdog();
|
|
@@ -94,43 +162,55 @@ export class ClawroomWsClient {
|
|
|
94
162
|
if (this.watchdog) { clearInterval(this.watchdog); this.watchdog = null; }
|
|
95
163
|
}
|
|
96
164
|
|
|
97
|
-
/** Synchronous tick — no async, no promises, no way to silently fail. */
|
|
98
165
|
private tick(): void {
|
|
99
|
-
if (this.stopped) return;
|
|
100
|
-
|
|
101
|
-
// If already reconnecting, skip
|
|
102
|
-
if (this.reconnecting) return;
|
|
166
|
+
if (this.stopped || this.reconnecting || this.mode === "polling") return;
|
|
103
167
|
|
|
104
168
|
const ws = this.ws;
|
|
105
169
|
|
|
106
|
-
// No socket → reconnect
|
|
107
170
|
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
|
|
108
171
|
this.triggerReconnect("no socket");
|
|
109
172
|
return;
|
|
110
173
|
}
|
|
111
174
|
|
|
112
|
-
// Still connecting → wait
|
|
113
175
|
if (ws.readyState === WebSocket.CONNECTING) return;
|
|
114
176
|
|
|
115
|
-
// Socket is OPEN — check if server is responsive
|
|
116
177
|
const elapsed = Date.now() - this.lastActivity;
|
|
117
178
|
if (elapsed > DEAD_THRESHOLD_MS) {
|
|
118
|
-
// No response from server in 25s — it's dead
|
|
119
179
|
this.options.log?.warn?.(`[clawroom] watchdog: no response for ${Math.round(elapsed / 1000)}s, forcing reconnect`);
|
|
120
180
|
this.triggerReconnect("dead connection");
|
|
121
181
|
return;
|
|
122
182
|
}
|
|
123
183
|
|
|
124
|
-
|
|
125
|
-
this.send({ type: "agent.heartbeat" });
|
|
184
|
+
this.ws?.readyState === WebSocket.OPEN && this.ws.send(JSON.stringify({ type: "agent.heartbeat" }));
|
|
126
185
|
}
|
|
127
186
|
|
|
128
187
|
private triggerReconnect(reason: string): void {
|
|
129
188
|
if (this.reconnecting || this.stopped) return;
|
|
189
|
+
|
|
190
|
+
// Non-recoverable error
|
|
191
|
+
if (NON_RECOVERABLE_CODES.has(this.lastCloseCode)) {
|
|
192
|
+
const msg = `non-recoverable close code ${this.lastCloseCode}, giving up`;
|
|
193
|
+
this.options.log?.error?.(`[clawroom] ${msg}`);
|
|
194
|
+
this.stopped = true;
|
|
195
|
+
this.stopWatchdog();
|
|
196
|
+
this.stopPolling();
|
|
197
|
+
for (const cb of this.fatalCallbacks) cb(msg, this.lastCloseCode);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
this.consecutiveWsFails++;
|
|
202
|
+
|
|
203
|
+
// Too many WS failures → degrade to HTTP polling
|
|
204
|
+
if (this.consecutiveWsFails >= WS_FAIL_THRESHOLD && this.mode !== "polling") {
|
|
205
|
+
this.options.log?.warn?.(`[clawroom] WS failed ${this.consecutiveWsFails} times, switching to HTTP polling`);
|
|
206
|
+
this.switchToPolling();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
130
210
|
this.reconnecting = true;
|
|
131
211
|
this.destroySocket();
|
|
132
212
|
|
|
133
|
-
const delayMs =
|
|
213
|
+
const delayMs = computeBackoff(this.reconnectAttempt);
|
|
134
214
|
this.reconnectAttempt++;
|
|
135
215
|
this.options.log?.info?.(`[clawroom] reconnecting in ${delayMs}ms (${reason}, attempt ${this.reconnectAttempt})`);
|
|
136
216
|
|
|
@@ -140,7 +220,7 @@ export class ClawroomWsClient {
|
|
|
140
220
|
}, delayMs);
|
|
141
221
|
}
|
|
142
222
|
|
|
143
|
-
// ── Connection
|
|
223
|
+
// ── WS Connection ──────────────────────────────────────────────
|
|
144
224
|
|
|
145
225
|
private doConnect(): void {
|
|
146
226
|
this.destroySocket();
|
|
@@ -156,30 +236,41 @@ export class ClawroomWsClient {
|
|
|
156
236
|
}
|
|
157
237
|
|
|
158
238
|
this.ws.addEventListener("open", () => {
|
|
159
|
-
this.options.log?.info?.("[clawroom] connected");
|
|
239
|
+
this.options.log?.info?.("[clawroom] WS connected");
|
|
160
240
|
this.reconnectAttempt = 0;
|
|
241
|
+
this.consecutiveWsFails = 0;
|
|
242
|
+
this.lastCloseCode = 0;
|
|
161
243
|
this.lastActivity = Date.now();
|
|
162
|
-
|
|
244
|
+
|
|
245
|
+
// If we were polling, switch back to WS
|
|
246
|
+
if (this.mode === "polling") {
|
|
247
|
+
this.switchToWs();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
this.ws?.send(JSON.stringify({
|
|
163
251
|
type: "agent.hello",
|
|
164
252
|
deviceId: this.options.deviceId,
|
|
165
253
|
skills: this.options.skills,
|
|
166
|
-
});
|
|
254
|
+
}));
|
|
167
255
|
});
|
|
168
256
|
|
|
169
257
|
this.ws.addEventListener("message", (event) => {
|
|
170
|
-
this.lastActivity = Date.now();
|
|
258
|
+
this.lastActivity = Date.now();
|
|
171
259
|
this.handleMessage(event.data as string);
|
|
172
260
|
});
|
|
173
261
|
|
|
174
|
-
this.ws.addEventListener("close", () => {
|
|
175
|
-
this.
|
|
262
|
+
this.ws.addEventListener("close", (event) => {
|
|
263
|
+
this.lastCloseCode = event.code;
|
|
264
|
+
const reason = event.reason || `code ${event.code}`;
|
|
265
|
+
this.options.log?.info?.(`[clawroom] WS disconnected (${reason})`);
|
|
176
266
|
this.ws = null;
|
|
177
|
-
|
|
267
|
+
for (const cb of this.disconnectCallbacks) cb();
|
|
268
|
+
if (NON_RECOVERABLE_CODES.has(event.code)) {
|
|
269
|
+
this.triggerReconnect(reason);
|
|
270
|
+
}
|
|
178
271
|
});
|
|
179
272
|
|
|
180
|
-
this.ws.addEventListener("error", () => {
|
|
181
|
-
// watchdog handles it
|
|
182
|
-
});
|
|
273
|
+
this.ws.addEventListener("error", () => {});
|
|
183
274
|
}
|
|
184
275
|
|
|
185
276
|
private destroySocket(): void {
|
|
@@ -189,6 +280,112 @@ export class ClawroomWsClient {
|
|
|
189
280
|
}
|
|
190
281
|
}
|
|
191
282
|
|
|
283
|
+
// ── HTTP Polling (fallback mode) ────────────────────────────────
|
|
284
|
+
|
|
285
|
+
private switchToPolling(): void {
|
|
286
|
+
this.mode = "polling";
|
|
287
|
+
this.destroySocket();
|
|
288
|
+
this.options.log?.info?.("[clawroom] entering HTTP polling mode");
|
|
289
|
+
for (const cb of this.modeChangeCallbacks) cb("polling");
|
|
290
|
+
|
|
291
|
+
// Send initial heartbeat
|
|
292
|
+
this.httpHeartbeat().catch(() => {});
|
|
293
|
+
|
|
294
|
+
this.pollCycleCount = 0;
|
|
295
|
+
this.stopPolling();
|
|
296
|
+
this.pollTimer = setInterval(() => this.pollTick(), POLL_INTERVAL_MS);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private switchToWs(): void {
|
|
300
|
+
this.options.log?.info?.("[clawroom] WS restored, switching back from polling");
|
|
301
|
+
this.stopPolling();
|
|
302
|
+
this.mode = "ws";
|
|
303
|
+
for (const cb of this.modeChangeCallbacks) cb("ws");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private stopPolling(): void {
|
|
307
|
+
if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private async pollTick(): Promise<void> {
|
|
311
|
+
if (this.stopped) return;
|
|
312
|
+
|
|
313
|
+
this.pollCycleCount++;
|
|
314
|
+
|
|
315
|
+
// Heartbeat
|
|
316
|
+
await this.httpHeartbeat().catch(() => {});
|
|
317
|
+
|
|
318
|
+
// Poll for tasks — task-executor handles execution via callbacks
|
|
319
|
+
// (poll endpoint auto-claims, so we just need to trigger task callbacks)
|
|
320
|
+
try {
|
|
321
|
+
const res = await this.httpRequest("POST", "/poll", {});
|
|
322
|
+
if (res.task) {
|
|
323
|
+
const task = res.task as ServerTask;
|
|
324
|
+
this.options.log?.info?.(`[clawroom] [poll] received task ${task.taskId}: ${task.title}`);
|
|
325
|
+
// Fire task + claim_ack callbacks so task-executor picks it up
|
|
326
|
+
for (const cb of this.taskCallbacks) cb(task);
|
|
327
|
+
for (const cb of this.claimAckCallbacks) cb({ type: "server.claim_ack", taskId: task.taskId, ok: true });
|
|
328
|
+
}
|
|
329
|
+
} catch (err) {
|
|
330
|
+
this.options.log?.warn?.(`[clawroom] [poll] error: ${err}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Periodically try to restore WS
|
|
334
|
+
if (this.pollCycleCount % WS_RESTORE_INTERVAL === 0) {
|
|
335
|
+
this.options.log?.info?.("[clawroom] [poll] attempting WS restore...");
|
|
336
|
+
this.doConnect(); // if WS connects, switchToWs() is called from open handler
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private async httpHeartbeat(): Promise<void> {
|
|
341
|
+
await this.httpRequest("POST", "/heartbeat", {}).catch(() => {});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private async sendViaHttp(message: AgentMessage): Promise<void> {
|
|
345
|
+
switch (message.type) {
|
|
346
|
+
case "agent.complete":
|
|
347
|
+
await this.httpRequest("POST", "/complete", {
|
|
348
|
+
taskId: message.taskId,
|
|
349
|
+
output: message.output,
|
|
350
|
+
attachments: message.attachments,
|
|
351
|
+
});
|
|
352
|
+
break;
|
|
353
|
+
case "agent.fail":
|
|
354
|
+
await this.httpRequest("POST", "/fail", {
|
|
355
|
+
taskId: message.taskId,
|
|
356
|
+
reason: message.reason,
|
|
357
|
+
});
|
|
358
|
+
break;
|
|
359
|
+
case "agent.progress":
|
|
360
|
+
await this.httpRequest("POST", "/progress", {
|
|
361
|
+
taskId: message.taskId,
|
|
362
|
+
message: message.message,
|
|
363
|
+
percent: message.percent,
|
|
364
|
+
});
|
|
365
|
+
break;
|
|
366
|
+
case "agent.heartbeat":
|
|
367
|
+
await this.httpHeartbeat();
|
|
368
|
+
break;
|
|
369
|
+
// claim, hello, release — not needed in polling mode
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private async httpRequest(method: string, path: string, body: unknown): Promise<any> {
|
|
374
|
+
const res = await fetch(`${this.httpBase}${path}`, {
|
|
375
|
+
method,
|
|
376
|
+
headers: {
|
|
377
|
+
"Content-Type": "application/json",
|
|
378
|
+
"Authorization": `Bearer ${this.options.token}`,
|
|
379
|
+
},
|
|
380
|
+
body: JSON.stringify(body),
|
|
381
|
+
});
|
|
382
|
+
if (!res.ok) {
|
|
383
|
+
const text = await res.text().catch(() => "");
|
|
384
|
+
throw new Error(`HTTP ${res.status}: ${text}`);
|
|
385
|
+
}
|
|
386
|
+
return res.json();
|
|
387
|
+
}
|
|
388
|
+
|
|
192
389
|
// ── Message handling ──────────────────────────────────────────────
|
|
193
390
|
|
|
194
391
|
private handleMessage(raw: string): void {
|
|
@@ -201,7 +398,6 @@ export class ClawroomWsClient {
|
|
|
201
398
|
for (const cb of this.welcomeCallbacks) cb(msg.agentId);
|
|
202
399
|
break;
|
|
203
400
|
case "server.pong":
|
|
204
|
-
// lastActivity already updated in message handler
|
|
205
401
|
break;
|
|
206
402
|
case "server.task":
|
|
207
403
|
this.options.log?.info?.(`[clawroom] received task ${msg.taskId}: ${msg.title}`);
|