@clawroom/sdk 0.5.1 → 0.5.19
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/package.json +1 -1
- package/src/client.ts +278 -28
- package/src/index.ts +2 -0
- package/src/machine-client.ts +200 -53
- package/src/protocol.ts +2 -0
- package/src/ws-transport.ts +123 -28
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
AgentChatProfile,
|
|
3
|
+
AgentResultFile,
|
|
2
4
|
AgentMessage,
|
|
5
|
+
AgentWorkRef,
|
|
3
6
|
ServerTask,
|
|
4
7
|
ServerChatMessage,
|
|
5
8
|
} from "./protocol.js";
|
|
@@ -8,9 +11,18 @@ import { WsTransport } from "./ws-transport.js";
|
|
|
8
11
|
const DEFAULT_ENDPOINT = "https://clawroom.site9.ai/api/agents";
|
|
9
12
|
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
10
13
|
const POLL_INTERVAL_MS = 10_000;
|
|
14
|
+
const DEFAULT_AGENT_CHAT_PROFILE: AgentChatProfile = {
|
|
15
|
+
role: "No role defined",
|
|
16
|
+
systemPrompt: "No system prompt configured",
|
|
17
|
+
memory: "No memory recorded yet",
|
|
18
|
+
continuityPacket: "No continuity packet available yet",
|
|
19
|
+
};
|
|
11
20
|
|
|
12
21
|
export type TaskCallback = (task: ServerTask) => void;
|
|
13
22
|
export type ChatCallback = (messages: ServerChatMessage[]) => void;
|
|
23
|
+
export type ConnectedCallback = (agentId: string) => void;
|
|
24
|
+
export type DisconnectedCallback = () => void;
|
|
25
|
+
export type Unsubscribe = () => void;
|
|
14
26
|
|
|
15
27
|
export type ClawroomClientOptions = {
|
|
16
28
|
endpoint?: string;
|
|
@@ -26,6 +38,17 @@ export type ClawroomClientOptions = {
|
|
|
26
38
|
};
|
|
27
39
|
};
|
|
28
40
|
|
|
41
|
+
type AgentHeartbeatResponse = {
|
|
42
|
+
ok?: boolean;
|
|
43
|
+
agentId?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type AgentPollResponse = {
|
|
47
|
+
agentId?: string;
|
|
48
|
+
task: ServerTask | null;
|
|
49
|
+
chat: ServerChatMessage[] | null;
|
|
50
|
+
};
|
|
51
|
+
|
|
29
52
|
/**
|
|
30
53
|
* ClawRoom SDK client.
|
|
31
54
|
* WebSocket primary for real-time push, HTTP polling as fallback.
|
|
@@ -33,12 +56,19 @@ export type ClawroomClientOptions = {
|
|
|
33
56
|
export class ClawroomClient {
|
|
34
57
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
35
58
|
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
59
|
+
private heartbeatInFlight = false;
|
|
60
|
+
private pollInFlight = false;
|
|
36
61
|
protected stopped = false;
|
|
37
62
|
protected readonly httpBase: string;
|
|
38
63
|
protected readonly options: ClawroomClientOptions;
|
|
39
64
|
protected taskCallbacks: TaskCallback[] = [];
|
|
40
65
|
protected chatCallbacks: ChatCallback[] = [];
|
|
66
|
+
private connectedCallbacks: ConnectedCallback[] = [];
|
|
67
|
+
private disconnectedCallbacks: DisconnectedCallback[] = [];
|
|
41
68
|
private wsTransport: WsTransport | null = null;
|
|
69
|
+
private recentChatIds = new Set<string>();
|
|
70
|
+
private connectedAgentId: string | null = null;
|
|
71
|
+
private isConnectedValue = false;
|
|
42
72
|
|
|
43
73
|
constructor(options: ClawroomClientOptions) {
|
|
44
74
|
this.options = options;
|
|
@@ -50,12 +80,26 @@ export class ClawroomClient {
|
|
|
50
80
|
return this.httpBase.replace(/\/api\/agents$/, "").replace(/^http/, "ws") + "/api/ws";
|
|
51
81
|
}
|
|
52
82
|
|
|
83
|
+
get connected(): boolean {
|
|
84
|
+
return this.isConnectedValue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get agentId(): string | null {
|
|
88
|
+
return this.connectedAgentId;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
get isAlive(): boolean {
|
|
92
|
+
return !this.stopped;
|
|
93
|
+
}
|
|
94
|
+
|
|
53
95
|
connect(): void {
|
|
54
96
|
this.stopped = false;
|
|
55
97
|
this.startHeartbeat();
|
|
56
98
|
void this.register();
|
|
57
99
|
|
|
58
100
|
// WebSocket transport
|
|
101
|
+
this.wsTransport?.disconnect();
|
|
102
|
+
this.wsTransport = null;
|
|
59
103
|
this.wsTransport = new WsTransport({
|
|
60
104
|
url: this.wsUrl,
|
|
61
105
|
token: this.options.token,
|
|
@@ -64,21 +108,48 @@ export class ClawroomClient {
|
|
|
64
108
|
onDisconnected: () => { this.options.log?.info?.("[clawroom] WebSocket disconnected"); },
|
|
65
109
|
onMessage: (msg) => {
|
|
66
110
|
if (msg.type === "task" && msg.task) {
|
|
67
|
-
|
|
111
|
+
const task = msg.task as ServerTask;
|
|
112
|
+
void this.dispatchTask(task);
|
|
68
113
|
}
|
|
69
114
|
if (msg.type === "chat" && Array.isArray(msg.messages)) {
|
|
70
|
-
|
|
115
|
+
const fresh = msg.messages.filter((m: ServerChatMessage) => this.rememberChat(m.workId ?? m.messageId));
|
|
116
|
+
if (fresh.length > 0) {
|
|
117
|
+
for (const cb of this.chatCallbacks) cb(fresh);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const message = getRecord(msg.message);
|
|
121
|
+
if (msg.type === "message" && message) {
|
|
122
|
+
const agentProfile = resolveAgentChatProfile(msg.agentProfile, message.agentProfile);
|
|
123
|
+
const delivered = [{
|
|
124
|
+
workId: getString(message.workId),
|
|
125
|
+
leaseToken: getString(message.leaseToken),
|
|
126
|
+
messageId: getString(message.id, getString(msg.messageId)),
|
|
127
|
+
channelId: getString(message.channelId),
|
|
128
|
+
content: getString(message.content),
|
|
129
|
+
attachments: getAttachments(msg.attachments) ?? getAttachments(message.attachments),
|
|
130
|
+
context: getContext(msg.context),
|
|
131
|
+
isMention: typeof msg.isMention === "boolean" ? msg.isMention : false,
|
|
132
|
+
wakeReason: getWakeReason(msg.wakeReason) ?? getWakeReason(message.wakeReason),
|
|
133
|
+
triggerReason: getOptionalString(msg.triggerReason) ?? getOptionalString(message.triggerReason),
|
|
134
|
+
agentProfile,
|
|
135
|
+
}] as ServerChatMessage[];
|
|
136
|
+
const fresh = delivered.filter((m) => this.rememberChat(m.workId ?? m.messageId));
|
|
137
|
+
if (fresh.length > 0) {
|
|
138
|
+
for (const cb of this.chatCallbacks) cb(fresh);
|
|
139
|
+
}
|
|
71
140
|
}
|
|
72
141
|
},
|
|
73
142
|
});
|
|
74
143
|
this.wsTransport.connect();
|
|
75
144
|
|
|
76
|
-
// HTTP
|
|
145
|
+
// Keep HTTP polling active even when WS is up.
|
|
146
|
+
// WS is the fast path; polling is the durable delivery path.
|
|
77
147
|
this.stopPolling();
|
|
78
148
|
this.pollTimer = setInterval(() => {
|
|
79
|
-
if (this.wsTransport?.connected) return;
|
|
80
149
|
void this.pollTick();
|
|
81
150
|
}, POLL_INTERVAL_MS);
|
|
151
|
+
|
|
152
|
+
void this.pollTick();
|
|
82
153
|
}
|
|
83
154
|
|
|
84
155
|
disconnect(): void {
|
|
@@ -87,14 +158,84 @@ export class ClawroomClient {
|
|
|
87
158
|
this.stopPolling();
|
|
88
159
|
this.wsTransport?.disconnect();
|
|
89
160
|
this.wsTransport = null;
|
|
161
|
+
this.recentChatIds.clear();
|
|
162
|
+
this.markDisconnected();
|
|
163
|
+
this.connectedAgentId = null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async send(message: AgentMessage): Promise<void> {
|
|
167
|
+
await this.sendViaHttp(message);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
onTask(cb: TaskCallback): Unsubscribe {
|
|
171
|
+
this.taskCallbacks.push(cb);
|
|
172
|
+
return () => this.offTask(cb);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
offTask(cb: TaskCallback): void {
|
|
176
|
+
removeCallback(this.taskCallbacks, cb);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
onChatMessage(cb: ChatCallback): Unsubscribe {
|
|
180
|
+
this.chatCallbacks.push(cb);
|
|
181
|
+
return () => this.offChatMessage(cb);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
offChatMessage(cb: ChatCallback): void {
|
|
185
|
+
removeCallback(this.chatCallbacks, cb);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
onConnected(cb: ConnectedCallback): Unsubscribe {
|
|
189
|
+
this.connectedCallbacks.push(cb);
|
|
190
|
+
return () => this.offConnected(cb);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
offConnected(cb: ConnectedCallback): void {
|
|
194
|
+
removeCallback(this.connectedCallbacks, cb);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
onDisconnected(cb: DisconnectedCallback): Unsubscribe {
|
|
198
|
+
this.disconnectedCallbacks.push(cb);
|
|
199
|
+
return () => this.offDisconnected(cb);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
offDisconnected(cb: DisconnectedCallback): void {
|
|
203
|
+
removeCallback(this.disconnectedCallbacks, cb);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async sendComplete(taskId: string, output: string, attachments?: AgentResultFile[], workRef?: AgentWorkRef): Promise<void> {
|
|
207
|
+
await this.send({ type: "agent.complete", taskId, output, attachments, workRef });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async sendFail(taskId: string, reason: string, workRef?: AgentWorkRef): Promise<void> {
|
|
211
|
+
await this.send({ type: "agent.fail", taskId, reason, workRef });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async sendProgress(taskId: string, message: string, workRef?: AgentWorkRef): Promise<void> {
|
|
215
|
+
await this.send({ type: "agent.progress", taskId, message, workRef });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async sendChatReply(channelId: string, content: string, replyTo?: string, workRefs?: AgentWorkRef[]): Promise<void> {
|
|
219
|
+
await this.send({ type: "agent.chat.reply", channelId, content, replyTo, workRefs });
|
|
90
220
|
}
|
|
91
221
|
|
|
92
|
-
|
|
93
|
-
this.
|
|
222
|
+
async sendTyping(channelId: string): Promise<void> {
|
|
223
|
+
await this.send({ type: "agent.typing", channelId });
|
|
94
224
|
}
|
|
95
225
|
|
|
96
|
-
|
|
97
|
-
|
|
226
|
+
async sendReflection(reflection: {
|
|
227
|
+
scope: "chat" | "task";
|
|
228
|
+
status: string;
|
|
229
|
+
summary: string;
|
|
230
|
+
channelId?: string | null;
|
|
231
|
+
taskId?: string | null;
|
|
232
|
+
messageId?: string | null;
|
|
233
|
+
toolsUsed?: string[];
|
|
234
|
+
responseExcerpt?: string | null;
|
|
235
|
+
detail?: Record<string, unknown>;
|
|
236
|
+
}): Promise<void> {
|
|
237
|
+
await this.httpRequest("POST", "/reflections", reflection);
|
|
238
|
+
}
|
|
98
239
|
|
|
99
240
|
// ── Heartbeat ─────────────────────────────────────────
|
|
100
241
|
|
|
@@ -114,67 +255,176 @@ export class ClawroomClient {
|
|
|
114
255
|
}
|
|
115
256
|
|
|
116
257
|
protected async register(): Promise<void> {
|
|
258
|
+
if (this.heartbeatInFlight) return;
|
|
259
|
+
this.heartbeatInFlight = true;
|
|
117
260
|
try {
|
|
118
|
-
await this.httpRequest("POST", "/heartbeat", {
|
|
261
|
+
const response = await this.httpRequest("POST", "/heartbeat", {
|
|
119
262
|
deviceId: this.options.deviceId,
|
|
120
263
|
skills: this.options.skills,
|
|
121
264
|
kind: this.options.kind ?? "openclaw",
|
|
122
|
-
});
|
|
123
|
-
this.onPollSuccess(
|
|
265
|
+
}) as AgentHeartbeatResponse;
|
|
266
|
+
this.onPollSuccess(response.agentId);
|
|
124
267
|
} catch (err) {
|
|
125
268
|
this.options.log?.warn?.(`[clawroom] heartbeat error: ${err}`);
|
|
126
|
-
this.
|
|
269
|
+
if (!this.wsTransport?.connected) {
|
|
270
|
+
this.onPollError(err);
|
|
271
|
+
}
|
|
272
|
+
} finally {
|
|
273
|
+
this.heartbeatInFlight = false;
|
|
127
274
|
}
|
|
128
275
|
}
|
|
129
276
|
|
|
130
277
|
protected async pollTick(): Promise<void> {
|
|
131
|
-
if (this.stopped) return;
|
|
278
|
+
if (this.stopped || this.pollInFlight) return;
|
|
279
|
+
this.pollInFlight = true;
|
|
132
280
|
try {
|
|
133
|
-
const res = await this.httpRequest("POST", "/poll", {});
|
|
281
|
+
const res = await this.httpRequest("POST", "/poll", {}) as AgentPollResponse;
|
|
134
282
|
this.onPollSuccess(res?.agentId);
|
|
135
283
|
if (res.task) {
|
|
136
|
-
this.
|
|
137
|
-
for (const cb of this.taskCallbacks) cb(res.task);
|
|
284
|
+
await this.dispatchTask(res.task);
|
|
138
285
|
}
|
|
139
286
|
if (res.chat && Array.isArray(res.chat) && res.chat.length > 0) {
|
|
140
|
-
|
|
141
|
-
|
|
287
|
+
const fresh = res.chat.filter((m: ServerChatMessage) => this.rememberChat(m.workId ?? m.messageId));
|
|
288
|
+
if (fresh.length > 0) {
|
|
289
|
+
this.options.log?.info?.(`[clawroom] received ${fresh.length} chat mention(s)`);
|
|
290
|
+
for (const cb of this.chatCallbacks) cb(fresh);
|
|
291
|
+
}
|
|
142
292
|
}
|
|
143
293
|
} catch (err) {
|
|
144
294
|
this.options.log?.warn?.(`[clawroom] poll error: ${err}`);
|
|
145
295
|
this.onPollError(err);
|
|
296
|
+
} finally {
|
|
297
|
+
this.pollInFlight = false;
|
|
146
298
|
}
|
|
147
299
|
}
|
|
148
300
|
|
|
149
|
-
protected onPollSuccess(
|
|
150
|
-
|
|
301
|
+
protected onPollSuccess(agentId: string | undefined): void {
|
|
302
|
+
this.markConnected(agentId);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
protected onPollError(err: unknown): void {
|
|
306
|
+
void err;
|
|
307
|
+
if (this.wsTransport?.connected) return;
|
|
308
|
+
this.markDisconnected();
|
|
309
|
+
}
|
|
151
310
|
|
|
152
311
|
// ── HTTP ──────────────────────────────────────────────
|
|
153
312
|
|
|
154
313
|
private async sendViaHttp(message: AgentMessage): Promise<void> {
|
|
155
314
|
switch (message.type) {
|
|
156
|
-
case "agent.complete": await this.httpRequest("POST", "/complete", { taskId: message.taskId, output: message.output, attachments: message.attachments }); break;
|
|
157
|
-
case "agent.fail": await this.httpRequest("POST", "/fail", { taskId: message.taskId, reason: message.reason }); break;
|
|
158
|
-
case "agent.progress": await this.httpRequest("POST", "/progress", { taskId: message.taskId, message: message.message,
|
|
315
|
+
case "agent.complete": await this.httpRequest("POST", "/complete", { taskId: message.taskId, output: message.output, attachments: message.attachments, workRef: message.workRef }); break;
|
|
316
|
+
case "agent.fail": await this.httpRequest("POST", "/fail", { taskId: message.taskId, reason: message.reason, workRef: message.workRef }); break;
|
|
317
|
+
case "agent.progress": await this.httpRequest("POST", "/progress", { taskId: message.taskId, message: message.message, workRef: message.workRef }); break;
|
|
159
318
|
case "agent.heartbeat": await this.httpRequest("POST", "/heartbeat", {}); break;
|
|
160
|
-
case "agent.chat.reply": await this.httpRequest("POST", "/chat/reply", {
|
|
319
|
+
case "agent.chat.reply": await this.httpRequest("POST", "/chat/reply", {
|
|
320
|
+
channelId: message.channelId,
|
|
321
|
+
content: message.content,
|
|
322
|
+
replyTo: message.replyTo,
|
|
323
|
+
workRefs: message.workRefs,
|
|
324
|
+
}); break;
|
|
161
325
|
case "agent.typing": await this.httpRequest("POST", "/typing", { channelId: message.channelId }); break;
|
|
162
326
|
}
|
|
163
327
|
}
|
|
164
328
|
|
|
165
|
-
protected async httpRequest(method: string, path: string, body: unknown): Promise<
|
|
329
|
+
protected async httpRequest(method: string, path: string, body: unknown): Promise<unknown> {
|
|
166
330
|
const res = await fetch(`${this.httpBase}${path}`, {
|
|
167
331
|
method,
|
|
168
332
|
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${this.options.token}` },
|
|
169
333
|
body: JSON.stringify(body),
|
|
170
334
|
});
|
|
335
|
+
const text = await res.text().catch(() => "");
|
|
171
336
|
if (!res.ok) {
|
|
172
|
-
const text = await res.text().catch(() => "");
|
|
173
337
|
this.onHttpError(res.status, text);
|
|
174
338
|
throw new Error(`HTTP ${res.status}: ${text}`);
|
|
175
339
|
}
|
|
176
|
-
|
|
340
|
+
if (!text) return null;
|
|
341
|
+
try {
|
|
342
|
+
return JSON.parse(text) as unknown;
|
|
343
|
+
} catch {
|
|
344
|
+
throw new Error(`Invalid JSON response from ${path}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
protected onHttpError(status: number, text: string): void {
|
|
349
|
+
void status;
|
|
350
|
+
void text;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private rememberChat(messageId?: string): boolean {
|
|
354
|
+
if (!messageId) return false;
|
|
355
|
+
if (this.recentChatIds.has(messageId)) return false;
|
|
356
|
+
this.recentChatIds.add(messageId);
|
|
357
|
+
if (this.recentChatIds.size > 1000) {
|
|
358
|
+
const oldest = this.recentChatIds.values().next().value;
|
|
359
|
+
if (oldest) this.recentChatIds.delete(oldest);
|
|
360
|
+
}
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private async dispatchTask(task: ServerTask): Promise<void> {
|
|
365
|
+
this.options.log?.info?.(`[clawroom] received task ${task.taskId}: ${task.title}`);
|
|
366
|
+
for (const cb of this.taskCallbacks) cb(task);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private markConnected(agentId?: string): void {
|
|
370
|
+
if (agentId) this.connectedAgentId = agentId;
|
|
371
|
+
if (!this.connectedAgentId || this.isConnectedValue) return;
|
|
372
|
+
this.isConnectedValue = true;
|
|
373
|
+
for (const callback of this.connectedCallbacks) callback(this.connectedAgentId);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private markDisconnected(): void {
|
|
377
|
+
if (!this.isConnectedValue) return;
|
|
378
|
+
this.isConnectedValue = false;
|
|
379
|
+
for (const callback of this.disconnectedCallbacks) callback();
|
|
177
380
|
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function removeCallback<T>(callbacks: T[], callback: T): void {
|
|
384
|
+
const index = callbacks.indexOf(callback);
|
|
385
|
+
if (index !== -1) callbacks.splice(index, 1);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function getRecord(value: unknown): Record<string, unknown> | null {
|
|
389
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
390
|
+
return value as Record<string, unknown>;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function getString(value: unknown, fallback = ""): string {
|
|
394
|
+
return typeof value === "string" ? value : fallback;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function getOptionalString(value: unknown): string | undefined {
|
|
398
|
+
return typeof value === "string" ? value : undefined;
|
|
399
|
+
}
|
|
178
400
|
|
|
179
|
-
|
|
401
|
+
function getAttachments(value: unknown): ServerChatMessage["attachments"] | undefined {
|
|
402
|
+
return Array.isArray(value) ? value as ServerChatMessage["attachments"] : undefined;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function getContext(value: unknown): ServerChatMessage["context"] {
|
|
406
|
+
return Array.isArray(value) ? value as ServerChatMessage["context"] : [];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function getWakeReason(value: unknown): ServerChatMessage["wakeReason"] | undefined {
|
|
410
|
+
return typeof value === "string" ? value as ServerChatMessage["wakeReason"] : undefined;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function getAgentChatProfile(value: unknown): AgentChatProfile | null {
|
|
414
|
+
const record = getRecord(value);
|
|
415
|
+
if (!record) return null;
|
|
416
|
+
return {
|
|
417
|
+
role: getString(record.role, DEFAULT_AGENT_CHAT_PROFILE.role),
|
|
418
|
+
systemPrompt: getString(record.systemPrompt, DEFAULT_AGENT_CHAT_PROFILE.systemPrompt),
|
|
419
|
+
memory: getString(record.memory, DEFAULT_AGENT_CHAT_PROFILE.memory),
|
|
420
|
+
continuityPacket: getString(record.continuityPacket, DEFAULT_AGENT_CHAT_PROFILE.continuityPacket),
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function resolveAgentChatProfile(...values: unknown[]): AgentChatProfile {
|
|
425
|
+
for (const value of values) {
|
|
426
|
+
const profile = getAgentChatProfile(value);
|
|
427
|
+
if (profile) return profile;
|
|
428
|
+
}
|
|
429
|
+
return DEFAULT_AGENT_CHAT_PROFILE;
|
|
180
430
|
}
|
package/src/index.ts
CHANGED
package/src/machine-client.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import * as os from "node:os";
|
|
7
|
+
import type { AgentChatProfile, AgentWorkRef, ServerChatMessage, ServerTask } from "./protocol.js";
|
|
7
8
|
import { WsTransport } from "./ws-transport.js";
|
|
8
9
|
|
|
9
10
|
export type ClawroomMachineClientOptions = {
|
|
@@ -19,12 +20,32 @@ export type ClawroomMachineClientOptions = {
|
|
|
19
20
|
};
|
|
20
21
|
};
|
|
21
22
|
|
|
23
|
+
export type AgentReflectionPayload = {
|
|
24
|
+
scope: "chat" | "task";
|
|
25
|
+
status: string;
|
|
26
|
+
summary: string;
|
|
27
|
+
channelId?: string | null;
|
|
28
|
+
taskId?: string | null;
|
|
29
|
+
messageId?: string | null;
|
|
30
|
+
toolsUsed?: string[];
|
|
31
|
+
responseExcerpt?: string | null;
|
|
32
|
+
detail?: Record<string, unknown>;
|
|
33
|
+
};
|
|
34
|
+
|
|
22
35
|
type AgentWork = {
|
|
23
36
|
agentId: string;
|
|
24
37
|
agentName: string;
|
|
25
|
-
task:
|
|
26
|
-
chat:
|
|
38
|
+
task: ServerTask | null;
|
|
39
|
+
chat: ServerChatMessage[] | null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const DEFAULT_AGENT_CHAT_PROFILE: AgentChatProfile = {
|
|
43
|
+
role: "No role defined",
|
|
44
|
+
systemPrompt: "No system prompt configured",
|
|
45
|
+
memory: "No memory recorded yet",
|
|
46
|
+
continuityPacket: "No continuity packet available yet",
|
|
27
47
|
};
|
|
48
|
+
const MAX_RECENT_CHAT_IDS = 1000;
|
|
28
49
|
|
|
29
50
|
type MachineHeartbeatResponse = {
|
|
30
51
|
machineId: string;
|
|
@@ -40,12 +61,15 @@ export class ClawroomMachineClient {
|
|
|
40
61
|
private options: ClawroomMachineClientOptions;
|
|
41
62
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
42
63
|
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
64
|
+
private heartbeatInFlight = false;
|
|
65
|
+
private pollInFlight = false;
|
|
43
66
|
private taskHandler: ((agentId: string, task: AgentWork["task"]) => void) | null = null;
|
|
44
67
|
private chatHandler: ((agentId: string, messages: NonNullable<AgentWork["chat"]>) => void) | null = null;
|
|
68
|
+
private connectHandler: ((machineId: string) => void) | null = null;
|
|
69
|
+
private disconnectHandler: (() => void) | null = null;
|
|
45
70
|
private _connected = false;
|
|
46
71
|
private _stopped = false;
|
|
47
72
|
private wsTransport: WsTransport | null = null;
|
|
48
|
-
private subscribedAgentIds = new Set<string>();
|
|
49
73
|
private recentChatIds = new Map<string, number>();
|
|
50
74
|
|
|
51
75
|
constructor(options: ClawroomMachineClientOptions) {
|
|
@@ -69,91 +93,147 @@ export class ClawroomMachineClient {
|
|
|
69
93
|
body: body ? JSON.stringify(body) : undefined,
|
|
70
94
|
});
|
|
71
95
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
|
72
|
-
|
|
96
|
+
const text = await res.text();
|
|
97
|
+
if (!text.trim()) return null;
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(text) as unknown;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`Invalid JSON from ${path}: ${error instanceof Error ? error.message : "unknown parse error"}`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
73
105
|
}
|
|
74
106
|
|
|
75
107
|
onAgentTask(handler: (agentId: string, task: AgentWork["task"]) => void) { this.taskHandler = handler; }
|
|
76
108
|
onAgentChat(handler: (agentId: string, messages: NonNullable<AgentWork["chat"]>) => void) { this.chatHandler = handler; }
|
|
109
|
+
onConnected(handler: (machineId: string) => void) { this.connectHandler = handler; }
|
|
110
|
+
onDisconnected(handler: () => void) { this.disconnectHandler = handler; }
|
|
77
111
|
|
|
78
|
-
async sendAgentComplete(agentId: string, taskId: string, output: string, attachments?: Array<{ filename: string; mimeType: string; data: string }
|
|
79
|
-
await this.httpRequest("POST", "/complete", { agentId, taskId, output, attachments });
|
|
112
|
+
async sendAgentComplete(agentId: string, taskId: string, output: string, attachments?: Array<{ filename: string; mimeType: string; data: string }>, workRef?: AgentWorkRef) {
|
|
113
|
+
await this.httpRequest("POST", "/complete", { agentId, taskId, output, attachments, workRef });
|
|
80
114
|
}
|
|
81
115
|
|
|
82
|
-
async sendAgentFail(agentId: string, taskId: string, reason: string) {
|
|
83
|
-
await this.httpRequest("POST", "/fail", { agentId, taskId, reason });
|
|
116
|
+
async sendAgentFail(agentId: string, taskId: string, reason: string, workRef?: AgentWorkRef) {
|
|
117
|
+
await this.httpRequest("POST", "/fail", { agentId, taskId, reason, workRef });
|
|
84
118
|
}
|
|
85
119
|
|
|
86
|
-
async sendAgentChatReply(agentId: string, channelId: string, content: string) {
|
|
87
|
-
await this.httpRequest("POST", "/chat-reply", { agentId, channelId, content });
|
|
120
|
+
async sendAgentChatReply(agentId: string, channelId: string, content: string, replyTo?: string, workRefs?: AgentWorkRef[]) {
|
|
121
|
+
await this.httpRequest("POST", "/chat-reply", { agentId, channelId, content, replyTo, workRefs });
|
|
88
122
|
}
|
|
89
123
|
|
|
90
124
|
async sendAgentTyping(agentId: string, channelId: string) {
|
|
91
125
|
await this.httpRequest("POST", "/typing", { agentId, channelId });
|
|
92
126
|
}
|
|
93
127
|
|
|
128
|
+
async sendAgentReflection(agentId: string, reflection: AgentReflectionPayload) {
|
|
129
|
+
await this.httpRequest("POST", "/reflections", { agentId, ...reflection });
|
|
130
|
+
}
|
|
131
|
+
|
|
94
132
|
get connected() { return this._connected; }
|
|
95
133
|
get stopped() { return this._stopped; }
|
|
96
134
|
|
|
97
|
-
private rememberChat(messageId
|
|
98
|
-
if (!messageId) return
|
|
135
|
+
private rememberChat(messageId?: string | null): boolean {
|
|
136
|
+
if (!messageId) return false;
|
|
99
137
|
const now = Date.now();
|
|
100
138
|
for (const [id, seenAt] of this.recentChatIds) {
|
|
101
139
|
if (now - seenAt > 10 * 60_000) this.recentChatIds.delete(id);
|
|
102
140
|
}
|
|
103
141
|
if (this.recentChatIds.has(messageId)) return false;
|
|
142
|
+
if (this.recentChatIds.size >= MAX_RECENT_CHAT_IDS) {
|
|
143
|
+
const oldestId = this.recentChatIds.keys().next().value;
|
|
144
|
+
if (oldestId) this.recentChatIds.delete(oldestId);
|
|
145
|
+
}
|
|
104
146
|
this.recentChatIds.set(messageId, now);
|
|
105
147
|
return true;
|
|
106
148
|
}
|
|
107
149
|
|
|
108
|
-
private
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
this.
|
|
112
|
-
this.
|
|
150
|
+
private markConnected(machineId: string) {
|
|
151
|
+
if (!this._connected) {
|
|
152
|
+
this._connected = true;
|
|
153
|
+
this.options.log?.info?.("[machine] connected");
|
|
154
|
+
this.connectHandler?.(machineId);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private markDisconnected() {
|
|
159
|
+
if (this._connected) {
|
|
160
|
+
this._connected = false;
|
|
161
|
+
this.options.log?.warn?.("[machine] disconnected");
|
|
162
|
+
this.disconnectHandler?.();
|
|
113
163
|
}
|
|
114
164
|
}
|
|
115
165
|
|
|
116
166
|
private async pollOnce() {
|
|
117
|
-
if (!this._connected) return;
|
|
167
|
+
if (!this._connected || this.pollInFlight) return;
|
|
168
|
+
this.pollInFlight = true;
|
|
118
169
|
try {
|
|
119
170
|
const result = (await this.httpRequest("POST", "/poll", {})) as MachinePollResponse;
|
|
120
|
-
this.syncAgentSubscriptions(result.agents.map((agent) => agent.agentId));
|
|
121
171
|
for (const agent of result.agents) {
|
|
122
|
-
if (agent.task && this.taskHandler)
|
|
172
|
+
if (agent.task && this.taskHandler) {
|
|
173
|
+
try {
|
|
174
|
+
this.taskHandler(agent.agentId, agent.task);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
this.options.log?.warn?.(
|
|
177
|
+
`[machine] task dispatch failed for agent ${agent.agentId} task ${agent.task.taskId}: ${err}`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
123
181
|
if (agent.chat && agent.chat.length > 0 && this.chatHandler) {
|
|
124
|
-
|
|
125
|
-
|
|
182
|
+
try {
|
|
183
|
+
const freshMessages = agent.chat.filter((message) => this.rememberChat(message.workId ?? message.messageId));
|
|
184
|
+
if (freshMessages.length > 0) {
|
|
185
|
+
this.chatHandler(agent.agentId, freshMessages);
|
|
186
|
+
}
|
|
187
|
+
} catch (err) {
|
|
188
|
+
this.options.log?.warn?.(`[machine] chat dispatch failed for agent ${agent.agentId}: ${err}`);
|
|
189
|
+
}
|
|
126
190
|
}
|
|
127
191
|
}
|
|
128
192
|
} catch (err) {
|
|
129
193
|
this.options.log?.warn?.(`[machine] poll error: ${err}`);
|
|
194
|
+
} finally {
|
|
195
|
+
this.pollInFlight = false;
|
|
130
196
|
}
|
|
131
197
|
}
|
|
132
198
|
|
|
133
199
|
connect() {
|
|
200
|
+
this.stopHeartbeat();
|
|
201
|
+
this.stopPolling();
|
|
202
|
+
this.wsTransport?.disconnect();
|
|
203
|
+
this.wsTransport = null;
|
|
134
204
|
this._stopped = false;
|
|
135
205
|
const hostname = this.options.hostname ?? os.hostname();
|
|
136
|
-
const hbBody = {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
206
|
+
const hbBody = {
|
|
207
|
+
hostname,
|
|
208
|
+
capabilities: this.options.capabilities,
|
|
209
|
+
platform: os.platform(),
|
|
210
|
+
architecture: os.arch(),
|
|
211
|
+
osRelease: os.release(),
|
|
212
|
+
};
|
|
213
|
+
const sendHeartbeat = async () => {
|
|
214
|
+
if (this.heartbeatInFlight) return;
|
|
215
|
+
this.heartbeatInFlight = true;
|
|
140
216
|
try {
|
|
141
217
|
const result = (await this.httpRequest("POST", "/heartbeat", hbBody)) as MachineHeartbeatResponse;
|
|
142
|
-
this.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
218
|
+
this.markConnected(result.machineId);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
if (!this.wsTransport?.connected) {
|
|
221
|
+
this.markDisconnected();
|
|
222
|
+
} else {
|
|
223
|
+
this.options.log?.warn?.(`[machine] heartbeat failed while WS is still connected: ${err}`);
|
|
224
|
+
}
|
|
225
|
+
throw err;
|
|
226
|
+
} finally {
|
|
227
|
+
this.heartbeatInFlight = false;
|
|
146
228
|
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Heartbeat every 30s (always HTTP)
|
|
232
|
+
this.heartbeatTimer = setInterval(() => {
|
|
233
|
+
void sendHeartbeat().catch(() => {});
|
|
147
234
|
}, 30_000);
|
|
148
235
|
|
|
149
|
-
this.
|
|
150
|
-
.then((result) => {
|
|
151
|
-
const data = result as MachineHeartbeatResponse;
|
|
152
|
-
this._connected = true;
|
|
153
|
-
this.syncAgentSubscriptions((data.agents ?? []).map((agent) => agent.id));
|
|
154
|
-
this.options.log?.info?.("[machine] connected");
|
|
155
|
-
})
|
|
156
|
-
.catch((err) => this.options.log?.warn?.(`[machine] initial heartbeat failed: ${err}`));
|
|
236
|
+
sendHeartbeat().catch((err) => this.options.log?.warn?.(`[machine] initial heartbeat failed: ${err}`));
|
|
157
237
|
|
|
158
238
|
// WebSocket transport
|
|
159
239
|
this.wsTransport = new WsTransport({
|
|
@@ -161,29 +241,37 @@ export class ClawroomMachineClient {
|
|
|
161
241
|
token: this.options.apiKey,
|
|
162
242
|
log: { info: (m) => this.options.log?.info?.(m), warn: (m) => this.options.log?.warn?.(m) },
|
|
163
243
|
onConnected: () => {
|
|
164
|
-
this.syncAgentSubscriptions([...this.subscribedAgentIds]);
|
|
165
244
|
this.options.log?.info?.("[machine] WebSocket connected");
|
|
166
245
|
},
|
|
167
246
|
onDisconnected: () => {
|
|
168
247
|
this.options.log?.info?.("[machine] WebSocket disconnected, HTTP polling active");
|
|
169
248
|
},
|
|
170
249
|
onMessage: (msg) => {
|
|
250
|
+
const message = getRecord(msg.message);
|
|
171
251
|
if (msg.type === "message") {
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
252
|
+
const targetAgentId = getOptionalString(msg.agentId) ?? getOptionalString(message?.agentId);
|
|
253
|
+
const messageId = getString(message?.id, getString(msg.messageId));
|
|
254
|
+
if (message && targetAgentId && this.chatHandler && this.rememberChat(messageId)) {
|
|
255
|
+
const agentProfile = resolveAgentChatProfile(msg.agentProfile, message?.agentProfile);
|
|
256
|
+
const messages = [{
|
|
257
|
+
workId: getString(message?.workId),
|
|
258
|
+
leaseToken: getString(message?.leaseToken),
|
|
177
259
|
messageId,
|
|
178
|
-
channelId:
|
|
179
|
-
content:
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
260
|
+
channelId: getString(message?.channelId),
|
|
261
|
+
content: getString(message?.content),
|
|
262
|
+
attachments: getAttachments(msg.attachments) ?? getAttachments(message?.attachments),
|
|
263
|
+
isMention: typeof msg.isMention === "boolean" ? msg.isMention : false,
|
|
264
|
+
wakeReason: getWakeReason(msg.wakeReason) ?? getWakeReason(message?.wakeReason),
|
|
265
|
+
triggerReason: getOptionalString(msg.triggerReason) ?? getOptionalString(message?.triggerReason),
|
|
266
|
+
context: getContext(msg.context),
|
|
267
|
+
agentProfile,
|
|
268
|
+
}];
|
|
269
|
+
this.chatHandler(targetAgentId, messages);
|
|
183
270
|
}
|
|
184
271
|
}
|
|
185
|
-
|
|
186
|
-
|
|
272
|
+
const taskAgentId = getOptionalString(msg.agentId);
|
|
273
|
+
if (msg.type === "task" && taskAgentId && this.taskHandler) {
|
|
274
|
+
this.taskHandler(taskAgentId, (msg.task as AgentWork["task"] | undefined) ?? null);
|
|
187
275
|
}
|
|
188
276
|
},
|
|
189
277
|
});
|
|
@@ -201,9 +289,68 @@ export class ClawroomMachineClient {
|
|
|
201
289
|
disconnect() {
|
|
202
290
|
this._stopped = true;
|
|
203
291
|
this._connected = false;
|
|
204
|
-
|
|
205
|
-
|
|
292
|
+
this.stopHeartbeat();
|
|
293
|
+
this.stopPolling();
|
|
206
294
|
this.wsTransport?.disconnect();
|
|
207
295
|
this.wsTransport = null;
|
|
296
|
+
this.recentChatIds.clear();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private stopHeartbeat() {
|
|
300
|
+
if (this.heartbeatTimer) {
|
|
301
|
+
clearInterval(this.heartbeatTimer);
|
|
302
|
+
this.heartbeatTimer = null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private stopPolling() {
|
|
307
|
+
if (this.pollTimer) {
|
|
308
|
+
clearInterval(this.pollTimer);
|
|
309
|
+
this.pollTimer = null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function getRecord(value: unknown): Record<string, unknown> | null {
|
|
315
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
316
|
+
return value as Record<string, unknown>;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function getString(value: unknown, fallback = ""): string {
|
|
320
|
+
return typeof value === "string" ? value : fallback;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function getOptionalString(value: unknown): string | undefined {
|
|
324
|
+
return typeof value === "string" ? value : undefined;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function getAttachments(value: unknown): ServerChatMessage["attachments"] | undefined {
|
|
328
|
+
return Array.isArray(value) ? value as ServerChatMessage["attachments"] : undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function getContext(value: unknown): ServerChatMessage["context"] {
|
|
332
|
+
return Array.isArray(value) ? value as ServerChatMessage["context"] : [];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function getWakeReason(value: unknown): ServerChatMessage["wakeReason"] | undefined {
|
|
336
|
+
return typeof value === "string" ? value as ServerChatMessage["wakeReason"] : undefined;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function getAgentChatProfile(value: unknown): AgentChatProfile | null {
|
|
340
|
+
const record = getRecord(value);
|
|
341
|
+
if (!record) return null;
|
|
342
|
+
return {
|
|
343
|
+
role: getString(record.role, DEFAULT_AGENT_CHAT_PROFILE.role),
|
|
344
|
+
systemPrompt: getString(record.systemPrompt, DEFAULT_AGENT_CHAT_PROFILE.systemPrompt),
|
|
345
|
+
memory: getString(record.memory, DEFAULT_AGENT_CHAT_PROFILE.memory),
|
|
346
|
+
continuityPacket: getString(record.continuityPacket, DEFAULT_AGENT_CHAT_PROFILE.continuityPacket),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function resolveAgentChatProfile(...values: unknown[]): AgentChatProfile {
|
|
351
|
+
for (const value of values) {
|
|
352
|
+
const profile = getAgentChatProfile(value);
|
|
353
|
+
if (profile) return profile;
|
|
208
354
|
}
|
|
355
|
+
return DEFAULT_AGENT_CHAT_PROFILE;
|
|
209
356
|
}
|
package/src/protocol.ts
CHANGED
package/src/ws-transport.ts
CHANGED
|
@@ -3,26 +3,41 @@
|
|
|
3
3
|
* Used by both ClawroomClient and ClawroomMachineClient.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
export type WsLike = {
|
|
7
|
+
readyState: number;
|
|
8
|
+
on: (event: string, listener: (...args: unknown[]) => void) => void;
|
|
9
|
+
removeAllListeners?: (event?: string) => void;
|
|
10
|
+
send: (data: string) => void;
|
|
11
|
+
close: () => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
6
14
|
export type WsTransportOptions = {
|
|
7
15
|
url: string;
|
|
8
16
|
token: string;
|
|
9
|
-
onMessage: (msg:
|
|
17
|
+
onMessage: (msg: Record<string, unknown>) => void;
|
|
10
18
|
onConnected: () => void;
|
|
11
19
|
onDisconnected: () => void;
|
|
20
|
+
createSocket?: (url: string) => WsLike | Promise<WsLike>;
|
|
12
21
|
log?: {
|
|
13
22
|
info?: (message: string) => void;
|
|
14
23
|
warn?: (message: string) => void;
|
|
15
24
|
};
|
|
16
25
|
};
|
|
17
26
|
|
|
27
|
+
type WsModule = {
|
|
28
|
+
default: new (url: string) => WsLike;
|
|
29
|
+
};
|
|
30
|
+
|
|
18
31
|
export class WsTransport {
|
|
19
|
-
private
|
|
32
|
+
private static readonly MAX_SEND_QUEUE = 500;
|
|
33
|
+
private ws: WsLike | null = null;
|
|
20
34
|
private options: WsTransportOptions;
|
|
21
35
|
private _connected = false;
|
|
22
36
|
private stopped = false;
|
|
23
37
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
24
38
|
private reconnectDelay = 1000;
|
|
25
39
|
private failCount = 0;
|
|
40
|
+
private sendQueue: string[] = [];
|
|
26
41
|
|
|
27
42
|
static readonly MAX_RECONNECT_DELAY = 60_000;
|
|
28
43
|
static readonly MAX_RECONNECT_FAILS = 10;
|
|
@@ -34,35 +49,44 @@ export class WsTransport {
|
|
|
34
49
|
get connected() { return this._connected; }
|
|
35
50
|
|
|
36
51
|
async connect() {
|
|
37
|
-
|
|
52
|
+
this.stopped = false;
|
|
53
|
+
if (this.reconnectTimer) {
|
|
54
|
+
clearTimeout(this.reconnectTimer);
|
|
55
|
+
this.reconnectTimer = null;
|
|
56
|
+
}
|
|
57
|
+
if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) return;
|
|
38
58
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
this.options.log?.info?.("ws module not available, skipping WebSocket");
|
|
44
|
-
return;
|
|
59
|
+
if (this.ws) {
|
|
60
|
+
const staleSocket = this.ws;
|
|
61
|
+
this.ws = null;
|
|
62
|
+
this.closeSocket(staleSocket);
|
|
45
63
|
}
|
|
46
64
|
|
|
47
65
|
try {
|
|
48
|
-
this.ws =
|
|
66
|
+
this.ws = await this.createSocket();
|
|
67
|
+
if (!this.ws) return;
|
|
49
68
|
} catch {
|
|
69
|
+
this._connected = false;
|
|
50
70
|
this.scheduleReconnect();
|
|
51
71
|
return;
|
|
52
72
|
}
|
|
53
73
|
|
|
54
|
-
this.ws
|
|
74
|
+
const socket = this.ws;
|
|
75
|
+
|
|
76
|
+
socket.on("open", () => {
|
|
55
77
|
this.reconnectDelay = 1000;
|
|
56
|
-
|
|
78
|
+
socket.send(JSON.stringify({ type: "auth", token: this.options.token }));
|
|
57
79
|
});
|
|
58
80
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
81
|
+
socket.on("message", (raw) => {
|
|
82
|
+
if (!raw || typeof raw !== "object" || !("toString" in raw) || typeof raw.toString !== "function") return;
|
|
83
|
+
let msg: Record<string, unknown>;
|
|
84
|
+
try { msg = JSON.parse(raw.toString()) as Record<string, unknown>; } catch { return; }
|
|
62
85
|
|
|
63
86
|
if (msg.type === "auth_ok") {
|
|
64
87
|
this._connected = true;
|
|
65
88
|
this.failCount = 0;
|
|
89
|
+
this.flushSendQueue(socket);
|
|
66
90
|
this.options.onConnected();
|
|
67
91
|
return;
|
|
68
92
|
}
|
|
@@ -70,7 +94,7 @@ export class WsTransport {
|
|
|
70
94
|
if (msg.type === "auth_error") {
|
|
71
95
|
this._connected = false;
|
|
72
96
|
this.options.log?.warn?.(`WS auth failed: ${msg.error ?? "unknown"}`);
|
|
73
|
-
|
|
97
|
+
socket.close();
|
|
74
98
|
return;
|
|
75
99
|
}
|
|
76
100
|
|
|
@@ -79,37 +103,70 @@ export class WsTransport {
|
|
|
79
103
|
this.options.onMessage(msg);
|
|
80
104
|
});
|
|
81
105
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
this._connected = false;
|
|
85
|
-
if (wasConnected) this.options.onDisconnected();
|
|
86
|
-
if (!this.stopped) this.scheduleReconnect();
|
|
106
|
+
socket.on("close", () => {
|
|
107
|
+
this.handleDisconnect(socket);
|
|
87
108
|
});
|
|
88
109
|
|
|
89
|
-
|
|
90
|
-
this.
|
|
110
|
+
socket.on("error", () => {
|
|
111
|
+
this.handleDisconnect(socket);
|
|
112
|
+
try {
|
|
113
|
+
if (socket.readyState === 0 || socket.readyState === 1) socket.close();
|
|
114
|
+
} catch {
|
|
115
|
+
/* ignore close errors */
|
|
116
|
+
}
|
|
91
117
|
});
|
|
92
118
|
}
|
|
93
119
|
|
|
94
|
-
send(msg:
|
|
120
|
+
send(msg: Record<string, unknown>): boolean {
|
|
121
|
+
const payload = JSON.stringify(msg);
|
|
95
122
|
if (this._connected && this.ws?.readyState === 1) {
|
|
96
|
-
this.ws.send(
|
|
123
|
+
this.ws.send(payload);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if (!this.stopped) {
|
|
127
|
+
if (!this.sendQueue.includes(payload)) {
|
|
128
|
+
if (this.sendQueue.length >= WsTransport.MAX_SEND_QUEUE) {
|
|
129
|
+
this.sendQueue.shift();
|
|
130
|
+
this.options.log?.warn?.(`WS send queue full; dropping oldest queued message`);
|
|
131
|
+
}
|
|
132
|
+
this.sendQueue.push(payload);
|
|
133
|
+
}
|
|
134
|
+
this.options.log?.warn?.("WS send queued until the socket is ready");
|
|
97
135
|
}
|
|
136
|
+
return false;
|
|
98
137
|
}
|
|
99
138
|
|
|
100
139
|
disconnect() {
|
|
101
140
|
this.stopped = true;
|
|
102
141
|
this._connected = false;
|
|
103
142
|
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
|
|
104
|
-
|
|
143
|
+
this.resetReconnectState();
|
|
144
|
+
this.sendQueue = [];
|
|
145
|
+
if (this.ws) {
|
|
146
|
+
const socket = this.ws;
|
|
147
|
+
this.ws = null;
|
|
148
|
+
this.closeSocket(socket);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private handleDisconnect(socket: WsLike) {
|
|
153
|
+
if (this.ws !== socket) return;
|
|
154
|
+
this.ws = null;
|
|
155
|
+
const wasConnected = this._connected;
|
|
156
|
+
this._connected = false;
|
|
157
|
+
if (wasConnected) this.options.onDisconnected();
|
|
158
|
+
if (!this.stopped) this.scheduleReconnect();
|
|
105
159
|
}
|
|
106
160
|
|
|
107
161
|
private scheduleReconnect() {
|
|
108
162
|
if (this.stopped || this.reconnectTimer) return;
|
|
109
163
|
|
|
110
164
|
this.failCount++;
|
|
111
|
-
|
|
112
|
-
|
|
165
|
+
const failedAttempts = this.failCount;
|
|
166
|
+
if (failedAttempts > WsTransport.MAX_RECONNECT_FAILS) {
|
|
167
|
+
this._connected = false;
|
|
168
|
+
this.resetReconnectState();
|
|
169
|
+
this.options.log?.warn?.(`WS reconnect failed ${failedAttempts} times, giving up. HTTP polling active.`);
|
|
113
170
|
return;
|
|
114
171
|
}
|
|
115
172
|
|
|
@@ -120,4 +177,42 @@ export class WsTransport {
|
|
|
120
177
|
}, Math.min(this.reconnectDelay + jitter, WsTransport.MAX_RECONNECT_DELAY));
|
|
121
178
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, WsTransport.MAX_RECONNECT_DELAY);
|
|
122
179
|
}
|
|
180
|
+
|
|
181
|
+
private async createSocket(): Promise<WsLike | null> {
|
|
182
|
+
if (this.options.createSocket) {
|
|
183
|
+
return await this.options.createSocket(this.options.url);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let WebSocket: WsModule["default"];
|
|
187
|
+
try {
|
|
188
|
+
WebSocket = (await import("ws") as WsModule).default;
|
|
189
|
+
} catch {
|
|
190
|
+
this.options.log?.info?.("ws module not available, skipping WebSocket");
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return new WebSocket(this.options.url);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private closeSocket(socket: WsLike) {
|
|
198
|
+
try {
|
|
199
|
+
socket.removeAllListeners?.();
|
|
200
|
+
socket.close();
|
|
201
|
+
} catch {
|
|
202
|
+
/* ignore close errors */
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private resetReconnectState() {
|
|
207
|
+
this.reconnectDelay = 1000;
|
|
208
|
+
this.failCount = 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private flushSendQueue(socket: WsLike) {
|
|
212
|
+
while (this.ws === socket && this._connected && socket.readyState === 1 && this.sendQueue.length > 0) {
|
|
213
|
+
const payload = this.sendQueue.shift();
|
|
214
|
+
if (!payload) continue;
|
|
215
|
+
socket.send(payload);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
123
218
|
}
|