@clawroom/sdk 0.5.2 → 0.5.20
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 +238 -45
- package/src/index.ts +1 -0
- package/src/machine-client.ts +168 -67
- package/src/protocol.ts +1 -0
- package/src/ws-transport.ts +123 -28
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AgentChatProfile,
|
|
3
|
+
AgentResultFile,
|
|
3
4
|
AgentMessage,
|
|
5
|
+
AgentWorkRef,
|
|
4
6
|
ServerTask,
|
|
5
7
|
ServerChatMessage,
|
|
6
8
|
} from "./protocol.js";
|
|
@@ -13,11 +15,14 @@ const DEFAULT_AGENT_CHAT_PROFILE: AgentChatProfile = {
|
|
|
13
15
|
role: "No role defined",
|
|
14
16
|
systemPrompt: "No system prompt configured",
|
|
15
17
|
memory: "No memory recorded yet",
|
|
16
|
-
|
|
18
|
+
continuityPacket: "No continuity packet available yet",
|
|
17
19
|
};
|
|
18
20
|
|
|
19
21
|
export type TaskCallback = (task: ServerTask) => void;
|
|
20
22
|
export type ChatCallback = (messages: ServerChatMessage[]) => void;
|
|
23
|
+
export type ConnectedCallback = (agentId: string) => void;
|
|
24
|
+
export type DisconnectedCallback = () => void;
|
|
25
|
+
export type Unsubscribe = () => void;
|
|
21
26
|
|
|
22
27
|
export type ClawroomClientOptions = {
|
|
23
28
|
endpoint?: string;
|
|
@@ -33,6 +38,17 @@ export type ClawroomClientOptions = {
|
|
|
33
38
|
};
|
|
34
39
|
};
|
|
35
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
|
+
|
|
36
52
|
/**
|
|
37
53
|
* ClawRoom SDK client.
|
|
38
54
|
* WebSocket primary for real-time push, HTTP polling as fallback.
|
|
@@ -40,13 +56,19 @@ export type ClawroomClientOptions = {
|
|
|
40
56
|
export class ClawroomClient {
|
|
41
57
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
42
58
|
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
59
|
+
private heartbeatInFlight = false;
|
|
60
|
+
private pollInFlight = false;
|
|
43
61
|
protected stopped = false;
|
|
44
62
|
protected readonly httpBase: string;
|
|
45
63
|
protected readonly options: ClawroomClientOptions;
|
|
46
64
|
protected taskCallbacks: TaskCallback[] = [];
|
|
47
65
|
protected chatCallbacks: ChatCallback[] = [];
|
|
66
|
+
private connectedCallbacks: ConnectedCallback[] = [];
|
|
67
|
+
private disconnectedCallbacks: DisconnectedCallback[] = [];
|
|
48
68
|
private wsTransport: WsTransport | null = null;
|
|
49
69
|
private recentChatIds = new Set<string>();
|
|
70
|
+
private connectedAgentId: string | null = null;
|
|
71
|
+
private isConnectedValue = false;
|
|
50
72
|
|
|
51
73
|
constructor(options: ClawroomClientOptions) {
|
|
52
74
|
this.options = options;
|
|
@@ -58,12 +80,26 @@ export class ClawroomClient {
|
|
|
58
80
|
return this.httpBase.replace(/\/api\/agents$/, "").replace(/^http/, "ws") + "/api/ws";
|
|
59
81
|
}
|
|
60
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
|
+
|
|
61
95
|
connect(): void {
|
|
62
96
|
this.stopped = false;
|
|
63
97
|
this.startHeartbeat();
|
|
64
98
|
void this.register();
|
|
65
99
|
|
|
66
100
|
// WebSocket transport
|
|
101
|
+
this.wsTransport?.disconnect();
|
|
102
|
+
this.wsTransport = null;
|
|
67
103
|
this.wsTransport = new WsTransport({
|
|
68
104
|
url: this.wsUrl,
|
|
69
105
|
token: this.options.token,
|
|
@@ -72,29 +108,34 @@ export class ClawroomClient {
|
|
|
72
108
|
onDisconnected: () => { this.options.log?.info?.("[clawroom] WebSocket disconnected"); },
|
|
73
109
|
onMessage: (msg) => {
|
|
74
110
|
if (msg.type === "task" && msg.task) {
|
|
75
|
-
|
|
111
|
+
const task = msg.task as ServerTask;
|
|
112
|
+
void this.dispatchTask(task);
|
|
76
113
|
}
|
|
77
114
|
if (msg.type === "chat" && Array.isArray(msg.messages)) {
|
|
78
|
-
const fresh = msg.messages.filter((m: ServerChatMessage) => this.rememberChat(m.messageId));
|
|
115
|
+
const fresh = msg.messages.filter((m: ServerChatMessage) => this.rememberChat(m.workId ?? m.messageId));
|
|
79
116
|
if (fresh.length > 0) {
|
|
80
117
|
for (const cb of this.chatCallbacks) cb(fresh);
|
|
81
|
-
this.ackChatBestEffort(fresh.map((m: ServerChatMessage) => m.messageId));
|
|
82
118
|
}
|
|
83
119
|
}
|
|
84
|
-
|
|
85
|
-
|
|
120
|
+
const message = getRecord(msg.message);
|
|
121
|
+
if (msg.type === "message" && message) {
|
|
122
|
+
const agentProfile = resolveAgentChatProfile(msg.agentProfile, message.agentProfile);
|
|
86
123
|
const delivered = [{
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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),
|
|
92
134
|
agentProfile,
|
|
93
135
|
}] as ServerChatMessage[];
|
|
94
|
-
const fresh = delivered.filter((m) => this.rememberChat(m.messageId));
|
|
136
|
+
const fresh = delivered.filter((m) => this.rememberChat(m.workId ?? m.messageId));
|
|
95
137
|
if (fresh.length > 0) {
|
|
96
138
|
for (const cb of this.chatCallbacks) cb(fresh);
|
|
97
|
-
this.ackChatBestEffort(fresh.map((m) => m.messageId));
|
|
98
139
|
}
|
|
99
140
|
}
|
|
100
141
|
},
|
|
@@ -117,14 +158,84 @@ export class ClawroomClient {
|
|
|
117
158
|
this.stopPolling();
|
|
118
159
|
this.wsTransport?.disconnect();
|
|
119
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 });
|
|
120
216
|
}
|
|
121
217
|
|
|
122
|
-
|
|
123
|
-
this.
|
|
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 });
|
|
124
220
|
}
|
|
125
221
|
|
|
126
|
-
|
|
127
|
-
|
|
222
|
+
async sendTyping(channelId: string): Promise<void> {
|
|
223
|
+
await this.send({ type: "agent.typing", channelId });
|
|
224
|
+
}
|
|
225
|
+
|
|
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
|
+
}
|
|
128
239
|
|
|
129
240
|
// ── Heartbeat ─────────────────────────────────────────
|
|
130
241
|
|
|
@@ -144,54 +255,73 @@ export class ClawroomClient {
|
|
|
144
255
|
}
|
|
145
256
|
|
|
146
257
|
protected async register(): Promise<void> {
|
|
258
|
+
if (this.heartbeatInFlight) return;
|
|
259
|
+
this.heartbeatInFlight = true;
|
|
147
260
|
try {
|
|
148
|
-
await this.httpRequest("POST", "/heartbeat", {
|
|
261
|
+
const response = await this.httpRequest("POST", "/heartbeat", {
|
|
149
262
|
deviceId: this.options.deviceId,
|
|
150
263
|
skills: this.options.skills,
|
|
151
264
|
kind: this.options.kind ?? "openclaw",
|
|
152
|
-
});
|
|
153
|
-
this.onPollSuccess(
|
|
265
|
+
}) as AgentHeartbeatResponse;
|
|
266
|
+
this.onPollSuccess(response.agentId);
|
|
154
267
|
} catch (err) {
|
|
155
268
|
this.options.log?.warn?.(`[clawroom] heartbeat error: ${err}`);
|
|
156
|
-
this.
|
|
269
|
+
if (!this.wsTransport?.connected) {
|
|
270
|
+
this.onPollError(err);
|
|
271
|
+
}
|
|
272
|
+
} finally {
|
|
273
|
+
this.heartbeatInFlight = false;
|
|
157
274
|
}
|
|
158
275
|
}
|
|
159
276
|
|
|
160
277
|
protected async pollTick(): Promise<void> {
|
|
161
|
-
if (this.stopped) return;
|
|
278
|
+
if (this.stopped || this.pollInFlight) return;
|
|
279
|
+
this.pollInFlight = true;
|
|
162
280
|
try {
|
|
163
|
-
const res = await this.httpRequest("POST", "/poll", {});
|
|
281
|
+
const res = await this.httpRequest("POST", "/poll", {}) as AgentPollResponse;
|
|
164
282
|
this.onPollSuccess(res?.agentId);
|
|
165
283
|
if (res.task) {
|
|
166
|
-
this.
|
|
167
|
-
for (const cb of this.taskCallbacks) cb(res.task);
|
|
284
|
+
await this.dispatchTask(res.task);
|
|
168
285
|
}
|
|
169
286
|
if (res.chat && Array.isArray(res.chat) && res.chat.length > 0) {
|
|
170
|
-
const fresh = res.chat.filter((m: ServerChatMessage) => this.rememberChat(m.messageId));
|
|
287
|
+
const fresh = res.chat.filter((m: ServerChatMessage) => this.rememberChat(m.workId ?? m.messageId));
|
|
171
288
|
if (fresh.length > 0) {
|
|
172
289
|
this.options.log?.info?.(`[clawroom] received ${fresh.length} chat mention(s)`);
|
|
173
290
|
for (const cb of this.chatCallbacks) cb(fresh);
|
|
174
|
-
this.ackChatBestEffort(fresh.map((m: ServerChatMessage) => m.messageId));
|
|
175
291
|
}
|
|
176
292
|
}
|
|
177
293
|
} catch (err) {
|
|
178
294
|
this.options.log?.warn?.(`[clawroom] poll error: ${err}`);
|
|
179
295
|
this.onPollError(err);
|
|
296
|
+
} finally {
|
|
297
|
+
this.pollInFlight = false;
|
|
180
298
|
}
|
|
181
299
|
}
|
|
182
300
|
|
|
183
|
-
protected onPollSuccess(
|
|
184
|
-
|
|
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
|
+
}
|
|
185
310
|
|
|
186
311
|
// ── HTTP ──────────────────────────────────────────────
|
|
187
312
|
|
|
188
313
|
private async sendViaHttp(message: AgentMessage): Promise<void> {
|
|
189
314
|
switch (message.type) {
|
|
190
|
-
case "agent.complete": await this.httpRequest("POST", "/complete", { taskId: message.taskId, output: message.output, attachments: message.attachments }); break;
|
|
191
|
-
case "agent.fail": await this.httpRequest("POST", "/fail", { taskId: message.taskId, reason: message.reason }); break;
|
|
192
|
-
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;
|
|
193
318
|
case "agent.heartbeat": await this.httpRequest("POST", "/heartbeat", {}); break;
|
|
194
|
-
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;
|
|
195
325
|
case "agent.typing": await this.httpRequest("POST", "/typing", { channelId: message.channelId }); break;
|
|
196
326
|
}
|
|
197
327
|
}
|
|
@@ -202,18 +332,26 @@ export class ClawroomClient {
|
|
|
202
332
|
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${this.options.token}` },
|
|
203
333
|
body: JSON.stringify(body),
|
|
204
334
|
});
|
|
335
|
+
const text = await res.text().catch(() => "");
|
|
205
336
|
if (!res.ok) {
|
|
206
|
-
const text = await res.text().catch(() => "");
|
|
207
337
|
this.onHttpError(res.status, text);
|
|
208
338
|
throw new Error(`HTTP ${res.status}: ${text}`);
|
|
209
339
|
}
|
|
210
|
-
|
|
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
|
+
}
|
|
211
346
|
}
|
|
212
347
|
|
|
213
|
-
protected onHttpError(
|
|
348
|
+
protected onHttpError(status: number, text: string): void {
|
|
349
|
+
void status;
|
|
350
|
+
void text;
|
|
351
|
+
}
|
|
214
352
|
|
|
215
353
|
private rememberChat(messageId?: string): boolean {
|
|
216
|
-
if (!messageId) return
|
|
354
|
+
if (!messageId) return false;
|
|
217
355
|
if (this.recentChatIds.has(messageId)) return false;
|
|
218
356
|
this.recentChatIds.add(messageId);
|
|
219
357
|
if (this.recentChatIds.size > 1000) {
|
|
@@ -223,15 +361,70 @@ export class ClawroomClient {
|
|
|
223
361
|
return true;
|
|
224
362
|
}
|
|
225
363
|
|
|
226
|
-
private
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
});
|
|
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);
|
|
230
367
|
}
|
|
231
368
|
|
|
232
|
-
private
|
|
233
|
-
|
|
234
|
-
if (
|
|
235
|
-
|
|
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();
|
|
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
|
+
}
|
|
400
|
+
|
|
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;
|
|
236
428
|
}
|
|
429
|
+
return DEFAULT_AGENT_CHAT_PROFILE;
|
|
237
430
|
}
|
package/src/index.ts
CHANGED
package/src/machine-client.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import * as os from "node:os";
|
|
7
|
-
import type { AgentChatProfile, ServerChatMessage } from "./protocol.js";
|
|
7
|
+
import type { AgentChatProfile, AgentWorkRef, ServerChatMessage, ServerTask } from "./protocol.js";
|
|
8
8
|
import { WsTransport } from "./ws-transport.js";
|
|
9
9
|
|
|
10
10
|
export type ClawroomMachineClientOptions = {
|
|
@@ -20,10 +20,22 @@ export type ClawroomMachineClientOptions = {
|
|
|
20
20
|
};
|
|
21
21
|
};
|
|
22
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
|
+
|
|
23
35
|
type AgentWork = {
|
|
24
36
|
agentId: string;
|
|
25
37
|
agentName: string;
|
|
26
|
-
task:
|
|
38
|
+
task: ServerTask | null;
|
|
27
39
|
chat: ServerChatMessage[] | null;
|
|
28
40
|
};
|
|
29
41
|
|
|
@@ -31,8 +43,9 @@ const DEFAULT_AGENT_CHAT_PROFILE: AgentChatProfile = {
|
|
|
31
43
|
role: "No role defined",
|
|
32
44
|
systemPrompt: "No system prompt configured",
|
|
33
45
|
memory: "No memory recorded yet",
|
|
34
|
-
|
|
46
|
+
continuityPacket: "No continuity packet available yet",
|
|
35
47
|
};
|
|
48
|
+
const MAX_RECENT_CHAT_IDS = 1000;
|
|
36
49
|
|
|
37
50
|
type MachineHeartbeatResponse = {
|
|
38
51
|
machineId: string;
|
|
@@ -48,6 +61,8 @@ export class ClawroomMachineClient {
|
|
|
48
61
|
private options: ClawroomMachineClientOptions;
|
|
49
62
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
50
63
|
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
64
|
+
private heartbeatInFlight = false;
|
|
65
|
+
private pollInFlight = false;
|
|
51
66
|
private taskHandler: ((agentId: string, task: AgentWork["task"]) => void) | null = null;
|
|
52
67
|
private chatHandler: ((agentId: string, messages: NonNullable<AgentWork["chat"]>) => void) | null = null;
|
|
53
68
|
private connectHandler: ((machineId: string) => void) | null = null;
|
|
@@ -55,7 +70,6 @@ export class ClawroomMachineClient {
|
|
|
55
70
|
private _connected = false;
|
|
56
71
|
private _stopped = false;
|
|
57
72
|
private wsTransport: WsTransport | null = null;
|
|
58
|
-
private subscribedAgentIds = new Set<string>();
|
|
59
73
|
private recentChatIds = new Map<string, number>();
|
|
60
74
|
|
|
61
75
|
constructor(options: ClawroomMachineClientOptions) {
|
|
@@ -79,7 +93,15 @@ export class ClawroomMachineClient {
|
|
|
79
93
|
body: body ? JSON.stringify(body) : undefined,
|
|
80
94
|
});
|
|
81
95
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
|
82
|
-
|
|
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
|
+
}
|
|
83
105
|
}
|
|
84
106
|
|
|
85
107
|
onAgentTask(handler: (agentId: string, task: AgentWork["task"]) => void) { this.taskHandler = handler; }
|
|
@@ -87,56 +109,44 @@ export class ClawroomMachineClient {
|
|
|
87
109
|
onConnected(handler: (machineId: string) => void) { this.connectHandler = handler; }
|
|
88
110
|
onDisconnected(handler: () => void) { this.disconnectHandler = handler; }
|
|
89
111
|
|
|
90
|
-
async sendAgentComplete(agentId: string, taskId: string, output: string, attachments?: Array<{ filename: string; mimeType: string; data: string }
|
|
91
|
-
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 });
|
|
92
114
|
}
|
|
93
115
|
|
|
94
|
-
async sendAgentFail(agentId: string, taskId: string, reason: string) {
|
|
95
|
-
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 });
|
|
96
118
|
}
|
|
97
119
|
|
|
98
|
-
async sendAgentChatReply(agentId: string, channelId: string, content: string) {
|
|
99
|
-
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 });
|
|
100
122
|
}
|
|
101
123
|
|
|
102
124
|
async sendAgentTyping(agentId: string, channelId: string) {
|
|
103
125
|
await this.httpRequest("POST", "/typing", { agentId, channelId });
|
|
104
126
|
}
|
|
105
127
|
|
|
106
|
-
async
|
|
107
|
-
|
|
108
|
-
if (ids.length === 0) return;
|
|
109
|
-
await this.httpRequest("POST", "/chat-ack", { agentId, messageIds: ids });
|
|
128
|
+
async sendAgentReflection(agentId: string, reflection: AgentReflectionPayload) {
|
|
129
|
+
await this.httpRequest("POST", "/reflections", { agentId, ...reflection });
|
|
110
130
|
}
|
|
111
131
|
|
|
112
132
|
get connected() { return this._connected; }
|
|
113
133
|
get stopped() { return this._stopped; }
|
|
114
134
|
|
|
115
|
-
private rememberChat(messageId
|
|
116
|
-
if (!messageId) return
|
|
135
|
+
private rememberChat(messageId?: string | null): boolean {
|
|
136
|
+
if (!messageId) return false;
|
|
117
137
|
const now = Date.now();
|
|
118
138
|
for (const [id, seenAt] of this.recentChatIds) {
|
|
119
139
|
if (now - seenAt > 10 * 60_000) this.recentChatIds.delete(id);
|
|
120
140
|
}
|
|
121
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
|
+
}
|
|
122
146
|
this.recentChatIds.set(messageId, now);
|
|
123
147
|
return true;
|
|
124
148
|
}
|
|
125
149
|
|
|
126
|
-
private ackAgentChatBestEffort(agentId: string, messageIds: string[]) {
|
|
127
|
-
void this.ackAgentChat(agentId, messageIds).catch((err) => {
|
|
128
|
-
this.options.log?.warn?.(`[machine] chat ack error: ${err}`);
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
private syncAgentSubscriptions(agentIds: string[]) {
|
|
133
|
-
if (agentIds.length === 0) return;
|
|
134
|
-
for (const agentId of agentIds) {
|
|
135
|
-
this.subscribedAgentIds.add(agentId);
|
|
136
|
-
this.wsTransport?.send({ type: "subscribe_agent", agentId });
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
150
|
private markConnected(machineId: string) {
|
|
141
151
|
if (!this._connected) {
|
|
142
152
|
this._connected = true;
|
|
@@ -154,48 +164,76 @@ export class ClawroomMachineClient {
|
|
|
154
164
|
}
|
|
155
165
|
|
|
156
166
|
private async pollOnce() {
|
|
157
|
-
if (!this._connected) return;
|
|
167
|
+
if (!this._connected || this.pollInFlight) return;
|
|
168
|
+
this.pollInFlight = true;
|
|
158
169
|
try {
|
|
159
170
|
const result = (await this.httpRequest("POST", "/poll", {})) as MachinePollResponse;
|
|
160
|
-
this.syncAgentSubscriptions(result.agents.map((agent) => agent.agentId));
|
|
161
171
|
for (const agent of result.agents) {
|
|
162
|
-
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
|
+
}
|
|
163
181
|
if (agent.chat && agent.chat.length > 0 && this.chatHandler) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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}`);
|
|
168
189
|
}
|
|
169
190
|
}
|
|
170
191
|
}
|
|
171
192
|
} catch (err) {
|
|
172
193
|
this.options.log?.warn?.(`[machine] poll error: ${err}`);
|
|
194
|
+
} finally {
|
|
195
|
+
this.pollInFlight = false;
|
|
173
196
|
}
|
|
174
197
|
}
|
|
175
198
|
|
|
176
199
|
connect() {
|
|
200
|
+
this.stopHeartbeat();
|
|
201
|
+
this.stopPolling();
|
|
202
|
+
this.wsTransport?.disconnect();
|
|
203
|
+
this.wsTransport = null;
|
|
177
204
|
this._stopped = false;
|
|
178
205
|
const hostname = this.options.hostname ?? os.hostname();
|
|
179
|
-
const hbBody = {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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;
|
|
183
216
|
try {
|
|
184
217
|
const result = (await this.httpRequest("POST", "/heartbeat", hbBody)) as MachineHeartbeatResponse;
|
|
185
|
-
this.syncAgentSubscriptions((result.agents ?? []).map((agent) => agent.id));
|
|
186
218
|
this.markConnected(result.machineId);
|
|
187
|
-
} catch {
|
|
188
|
-
this.
|
|
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;
|
|
189
228
|
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Heartbeat every 30s (always HTTP)
|
|
232
|
+
this.heartbeatTimer = setInterval(() => {
|
|
233
|
+
void sendHeartbeat().catch(() => {});
|
|
190
234
|
}, 30_000);
|
|
191
235
|
|
|
192
|
-
this.
|
|
193
|
-
.then((result) => {
|
|
194
|
-
const data = result as MachineHeartbeatResponse;
|
|
195
|
-
this.syncAgentSubscriptions((data.agents ?? []).map((agent) => agent.id));
|
|
196
|
-
this.markConnected(data.machineId);
|
|
197
|
-
})
|
|
198
|
-
.catch((err) => this.options.log?.warn?.(`[machine] initial heartbeat failed: ${err}`));
|
|
236
|
+
sendHeartbeat().catch((err) => this.options.log?.warn?.(`[machine] initial heartbeat failed: ${err}`));
|
|
199
237
|
|
|
200
238
|
// WebSocket transport
|
|
201
239
|
this.wsTransport = new WsTransport({
|
|
@@ -203,33 +241,37 @@ export class ClawroomMachineClient {
|
|
|
203
241
|
token: this.options.apiKey,
|
|
204
242
|
log: { info: (m) => this.options.log?.info?.(m), warn: (m) => this.options.log?.warn?.(m) },
|
|
205
243
|
onConnected: () => {
|
|
206
|
-
this.syncAgentSubscriptions([...this.subscribedAgentIds]);
|
|
207
244
|
this.options.log?.info?.("[machine] WebSocket connected");
|
|
208
245
|
},
|
|
209
246
|
onDisconnected: () => {
|
|
210
247
|
this.options.log?.info?.("[machine] WebSocket disconnected, HTTP polling active");
|
|
211
248
|
},
|
|
212
249
|
onMessage: (msg) => {
|
|
250
|
+
const message = getRecord(msg.message);
|
|
213
251
|
if (msg.type === "message") {
|
|
214
|
-
const
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const agentProfile = msg.agentProfile ?? m.agentProfile ?? DEFAULT_AGENT_CHAT_PROFILE;
|
|
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);
|
|
219
256
|
const messages = [{
|
|
257
|
+
workId: getString(message?.workId),
|
|
258
|
+
leaseToken: getString(message?.leaseToken),
|
|
220
259
|
messageId,
|
|
221
|
-
channelId:
|
|
222
|
-
content:
|
|
223
|
-
|
|
224
|
-
|
|
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),
|
|
225
267
|
agentProfile,
|
|
226
268
|
}];
|
|
227
269
|
this.chatHandler(targetAgentId, messages);
|
|
228
|
-
this.ackAgentChatBestEffort(targetAgentId, messages.map((message) => message.messageId));
|
|
229
270
|
}
|
|
230
271
|
}
|
|
231
|
-
|
|
232
|
-
|
|
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);
|
|
233
275
|
}
|
|
234
276
|
},
|
|
235
277
|
});
|
|
@@ -247,9 +289,68 @@ export class ClawroomMachineClient {
|
|
|
247
289
|
disconnect() {
|
|
248
290
|
this._stopped = true;
|
|
249
291
|
this._connected = false;
|
|
250
|
-
|
|
251
|
-
|
|
292
|
+
this.stopHeartbeat();
|
|
293
|
+
this.stopPolling();
|
|
252
294
|
this.wsTransport?.disconnect();
|
|
253
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;
|
|
254
354
|
}
|
|
355
|
+
return DEFAULT_AGENT_CHAT_PROFILE;
|
|
255
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
|
}
|