@clawroom/sdk 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/client.ts +99 -48
- package/src/index.ts +3 -0
- package/src/machine-client.ts +162 -34
- package/src/protocol.ts +21 -83
- package/src/ws-transport.ts +123 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clawroom/sdk",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "ClawRoom SDK — polling client and protocol types for connecting any agent to ClawRoom",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -19,5 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src"
|
|
21
21
|
],
|
|
22
|
-
"dependencies": {
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@clawroom/protocol": "^0.5.1"
|
|
24
|
+
}
|
|
23
25
|
}
|
package/src/client.ts
CHANGED
|
@@ -1,29 +1,31 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
AgentChatProfile,
|
|
2
3
|
AgentMessage,
|
|
3
4
|
ServerTask,
|
|
4
5
|
ServerChatMessage,
|
|
5
6
|
} from "./protocol.js";
|
|
7
|
+
import { WsTransport } from "./ws-transport.js";
|
|
6
8
|
|
|
7
9
|
const DEFAULT_ENDPOINT = "https://clawroom.site9.ai/api/agents";
|
|
8
|
-
|
|
9
10
|
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
10
11
|
const POLL_INTERVAL_MS = 10_000;
|
|
12
|
+
const DEFAULT_AGENT_CHAT_PROFILE: AgentChatProfile = {
|
|
13
|
+
role: "No role defined",
|
|
14
|
+
systemPrompt: "No system prompt configured",
|
|
15
|
+
memory: "No memory recorded yet",
|
|
16
|
+
focus: "No focus items",
|
|
17
|
+
};
|
|
11
18
|
|
|
12
19
|
export type TaskCallback = (task: ServerTask) => void;
|
|
13
20
|
export type ChatCallback = (messages: ServerChatMessage[]) => void;
|
|
14
21
|
|
|
15
22
|
export type ClawroomClientOptions = {
|
|
16
|
-
/** HTTP base URL. Defaults to https://clawroom.site9.ai/api/agents */
|
|
17
23
|
endpoint?: string;
|
|
18
|
-
/** Agent secret token */
|
|
19
24
|
token: string;
|
|
20
|
-
/** Device identifier */
|
|
21
25
|
deviceId: string;
|
|
22
|
-
/** Agent skills */
|
|
23
26
|
skills: string[];
|
|
24
|
-
/** Agent kind (e.g. "openclaw", "claude-code", "codex", "custom"). Defaults to "openclaw" */
|
|
25
27
|
kind?: string;
|
|
26
|
-
|
|
28
|
+
wsUrl?: string;
|
|
27
29
|
log?: {
|
|
28
30
|
info?: (message: string, ...args: unknown[]) => void;
|
|
29
31
|
warn?: (message: string, ...args: unknown[]) => void;
|
|
@@ -32,34 +34,8 @@ export type ClawroomClientOptions = {
|
|
|
32
34
|
};
|
|
33
35
|
|
|
34
36
|
/**
|
|
35
|
-
* ClawRoom SDK client
|
|
36
|
-
*
|
|
37
|
-
* Agents register with /heartbeat and receive assigned tasks + chat via /poll.
|
|
38
|
-
* All agent actions (complete, fail, progress, chat reply) use HTTP POST.
|
|
39
|
-
*
|
|
40
|
-
* Usage:
|
|
41
|
-
* ```ts
|
|
42
|
-
* import { ClawroomClient } from "@clawroom/sdk";
|
|
43
|
-
*
|
|
44
|
-
* const client = new ClawroomClient({
|
|
45
|
-
* token: "your-agent-secret",
|
|
46
|
-
* deviceId: "my-agent-1",
|
|
47
|
-
* skills: ["translation", "coding"],
|
|
48
|
-
* });
|
|
49
|
-
*
|
|
50
|
-
* client.onTask((task) => {
|
|
51
|
-
* // task was assigned to this agent — execute it
|
|
52
|
-
* client.send({ type: "agent.complete", taskId: task.taskId, output: "Done!" });
|
|
53
|
-
* });
|
|
54
|
-
*
|
|
55
|
-
* client.onChatMessage((messages) => {
|
|
56
|
-
* for (const msg of messages) {
|
|
57
|
-
* client.send({ type: "agent.chat.reply", channelId: msg.channelId, content: "Hello!" });
|
|
58
|
-
* }
|
|
59
|
-
* });
|
|
60
|
-
*
|
|
61
|
-
* client.connect();
|
|
62
|
-
* ```
|
|
37
|
+
* ClawRoom SDK client.
|
|
38
|
+
* WebSocket primary for real-time push, HTTP polling as fallback.
|
|
63
39
|
*/
|
|
64
40
|
export class ClawroomClient {
|
|
65
41
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
@@ -67,27 +43,80 @@ export class ClawroomClient {
|
|
|
67
43
|
protected stopped = false;
|
|
68
44
|
protected readonly httpBase: string;
|
|
69
45
|
protected readonly options: ClawroomClientOptions;
|
|
70
|
-
|
|
71
46
|
protected taskCallbacks: TaskCallback[] = [];
|
|
72
47
|
protected chatCallbacks: ChatCallback[] = [];
|
|
48
|
+
private wsTransport: WsTransport | null = null;
|
|
49
|
+
private recentChatIds = new Set<string>();
|
|
73
50
|
|
|
74
51
|
constructor(options: ClawroomClientOptions) {
|
|
75
52
|
this.options = options;
|
|
76
53
|
this.httpBase = (options.endpoint || DEFAULT_ENDPOINT).replace(/\/+$/, "");
|
|
77
54
|
}
|
|
78
55
|
|
|
56
|
+
private get wsUrl(): string {
|
|
57
|
+
if (this.options.wsUrl) return this.options.wsUrl;
|
|
58
|
+
return this.httpBase.replace(/\/api\/agents$/, "").replace(/^http/, "ws") + "/api/ws";
|
|
59
|
+
}
|
|
60
|
+
|
|
79
61
|
connect(): void {
|
|
80
62
|
this.stopped = false;
|
|
81
63
|
this.startHeartbeat();
|
|
82
|
-
this.stopPolling();
|
|
83
|
-
this.pollTimer = setInterval(() => void this.pollTick(), POLL_INTERVAL_MS);
|
|
84
64
|
void this.register();
|
|
65
|
+
|
|
66
|
+
// WebSocket transport
|
|
67
|
+
this.wsTransport = new WsTransport({
|
|
68
|
+
url: this.wsUrl,
|
|
69
|
+
token: this.options.token,
|
|
70
|
+
log: { info: (m) => this.options.log?.info?.(m), warn: (m) => this.options.log?.warn?.(m) },
|
|
71
|
+
onConnected: () => { this.options.log?.info?.("[clawroom] WebSocket connected"); },
|
|
72
|
+
onDisconnected: () => { this.options.log?.info?.("[clawroom] WebSocket disconnected"); },
|
|
73
|
+
onMessage: (msg) => {
|
|
74
|
+
if (msg.type === "task" && msg.task) {
|
|
75
|
+
for (const cb of this.taskCallbacks) cb(msg.task);
|
|
76
|
+
}
|
|
77
|
+
if (msg.type === "chat" && Array.isArray(msg.messages)) {
|
|
78
|
+
const fresh = msg.messages.filter((m: ServerChatMessage) => this.rememberChat(m.messageId));
|
|
79
|
+
if (fresh.length > 0) {
|
|
80
|
+
for (const cb of this.chatCallbacks) cb(fresh);
|
|
81
|
+
this.ackChatBestEffort(fresh.map((m: ServerChatMessage) => m.messageId));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (msg.type === "message" && msg.message) {
|
|
85
|
+
const agentProfile = msg.agentProfile ?? msg.message.agentProfile ?? DEFAULT_AGENT_CHAT_PROFILE;
|
|
86
|
+
const delivered = [{
|
|
87
|
+
messageId: msg.message.id ?? msg.messageId,
|
|
88
|
+
channelId: msg.message.channelId ?? "",
|
|
89
|
+
content: msg.message.content ?? "",
|
|
90
|
+
context: Array.isArray(msg.context) ? msg.context : [],
|
|
91
|
+
isMention: msg.isMention ?? false,
|
|
92
|
+
agentProfile,
|
|
93
|
+
}] as ServerChatMessage[];
|
|
94
|
+
const fresh = delivered.filter((m) => this.rememberChat(m.messageId));
|
|
95
|
+
if (fresh.length > 0) {
|
|
96
|
+
for (const cb of this.chatCallbacks) cb(fresh);
|
|
97
|
+
this.ackChatBestEffort(fresh.map((m) => m.messageId));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
this.wsTransport.connect();
|
|
103
|
+
|
|
104
|
+
// Keep HTTP polling active even when WS is up.
|
|
105
|
+
// WS is the fast path; polling is the durable delivery path.
|
|
106
|
+
this.stopPolling();
|
|
107
|
+
this.pollTimer = setInterval(() => {
|
|
108
|
+
void this.pollTick();
|
|
109
|
+
}, POLL_INTERVAL_MS);
|
|
110
|
+
|
|
111
|
+
void this.pollTick();
|
|
85
112
|
}
|
|
86
113
|
|
|
87
114
|
disconnect(): void {
|
|
88
115
|
this.stopped = true;
|
|
89
116
|
this.stopHeartbeat();
|
|
90
117
|
this.stopPolling();
|
|
118
|
+
this.wsTransport?.disconnect();
|
|
119
|
+
this.wsTransport = null;
|
|
91
120
|
}
|
|
92
121
|
|
|
93
122
|
send(message: AgentMessage): void {
|
|
@@ -97,7 +126,7 @@ export class ClawroomClient {
|
|
|
97
126
|
onTask(cb: TaskCallback): void { this.taskCallbacks.push(cb); }
|
|
98
127
|
onChatMessage(cb: ChatCallback): void { this.chatCallbacks.push(cb); }
|
|
99
128
|
|
|
100
|
-
// ── Heartbeat
|
|
129
|
+
// ── Heartbeat ─────────────────────────────────────────
|
|
101
130
|
|
|
102
131
|
private startHeartbeat(): void {
|
|
103
132
|
this.stopHeartbeat();
|
|
@@ -133,15 +162,17 @@ export class ClawroomClient {
|
|
|
133
162
|
try {
|
|
134
163
|
const res = await this.httpRequest("POST", "/poll", {});
|
|
135
164
|
this.onPollSuccess(res?.agentId);
|
|
136
|
-
|
|
137
165
|
if (res.task) {
|
|
138
166
|
this.options.log?.info?.(`[clawroom] received task ${res.task.taskId}: ${res.task.title}`);
|
|
139
167
|
for (const cb of this.taskCallbacks) cb(res.task);
|
|
140
168
|
}
|
|
141
|
-
|
|
142
169
|
if (res.chat && Array.isArray(res.chat) && res.chat.length > 0) {
|
|
143
|
-
|
|
144
|
-
|
|
170
|
+
const fresh = res.chat.filter((m: ServerChatMessage) => this.rememberChat(m.messageId));
|
|
171
|
+
if (fresh.length > 0) {
|
|
172
|
+
this.options.log?.info?.(`[clawroom] received ${fresh.length} chat mention(s)`);
|
|
173
|
+
for (const cb of this.chatCallbacks) cb(fresh);
|
|
174
|
+
this.ackChatBestEffort(fresh.map((m: ServerChatMessage) => m.messageId));
|
|
175
|
+
}
|
|
145
176
|
}
|
|
146
177
|
} catch (err) {
|
|
147
178
|
this.options.log?.warn?.(`[clawroom] poll error: ${err}`);
|
|
@@ -149,12 +180,10 @@ export class ClawroomClient {
|
|
|
149
180
|
}
|
|
150
181
|
}
|
|
151
182
|
|
|
152
|
-
/** Override in subclass for lifecycle tracking */
|
|
153
183
|
protected onPollSuccess(_agentId: string | undefined): void {}
|
|
154
|
-
/** Override in subclass for lifecycle tracking */
|
|
155
184
|
protected onPollError(_err: unknown): void {}
|
|
156
185
|
|
|
157
|
-
// ── HTTP
|
|
186
|
+
// ── HTTP ──────────────────────────────────────────────
|
|
158
187
|
|
|
159
188
|
private async sendViaHttp(message: AgentMessage): Promise<void> {
|
|
160
189
|
switch (message.type) {
|
|
@@ -167,7 +196,7 @@ export class ClawroomClient {
|
|
|
167
196
|
}
|
|
168
197
|
}
|
|
169
198
|
|
|
170
|
-
protected async httpRequest(method: string, path: string, body: unknown): Promise<
|
|
199
|
+
protected async httpRequest(method: string, path: string, body: unknown): Promise<unknown> {
|
|
171
200
|
const res = await fetch(`${this.httpBase}${path}`, {
|
|
172
201
|
method,
|
|
173
202
|
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${this.options.token}` },
|
|
@@ -181,6 +210,28 @@ export class ClawroomClient {
|
|
|
181
210
|
return res.json();
|
|
182
211
|
}
|
|
183
212
|
|
|
184
|
-
/** Override in subclass for error handling (e.g. 401 auto-stop) */
|
|
185
213
|
protected onHttpError(_status: number, _text: string): void {}
|
|
214
|
+
|
|
215
|
+
private rememberChat(messageId?: string): boolean {
|
|
216
|
+
if (!messageId) return true;
|
|
217
|
+
if (this.recentChatIds.has(messageId)) return false;
|
|
218
|
+
this.recentChatIds.add(messageId);
|
|
219
|
+
if (this.recentChatIds.size > 1000) {
|
|
220
|
+
const oldest = this.recentChatIds.values().next().value;
|
|
221
|
+
if (oldest) this.recentChatIds.delete(oldest);
|
|
222
|
+
}
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private ackChatBestEffort(messageIds: string[]): void {
|
|
227
|
+
void this.ackChat(messageIds).catch((err) => {
|
|
228
|
+
this.options.log?.warn?.(`[clawroom] chat ack error: ${err}`);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private async ackChat(messageIds: string[]): Promise<void> {
|
|
233
|
+
const ids = Array.from(new Set(messageIds.filter(Boolean)));
|
|
234
|
+
if (ids.length === 0) return;
|
|
235
|
+
await this.httpRequest("POST", "/chat/ack", { messageIds: ids });
|
|
236
|
+
}
|
|
186
237
|
}
|
package/src/index.ts
CHANGED
|
@@ -2,8 +2,11 @@ export { ClawroomClient } from "./client.js";
|
|
|
2
2
|
export type { ClawroomClientOptions } from "./client.js";
|
|
3
3
|
export { ClawroomMachineClient } from "./machine-client.js";
|
|
4
4
|
export type { ClawroomMachineClientOptions } from "./machine-client.js";
|
|
5
|
+
export { WsTransport } from "./ws-transport.js";
|
|
6
|
+
export type { WsTransportOptions } from "./ws-transport.js";
|
|
5
7
|
|
|
6
8
|
export type {
|
|
9
|
+
AgentChatProfile,
|
|
7
10
|
AgentMessage,
|
|
8
11
|
AgentHeartbeat,
|
|
9
12
|
AgentComplete,
|
package/src/machine-client.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ClawRoom Machine Client — machine-level auth with multi-agent support.
|
|
3
|
+
* WebSocket primary for real-time push, HTTP polling as fallback.
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
6
|
import * as os from "node:os";
|
|
7
|
+
import type { AgentChatProfile, ServerChatMessage } from "./protocol.js";
|
|
8
|
+
import { WsTransport } from "./ws-transport.js";
|
|
6
9
|
|
|
7
10
|
export type ClawroomMachineClientOptions = {
|
|
8
11
|
endpoint?: string;
|
|
9
12
|
apiKey: string;
|
|
10
13
|
hostname?: string;
|
|
11
14
|
capabilities?: string[];
|
|
15
|
+
wsUrl?: string;
|
|
12
16
|
log?: {
|
|
13
17
|
info?: (message: string, ...args: unknown[]) => void;
|
|
14
18
|
warn?: (message: string, ...args: unknown[]) => void;
|
|
@@ -20,7 +24,24 @@ type AgentWork = {
|
|
|
20
24
|
agentId: string;
|
|
21
25
|
agentName: string;
|
|
22
26
|
task: { type: "server.task"; taskId: string; title: string; description: string; input: string; skillTags: string[] } | null;
|
|
23
|
-
chat:
|
|
27
|
+
chat: ServerChatMessage[] | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const DEFAULT_AGENT_CHAT_PROFILE: AgentChatProfile = {
|
|
31
|
+
role: "No role defined",
|
|
32
|
+
systemPrompt: "No system prompt configured",
|
|
33
|
+
memory: "No memory recorded yet",
|
|
34
|
+
focus: "No focus items",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type MachineHeartbeatResponse = {
|
|
38
|
+
machineId: string;
|
|
39
|
+
agents?: Array<{ id: string }>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type MachinePollResponse = {
|
|
43
|
+
machineId: string;
|
|
44
|
+
agents: AgentWork[];
|
|
24
45
|
};
|
|
25
46
|
|
|
26
47
|
export class ClawroomMachineClient {
|
|
@@ -29,8 +50,13 @@ export class ClawroomMachineClient {
|
|
|
29
50
|
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
30
51
|
private taskHandler: ((agentId: string, task: AgentWork["task"]) => void) | null = null;
|
|
31
52
|
private chatHandler: ((agentId: string, messages: NonNullable<AgentWork["chat"]>) => void) | null = null;
|
|
53
|
+
private connectHandler: ((machineId: string) => void) | null = null;
|
|
54
|
+
private disconnectHandler: (() => void) | null = null;
|
|
32
55
|
private _connected = false;
|
|
33
56
|
private _stopped = false;
|
|
57
|
+
private wsTransport: WsTransport | null = null;
|
|
58
|
+
private subscribedAgentIds = new Set<string>();
|
|
59
|
+
private recentChatIds = new Map<string, number>();
|
|
34
60
|
|
|
35
61
|
constructor(options: ClawroomMachineClientOptions) {
|
|
36
62
|
this.options = options;
|
|
@@ -40,31 +66,29 @@ export class ClawroomMachineClient {
|
|
|
40
66
|
return this.options.endpoint ?? "http://localhost:3000/api/machines";
|
|
41
67
|
}
|
|
42
68
|
|
|
69
|
+
private get wsUrl(): string {
|
|
70
|
+
if (this.options.wsUrl) return this.options.wsUrl;
|
|
71
|
+
return this.baseUrl.replace(/\/api\/machines$/, "").replace(/^http/, "ws") + "/api/ws";
|
|
72
|
+
}
|
|
73
|
+
|
|
43
74
|
private async httpRequest(method: string, path: string, body?: unknown): Promise<unknown> {
|
|
44
75
|
const url = `${this.baseUrl}${path}`;
|
|
45
76
|
const res = await fetch(url, {
|
|
46
77
|
method,
|
|
47
|
-
headers: {
|
|
48
|
-
"Content-Type": "application/json",
|
|
49
|
-
Authorization: `Bearer ${this.options.apiKey}`,
|
|
50
|
-
},
|
|
78
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.options.apiKey}` },
|
|
51
79
|
body: body ? JSON.stringify(body) : undefined,
|
|
52
80
|
});
|
|
53
81
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
|
54
82
|
return res.json();
|
|
55
83
|
}
|
|
56
84
|
|
|
57
|
-
onAgentTask(handler: (agentId: string, task: AgentWork["task"]) => void) {
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
onAgentChat(handler: (agentId: string, messages: NonNullable<AgentWork["chat"]>) => void) {
|
|
62
|
-
this.chatHandler = handler;
|
|
63
|
-
}
|
|
85
|
+
onAgentTask(handler: (agentId: string, task: AgentWork["task"]) => void) { this.taskHandler = handler; }
|
|
86
|
+
onAgentChat(handler: (agentId: string, messages: NonNullable<AgentWork["chat"]>) => void) { this.chatHandler = handler; }
|
|
87
|
+
onConnected(handler: (machineId: string) => void) { this.connectHandler = handler; }
|
|
88
|
+
onDisconnected(handler: () => void) { this.disconnectHandler = handler; }
|
|
64
89
|
|
|
65
|
-
async sendAgentComplete(agentId: string, taskId: string, output: string) {
|
|
66
|
-
|
|
67
|
-
await this.httpRequest("POST", "/complete", { agentId, taskId, output });
|
|
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 });
|
|
68
92
|
}
|
|
69
93
|
|
|
70
94
|
async sendAgentFail(agentId: string, taskId: string, reason: string) {
|
|
@@ -79,43 +103,145 @@ export class ClawroomMachineClient {
|
|
|
79
103
|
await this.httpRequest("POST", "/typing", { agentId, channelId });
|
|
80
104
|
}
|
|
81
105
|
|
|
106
|
+
async ackAgentChat(agentId: string, messageIds: string[]) {
|
|
107
|
+
const ids = Array.from(new Set(messageIds.filter(Boolean)));
|
|
108
|
+
if (ids.length === 0) return;
|
|
109
|
+
await this.httpRequest("POST", "/chat-ack", { agentId, messageIds: ids });
|
|
110
|
+
}
|
|
111
|
+
|
|
82
112
|
get connected() { return this._connected; }
|
|
83
113
|
get stopped() { return this._stopped; }
|
|
84
114
|
|
|
115
|
+
private rememberChat(messageId: string): boolean {
|
|
116
|
+
if (!messageId) return true;
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
for (const [id, seenAt] of this.recentChatIds) {
|
|
119
|
+
if (now - seenAt > 10 * 60_000) this.recentChatIds.delete(id);
|
|
120
|
+
}
|
|
121
|
+
if (this.recentChatIds.has(messageId)) return false;
|
|
122
|
+
this.recentChatIds.set(messageId, now);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
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
|
+
private markConnected(machineId: string) {
|
|
141
|
+
if (!this._connected) {
|
|
142
|
+
this._connected = true;
|
|
143
|
+
this.options.log?.info?.("[machine] connected");
|
|
144
|
+
this.connectHandler?.(machineId);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private markDisconnected() {
|
|
149
|
+
if (this._connected) {
|
|
150
|
+
this._connected = false;
|
|
151
|
+
this.options.log?.warn?.("[machine] disconnected");
|
|
152
|
+
this.disconnectHandler?.();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private async pollOnce() {
|
|
157
|
+
if (!this._connected) return;
|
|
158
|
+
try {
|
|
159
|
+
const result = (await this.httpRequest("POST", "/poll", {})) as MachinePollResponse;
|
|
160
|
+
this.syncAgentSubscriptions(result.agents.map((agent) => agent.agentId));
|
|
161
|
+
for (const agent of result.agents) {
|
|
162
|
+
if (agent.task && this.taskHandler) this.taskHandler(agent.agentId, agent.task);
|
|
163
|
+
if (agent.chat && agent.chat.length > 0 && this.chatHandler) {
|
|
164
|
+
const freshMessages = agent.chat.filter((message) => this.rememberChat(message.messageId));
|
|
165
|
+
if (freshMessages.length > 0) {
|
|
166
|
+
this.chatHandler(agent.agentId, freshMessages);
|
|
167
|
+
this.ackAgentChatBestEffort(agent.agentId, freshMessages.map((message) => message.messageId));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} catch (err) {
|
|
172
|
+
this.options.log?.warn?.(`[machine] poll error: ${err}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
85
176
|
connect() {
|
|
86
177
|
this._stopped = false;
|
|
87
178
|
const hostname = this.options.hostname ?? os.hostname();
|
|
88
179
|
const hbBody = { hostname, capabilities: this.options.capabilities };
|
|
89
180
|
|
|
90
|
-
// Heartbeat every 30s
|
|
181
|
+
// Heartbeat every 30s (always HTTP)
|
|
91
182
|
this.heartbeatTimer = setInterval(async () => {
|
|
92
183
|
try {
|
|
93
|
-
await this.httpRequest("POST", "/heartbeat", hbBody);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
this.
|
|
184
|
+
const result = (await this.httpRequest("POST", "/heartbeat", hbBody)) as MachineHeartbeatResponse;
|
|
185
|
+
this.syncAgentSubscriptions((result.agents ?? []).map((agent) => agent.id));
|
|
186
|
+
this.markConnected(result.machineId);
|
|
187
|
+
} catch {
|
|
188
|
+
this.markDisconnected();
|
|
98
189
|
}
|
|
99
190
|
}, 30_000);
|
|
100
191
|
|
|
101
|
-
// Initial heartbeat
|
|
102
192
|
this.httpRequest("POST", "/heartbeat", hbBody)
|
|
103
|
-
.then(() => {
|
|
193
|
+
.then((result) => {
|
|
194
|
+
const data = result as MachineHeartbeatResponse;
|
|
195
|
+
this.syncAgentSubscriptions((data.agents ?? []).map((agent) => agent.id));
|
|
196
|
+
this.markConnected(data.machineId);
|
|
197
|
+
})
|
|
104
198
|
.catch((err) => this.options.log?.warn?.(`[machine] initial heartbeat failed: ${err}`));
|
|
105
199
|
|
|
106
|
-
//
|
|
107
|
-
this.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
200
|
+
// WebSocket transport
|
|
201
|
+
this.wsTransport = new WsTransport({
|
|
202
|
+
url: this.wsUrl,
|
|
203
|
+
token: this.options.apiKey,
|
|
204
|
+
log: { info: (m) => this.options.log?.info?.(m), warn: (m) => this.options.log?.warn?.(m) },
|
|
205
|
+
onConnected: () => {
|
|
206
|
+
this.syncAgentSubscriptions([...this.subscribedAgentIds]);
|
|
207
|
+
this.options.log?.info?.("[machine] WebSocket connected");
|
|
208
|
+
},
|
|
209
|
+
onDisconnected: () => {
|
|
210
|
+
this.options.log?.info?.("[machine] WebSocket disconnected, HTTP polling active");
|
|
211
|
+
},
|
|
212
|
+
onMessage: (msg) => {
|
|
213
|
+
if (msg.type === "message") {
|
|
214
|
+
const m = msg.message;
|
|
215
|
+
const targetAgentId = msg.agentId ?? m?.agentId;
|
|
216
|
+
const messageId = m?.id ?? msg.messageId ?? "";
|
|
217
|
+
if (m && targetAgentId && this.chatHandler && this.rememberChat(messageId)) {
|
|
218
|
+
const agentProfile = msg.agentProfile ?? m.agentProfile ?? DEFAULT_AGENT_CHAT_PROFILE;
|
|
219
|
+
const messages = [{
|
|
220
|
+
messageId,
|
|
221
|
+
channelId: m.channelId ?? "",
|
|
222
|
+
content: m.content ?? "",
|
|
223
|
+
isMention: msg.isMention ?? false,
|
|
224
|
+
context: msg.context ?? [],
|
|
225
|
+
agentProfile,
|
|
226
|
+
}];
|
|
227
|
+
this.chatHandler(targetAgentId, messages);
|
|
228
|
+
this.ackAgentChatBestEffort(targetAgentId, messages.map((message) => message.messageId));
|
|
229
|
+
}
|
|
114
230
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
231
|
+
if (msg.type === "task" && msg.agentId && this.taskHandler) {
|
|
232
|
+
this.taskHandler(msg.agentId, msg.task);
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
this.wsTransport.connect();
|
|
237
|
+
|
|
238
|
+
// Keep HTTP polling active even when WS is up.
|
|
239
|
+
// Machine WS chat delivery is best-effort; polling is the durable delivery path.
|
|
240
|
+
this.pollTimer = setInterval(async () => {
|
|
241
|
+
await this.pollOnce();
|
|
118
242
|
}, 10_000);
|
|
243
|
+
|
|
244
|
+
void this.pollOnce();
|
|
119
245
|
}
|
|
120
246
|
|
|
121
247
|
disconnect() {
|
|
@@ -123,5 +249,7 @@ export class ClawroomMachineClient {
|
|
|
123
249
|
this._connected = false;
|
|
124
250
|
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
125
251
|
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
252
|
+
this.wsTransport?.disconnect();
|
|
253
|
+
this.wsTransport = null;
|
|
126
254
|
}
|
|
127
255
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -1,83 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface AgentProgress {
|
|
24
|
-
type: "agent.progress";
|
|
25
|
-
taskId: string;
|
|
26
|
-
message: string;
|
|
27
|
-
percent?: number;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export interface AgentFail {
|
|
31
|
-
type: "agent.fail";
|
|
32
|
-
taskId: string;
|
|
33
|
-
reason: string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface AgentChatReply {
|
|
37
|
-
type: "agent.chat.reply";
|
|
38
|
-
channelId: string;
|
|
39
|
-
content: string;
|
|
40
|
-
replyTo?: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface AgentTyping {
|
|
44
|
-
type: "agent.typing";
|
|
45
|
-
channelId: string;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export type AgentMessage =
|
|
49
|
-
| AgentHeartbeat
|
|
50
|
-
| AgentComplete
|
|
51
|
-
| AgentProgress
|
|
52
|
-
| AgentFail
|
|
53
|
-
| AgentChatReply
|
|
54
|
-
| AgentTyping;
|
|
55
|
-
|
|
56
|
-
// ---- Server -> Agent ----
|
|
57
|
-
|
|
58
|
-
export interface ServerTask {
|
|
59
|
-
type: "server.task";
|
|
60
|
-
taskId: string;
|
|
61
|
-
title: string;
|
|
62
|
-
description: string;
|
|
63
|
-
input: string;
|
|
64
|
-
skillTags: string[];
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export interface ServerChatMessage {
|
|
68
|
-
messageId: string;
|
|
69
|
-
channelId: string;
|
|
70
|
-
content: string;
|
|
71
|
-
isMention: boolean;
|
|
72
|
-
context: Array<{
|
|
73
|
-
id: string;
|
|
74
|
-
senderType: string;
|
|
75
|
-
senderName: string;
|
|
76
|
-
content: string;
|
|
77
|
-
createdAt: number;
|
|
78
|
-
}>;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export type ServerMessage =
|
|
82
|
-
| ServerTask
|
|
83
|
-
| ServerChatMessage;
|
|
1
|
+
export type {
|
|
2
|
+
AgentChatProfile,
|
|
3
|
+
AgentHeartbeat,
|
|
4
|
+
AgentResultFile,
|
|
5
|
+
AgentComplete,
|
|
6
|
+
AgentProgress,
|
|
7
|
+
AgentFail,
|
|
8
|
+
AgentChatReply,
|
|
9
|
+
AgentTyping,
|
|
10
|
+
AgentMessage,
|
|
11
|
+
ServerTask,
|
|
12
|
+
ServerChatMessage,
|
|
13
|
+
ServerMessage,
|
|
14
|
+
RuntimeDef,
|
|
15
|
+
} from "@clawroom/protocol";
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
RUNTIMES,
|
|
19
|
+
BRIDGE_MANAGED_RUNTIME_IDS,
|
|
20
|
+
RUNTIME_MAP,
|
|
21
|
+
} from "@clawroom/protocol";
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared WebSocket transport with auto-reconnect and fallback support.
|
|
3
|
+
* Used by both ClawroomClient and ClawroomMachineClient.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type WsTransportOptions = {
|
|
7
|
+
url: string;
|
|
8
|
+
token: string;
|
|
9
|
+
onMessage: (msg: any) => void;
|
|
10
|
+
onConnected: () => void;
|
|
11
|
+
onDisconnected: () => void;
|
|
12
|
+
log?: {
|
|
13
|
+
info?: (message: string) => void;
|
|
14
|
+
warn?: (message: string) => void;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export class WsTransport {
|
|
19
|
+
private ws: any = null;
|
|
20
|
+
private options: WsTransportOptions;
|
|
21
|
+
private _connected = false;
|
|
22
|
+
private stopped = false;
|
|
23
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
24
|
+
private reconnectDelay = 1000;
|
|
25
|
+
private failCount = 0;
|
|
26
|
+
|
|
27
|
+
static readonly MAX_RECONNECT_DELAY = 60_000;
|
|
28
|
+
static readonly MAX_RECONNECT_FAILS = 10;
|
|
29
|
+
|
|
30
|
+
constructor(options: WsTransportOptions) {
|
|
31
|
+
this.options = options;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get connected() { return this._connected; }
|
|
35
|
+
|
|
36
|
+
async connect() {
|
|
37
|
+
if (this.stopped) return;
|
|
38
|
+
|
|
39
|
+
let WebSocket: any;
|
|
40
|
+
try {
|
|
41
|
+
WebSocket = (await import("ws")).default;
|
|
42
|
+
} catch {
|
|
43
|
+
this.options.log?.info?.("ws module not available, skipping WebSocket");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
this.ws = new WebSocket(this.options.url);
|
|
49
|
+
} catch {
|
|
50
|
+
this.scheduleReconnect();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.ws.on("open", () => {
|
|
55
|
+
this.reconnectDelay = 1000;
|
|
56
|
+
this.ws.send(JSON.stringify({ type: "auth", token: this.options.token }));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
this.ws.on("message", (raw: any) => {
|
|
60
|
+
let msg: any;
|
|
61
|
+
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
62
|
+
|
|
63
|
+
if (msg.type === "auth_ok") {
|
|
64
|
+
this._connected = true;
|
|
65
|
+
this.failCount = 0;
|
|
66
|
+
this.options.onConnected();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (msg.type === "auth_error") {
|
|
71
|
+
this._connected = false;
|
|
72
|
+
this.options.log?.warn?.(`WS auth failed: ${msg.error ?? "unknown"}`);
|
|
73
|
+
this.ws?.close();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (msg.type === "pong") return;
|
|
78
|
+
|
|
79
|
+
this.options.onMessage(msg);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
this.ws.on("close", () => {
|
|
83
|
+
const wasConnected = this._connected;
|
|
84
|
+
this._connected = false;
|
|
85
|
+
if (wasConnected) this.options.onDisconnected();
|
|
86
|
+
if (!this.stopped) this.scheduleReconnect();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
this.ws.on("error", () => {
|
|
90
|
+
this._connected = false;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
send(msg: any) {
|
|
95
|
+
if (this._connected && this.ws?.readyState === 1) {
|
|
96
|
+
this.ws.send(JSON.stringify(msg));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
disconnect() {
|
|
101
|
+
this.stopped = true;
|
|
102
|
+
this._connected = false;
|
|
103
|
+
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
|
|
104
|
+
if (this.ws) { this.ws.close(); this.ws = null; }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private scheduleReconnect() {
|
|
108
|
+
if (this.stopped || this.reconnectTimer) return;
|
|
109
|
+
|
|
110
|
+
this.failCount++;
|
|
111
|
+
if (this.failCount > WsTransport.MAX_RECONNECT_FAILS) {
|
|
112
|
+
this.options.log?.warn?.(`WS reconnect failed ${this.failCount} times, giving up. HTTP polling active.`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const jitter = Math.random() * 3000;
|
|
117
|
+
this.reconnectTimer = setTimeout(() => {
|
|
118
|
+
this.reconnectTimer = null;
|
|
119
|
+
this.connect();
|
|
120
|
+
}, Math.min(this.reconnectDelay + jitter, WsTransport.MAX_RECONNECT_DELAY));
|
|
121
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, WsTransport.MAX_RECONNECT_DELAY);
|
|
122
|
+
}
|
|
123
|
+
}
|