clawmatrix 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmatrix",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Decentralized mesh cluster plugin for OpenClaw — inter-gateway communication, model proxy, task handoff, and tool proxy.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,10 +31,12 @@
31
31
  "prepublishOnly": "bun test"
32
32
  },
33
33
  "dependencies": {
34
+ "ws": "^8.19.0",
34
35
  "zod": "^4.3.6"
35
36
  },
36
37
  "devDependencies": {
37
38
  "@types/bun": "latest",
39
+ "@types/ws": "^8.18.1",
38
40
  "openclaw": "^2026.3.2"
39
41
  },
40
42
  "peerDependencies": {
@@ -4,6 +4,7 @@ import type {
4
4
  PluginLogger,
5
5
  } from "openclaw/plugin-sdk";
6
6
  import type { ClawMatrixConfig } from "./config.ts";
7
+ import { spawnProcess } from "./compat.ts";
7
8
  import { PeerManager } from "./peer-manager.ts";
8
9
  import { HandoffManager } from "./handoff.ts";
9
10
  import { ModelProxy } from "./model-proxy.ts";
@@ -124,7 +125,7 @@ export class ClusterRuntime {
124
125
  }
125
126
 
126
127
  // Fire-and-forget: inject message via openclaw agent
127
- Bun.spawn(["openclaw", "agent", "--message", message], {
128
+ spawnProcess(["openclaw", "agent", "--message", message], {
128
129
  stdout: "ignore",
129
130
  stderr: "ignore",
130
131
  });
package/src/compat.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Runtime compatibility layer — provides Node.js-compatible alternatives
3
+ * for Bun-specific APIs so the plugin works inside the OpenClaw gateway
4
+ * (which runs on Node.js).
5
+ */
6
+
7
+ import { spawn as cpSpawn } from "node:child_process";
8
+ import { readFile, writeFile } from "node:fs/promises";
9
+
10
+ export interface SpawnResult {
11
+ exitCode: number;
12
+ stdout: string;
13
+ stderr: string;
14
+ }
15
+
16
+ /** Spawn a subprocess and collect stdout/stderr. */
17
+ export function spawnProcess(
18
+ cmd: string[],
19
+ opts?: { cwd?: string; stdout?: "pipe" | "ignore"; stderr?: "pipe" | "ignore" },
20
+ ): { exited: Promise<number>; stdout: ReadableStream | null; stderr: ReadableStream | null; kill: () => void } {
21
+ const child = cpSpawn(cmd[0]!, cmd.slice(1), {
22
+ cwd: opts?.cwd,
23
+ stdio: [
24
+ "ignore",
25
+ opts?.stdout === "ignore" ? "ignore" : "pipe",
26
+ opts?.stderr === "ignore" ? "ignore" : "pipe",
27
+ ],
28
+ });
29
+
30
+ const exited = new Promise<number>((resolve, reject) => {
31
+ child.on("close", (code) => resolve(code ?? 1));
32
+ child.on("error", reject);
33
+ });
34
+
35
+ function nodeStreamToWeb(stream: import("node:stream").Readable | null): ReadableStream | null {
36
+ if (!stream) return null;
37
+ return new ReadableStream({
38
+ start(controller) {
39
+ stream.on("data", (chunk: Buffer) => controller.enqueue(chunk));
40
+ stream.on("end", () => controller.close());
41
+ stream.on("error", (err) => controller.error(err));
42
+ },
43
+ });
44
+ }
45
+
46
+ return {
47
+ exited,
48
+ stdout: nodeStreamToWeb(child.stdout),
49
+ stderr: nodeStreamToWeb(child.stderr),
50
+ kill: () => child.kill(),
51
+ };
52
+ }
53
+
54
+ /** Read a file as text (replaces Bun.file().text()). */
55
+ export async function readFileText(path: string): Promise<string> {
56
+ return readFile(path, "utf-8");
57
+ }
58
+
59
+ /** Write text to a file (replaces Bun.write()). */
60
+ export async function writeFileText(path: string, content: string): Promise<void> {
61
+ await writeFile(path, content, "utf-8");
62
+ }
package/src/handoff.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { PeerManager } from "./peer-manager.ts";
2
2
  import type { ClawMatrixConfig } from "./config.ts";
3
+ import { spawnProcess } from "./compat.ts";
3
4
  import type {
4
5
  HandoffRequest,
5
6
  HandoffResponse,
@@ -147,16 +148,16 @@ export class HandoffManager {
147
148
  ? `${payload.task}\n\nContext:\n${payload.context}`
148
149
  : payload.task;
149
150
 
150
- const proc = Bun.spawn(["openclaw", "agent", "--message", message], {
151
+ const proc = spawnProcess(["openclaw", "agent", "--message", message], {
151
152
  stdout: "pipe",
152
153
  stderr: "pipe",
153
154
  });
154
155
 
155
- const stdout = await new Response(proc.stdout).text();
156
+ const stdout = proc.stdout ? await new Response(proc.stdout).text() : "";
156
157
  const exitCode = await proc.exited;
157
158
 
158
159
  if (exitCode !== 0) {
159
- const stderr = await new Response(proc.stderr).text();
160
+ const stderr = proc.stderr ? await new Response(proc.stderr).text() : "";
160
161
  throw new Error(`Agent exited with code ${exitCode}: ${stderr}`);
161
162
  }
162
163
 
@@ -1,3 +1,4 @@
1
+ import { createServer, type Server } from "node:http";
1
2
  import type { PeerManager } from "./peer-manager.ts";
2
3
  import type { ClawMatrixConfig } from "./config.ts";
3
4
  import type {
@@ -21,7 +22,7 @@ export class ModelProxy {
21
22
  private config: ClawMatrixConfig;
22
23
  private peerManager: PeerManager;
23
24
  private pending = new Map<string, PendingModelReq>();
24
- private httpServer: ReturnType<typeof Bun.serve> | null = null;
25
+ private httpServer: Server | null = null;
25
26
 
26
27
  constructor(config: ClawMatrixConfig, peerManager: PeerManager) {
27
28
  this.config = config;
@@ -30,26 +31,28 @@ export class ModelProxy {
30
31
 
31
32
  /** Start the local HTTP proxy server for OpenAI-compatible requests. */
32
33
  start() {
33
- this.httpServer = Bun.serve({
34
- port: this.config.proxyPort,
35
- hostname: "127.0.0.1",
36
- routes: {
37
- "/v1/chat/completions": {
38
- POST: async (req) => this.handleChatCompletion(req),
39
- },
40
- "/v1/models": {
41
- GET: () => this.handleListModels(),
42
- },
43
- },
44
- fetch() {
45
- return new Response("Not Found", { status: 404 });
46
- },
34
+ this.httpServer = createServer(async (req, res) => {
35
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
36
+
37
+ if (url.pathname === "/v1/chat/completions" && req.method === "POST") {
38
+ const body = await this.readBody(req);
39
+ const response = await this.handleChatCompletion(body);
40
+ this.sendResponse(res, response);
41
+ } else if (url.pathname === "/v1/models" && req.method === "GET") {
42
+ const response = this.handleListModels();
43
+ this.sendResponse(res, response);
44
+ } else {
45
+ res.writeHead(404, { "Content-Type": "text/plain" });
46
+ res.end("Not Found");
47
+ }
47
48
  });
49
+
50
+ this.httpServer.listen(this.config.proxyPort, "127.0.0.1");
48
51
  }
49
52
 
50
53
  stop() {
51
54
  if (this.httpServer) {
52
- this.httpServer.stop();
55
+ this.httpServer.close();
53
56
  this.httpServer = null;
54
57
  }
55
58
  for (const [, pending] of this.pending) {
@@ -59,8 +62,38 @@ export class ModelProxy {
59
62
  this.pending.clear();
60
63
  }
61
64
 
65
+ private readBody(req: import("node:http").IncomingMessage): Promise<string> {
66
+ return new Promise((resolve, reject) => {
67
+ const chunks: Buffer[] = [];
68
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
69
+ req.on("end", () => resolve(Buffer.concat(chunks).toString()));
70
+ req.on("error", reject);
71
+ });
72
+ }
73
+
74
+ private sendResponse(res: import("node:http").ServerResponse, response: { status: number; headers: Record<string, string>; body: string | ReadableStream }) {
75
+ res.writeHead(response.status, response.headers);
76
+ if (typeof response.body === "string") {
77
+ res.end(response.body);
78
+ } else {
79
+ // Stream response
80
+ const reader = response.body.getReader();
81
+ const pump = (): void => {
82
+ reader.read().then(({ done, value }) => {
83
+ if (done) {
84
+ res.end();
85
+ return;
86
+ }
87
+ res.write(value);
88
+ pump();
89
+ }).catch(() => res.end());
90
+ };
91
+ pump();
92
+ }
93
+ }
94
+
62
95
  // ── HTTP handlers ──────────────────────────────────────────────
63
- private async handleChatCompletion(req: Request): Promise<Response> {
96
+ private async handleChatCompletion(rawBody: string): Promise<{ status: number; headers: Record<string, string>; body: string | ReadableStream }> {
64
97
  let body: {
65
98
  model: string;
66
99
  messages: unknown[];
@@ -70,21 +103,23 @@ export class ModelProxy {
70
103
  };
71
104
 
72
105
  try {
73
- body = (await req.json()) as typeof body;
106
+ body = JSON.parse(rawBody);
74
107
  } catch {
75
- return new Response(JSON.stringify({ error: "Invalid JSON" }), {
108
+ return {
76
109
  status: 400,
77
110
  headers: { "Content-Type": "application/json" },
78
- });
111
+ body: JSON.stringify({ error: "Invalid JSON" }),
112
+ };
79
113
  }
80
114
 
81
115
  const modelId = body.model;
82
116
  const route = this.peerManager.router.resolveModel(modelId);
83
117
  if (!route) {
84
- return new Response(
85
- JSON.stringify({ error: { message: `Model "${modelId}" not found in cluster` } }),
86
- { status: 404, headers: { "Content-Type": "application/json" } },
87
- );
118
+ return {
119
+ status: 404,
120
+ headers: { "Content-Type": "application/json" },
121
+ body: JSON.stringify({ error: { message: `Model "${modelId}" not found in cluster` } }),
122
+ };
88
123
  }
89
124
 
90
125
  const stream = body.stream ?? false;
@@ -116,7 +151,7 @@ export class ModelProxy {
116
151
  requestId: string,
117
152
  targetNodeId: string,
118
153
  frame: ModelRequest,
119
- ): Response {
154
+ ): { status: number; headers: Record<string, string>; body: ReadableStream } {
120
155
  const encoder = new TextEncoder();
121
156
 
122
157
  const readable = new ReadableStream({
@@ -159,20 +194,22 @@ export class ModelProxy {
159
194
  },
160
195
  });
161
196
 
162
- return new Response(readable, {
197
+ return {
198
+ status: 200,
163
199
  headers: {
164
200
  "Content-Type": "text/event-stream",
165
201
  "Cache-Control": "no-cache",
166
- Connection: "keep-alive",
202
+ "Connection": "keep-alive",
167
203
  },
168
- });
204
+ body: readable,
205
+ };
169
206
  }
170
207
 
171
208
  private async handleNonStreamRequest(
172
209
  requestId: string,
173
210
  targetNodeId: string,
174
211
  frame: ModelRequest,
175
- ): Promise<Response> {
212
+ ): Promise<{ status: number; headers: Record<string, string>; body: string }> {
176
213
  try {
177
214
  const result = await new Promise<ModelResponse["payload"]>(
178
215
  (resolve, reject) => {
@@ -199,15 +236,17 @@ export class ModelProxy {
199
236
  );
200
237
 
201
238
  if (!result.success) {
202
- return new Response(
203
- JSON.stringify({ error: { message: result.error } }),
204
- { status: 502, headers: { "Content-Type": "application/json" } },
205
- );
239
+ return {
240
+ status: 502,
241
+ headers: { "Content-Type": "application/json" },
242
+ body: JSON.stringify({ error: { message: result.error } }),
243
+ };
206
244
  }
207
245
 
208
- // OpenAI-compatible response
209
- return new Response(
210
- JSON.stringify({
246
+ return {
247
+ status: 200,
248
+ headers: { "Content-Type": "application/json" },
249
+ body: JSON.stringify({
211
250
  id: `chatcmpl-${requestId}`,
212
251
  object: "chat.completion",
213
252
  created: Math.floor(Date.now() / 1000),
@@ -227,17 +266,17 @@ export class ModelProxy {
227
266
  }
228
267
  : undefined,
229
268
  }),
230
- { headers: { "Content-Type": "application/json" } },
231
- );
269
+ };
232
270
  } catch (err) {
233
- return new Response(
234
- JSON.stringify({ error: { message: err instanceof Error ? err.message : String(err) } }),
235
- { status: 502, headers: { "Content-Type": "application/json" } },
236
- );
271
+ return {
272
+ status: 502,
273
+ headers: { "Content-Type": "application/json" },
274
+ body: JSON.stringify({ error: { message: err instanceof Error ? err.message : String(err) } }),
275
+ };
237
276
  }
238
277
  }
239
278
 
240
- private handleListModels(): Response {
279
+ private handleListModels(): { status: number; headers: Record<string, string>; body: string } {
241
280
  const models = this.peerManager.router
242
281
  .getAllPeers()
243
282
  .flatMap((p) =>
@@ -249,10 +288,11 @@ export class ModelProxy {
249
288
  })),
250
289
  );
251
290
 
252
- return new Response(
253
- JSON.stringify({ object: "list", data: models }),
254
- { headers: { "Content-Type": "application/json" } },
255
- );
291
+ return {
292
+ status: 200,
293
+ headers: { "Content-Type": "application/json" },
294
+ body: JSON.stringify({ object: "list", data: models }),
295
+ };
256
296
  }
257
297
 
258
298
  // ── Incoming frame handlers ────────────────────────────────────
@@ -273,7 +313,6 @@ export class ModelProxy {
273
313
 
274
314
  try {
275
315
  if (frame.payload.done) {
276
- // Send final chunk with usage if available
277
316
  if (frame.payload.usage) {
278
317
  const usageChunk = {
279
318
  id: `chatcmpl-${frame.id}`,
@@ -310,7 +349,6 @@ export class ModelProxy {
310
349
  );
311
350
  }
312
351
  } catch {
313
- // controller may be closed
314
352
  clearTimeout(pending.timer);
315
353
  this.pending.delete(frame.id);
316
354
  }
@@ -320,7 +358,6 @@ export class ModelProxy {
320
358
  async handleModelRequest(frame: ModelRequest): Promise<void> {
321
359
  const { id, from, payload } = frame;
322
360
 
323
- // Find the model in our local config
324
361
  const model = this.config.models.find((m) => m.id === payload.model);
325
362
  if (!model) {
326
363
  this.peerManager.sendTo(from, {
@@ -335,9 +372,6 @@ export class ModelProxy {
335
372
  }
336
373
 
337
374
  try {
338
- // Use OpenClaw's API to run the model locally
339
- // This is done by calling the local gateway's /v1/chat/completions endpoint
340
- // The local OpenClaw gateway handles provider routing
341
375
  const localUrl = `http://127.0.0.1:${process.env.OPENCLAW_GATEWAY_PORT ?? 3000}/v1/chat/completions`;
342
376
 
343
377
  const response = await fetch(localUrl, {
@@ -353,7 +387,6 @@ export class ModelProxy {
353
387
  });
354
388
 
355
389
  if (payload.stream) {
356
- // Read SSE stream and forward as model_stream frames
357
390
  const reader = response.body?.getReader();
358
391
  if (!reader) throw new Error("No response body");
359
392
 
@@ -1,5 +1,6 @@
1
1
  import { EventEmitter } from "node:events";
2
- import type { ServerWebSocket } from "bun";
2
+ import { createServer, type IncomingMessage, type Server } from "node:http";
3
+ import { WebSocketServer, WebSocket as WsWebSocket } from "ws";
3
4
  import type { ClawMatrixConfig, PeerConfig } from "./config.ts";
4
5
  import { Connection } from "./connection.ts";
5
6
  import type { WsTransport } from "./connection.ts";
@@ -25,12 +26,13 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
25
26
  readonly router: Router;
26
27
  private config: ClawMatrixConfig;
27
28
  private localCapabilities: NodeCapabilities;
28
- private wsServer: ReturnType<typeof Bun.serve> | null = null;
29
+ private httpServer: Server | null = null;
30
+ private wss: WebSocketServer | null = null;
29
31
  private reconnectTimers = new Map<string, ReturnType<typeof setTimeout>>();
30
32
  private reconnectAttempts = new Map<string, number>();
31
33
  private stopped = false;
32
- /** Map from ServerWebSocket to Connection for inbound connections. */
33
- private inboundConnections = new Map<ServerWebSocket<unknown>, Connection>();
34
+ /** Map from ws WebSocket to Connection for inbound connections. */
35
+ private inboundConnections = new Map<WsWebSocket, Connection>();
34
36
 
35
37
  constructor(config: ClawMatrixConfig) {
36
38
  super();
@@ -76,50 +78,52 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
76
78
  conn.close(1000, "shutdown");
77
79
  }
78
80
 
79
- if (this.wsServer) {
80
- this.wsServer.stop();
81
- this.wsServer = null;
81
+ if (this.wss) {
82
+ this.wss.close();
83
+ this.wss = null;
84
+ }
85
+ if (this.httpServer) {
86
+ this.httpServer.close();
87
+ this.httpServer = null;
82
88
  }
83
89
  }
84
90
 
85
- // ── Inbound WS server (Bun.serve) ─────────────────────────────
91
+ // ── Inbound WS server (node:http + ws) ──────────────────────────
86
92
  private startListening() {
87
93
  const port = this.config.listenPort;
88
94
  const hostname = this.config.listenHost;
89
- const self = this;
90
-
91
- this.wsServer = Bun.serve<undefined>({
92
- port,
93
- hostname,
94
- fetch(req, server) {
95
- if (server.upgrade(req)) {
96
- return undefined;
95
+
96
+ this.httpServer = createServer((_req, res) => {
97
+ res.writeHead(426, { "Content-Type": "text/plain" });
98
+ res.end("WebSocket upgrade required");
99
+ });
100
+
101
+ this.wss = new WebSocketServer({ server: this.httpServer });
102
+
103
+ this.wss.on("connection", (ws) => {
104
+ this.handleInboundOpen(ws);
105
+
106
+ ws.on("message", (data) => {
107
+ const conn = this.inboundConnections.get(ws);
108
+ if (conn) {
109
+ conn.feedMessage(typeof data === "string" ? data : String(data));
97
110
  }
98
- return new Response("WebSocket upgrade required", { status: 426 });
99
- },
100
- websocket: {
101
- open(ws) {
102
- self.handleInboundOpen(ws);
103
- },
104
- message(ws, message) {
105
- const conn = self.inboundConnections.get(ws);
106
- if (conn) {
107
- conn.feedMessage(typeof message === "string" ? message : Buffer.from(message));
108
- }
109
- },
110
- close(ws, code, reason) {
111
- const conn = self.inboundConnections.get(ws);
112
- if (conn) {
113
- conn.feedClose(code, reason);
114
- self.inboundConnections.delete(ws);
115
- }
116
- },
117
- },
111
+ });
112
+
113
+ ws.on("close", (code, reason) => {
114
+ const conn = this.inboundConnections.get(ws);
115
+ if (conn) {
116
+ conn.feedClose(code, reason.toString());
117
+ this.inboundConnections.delete(ws);
118
+ }
119
+ });
118
120
  });
121
+
122
+ this.httpServer.listen(port, hostname);
119
123
  }
120
124
 
121
- private handleInboundOpen(ws: ServerWebSocket<unknown>) {
122
- // Wrap Bun's ServerWebSocket into our WsTransport interface
125
+ private handleInboundOpen(ws: WsWebSocket) {
126
+ // Wrap ws WebSocket into our WsTransport interface
123
127
  const transport: WsTransport = {
124
128
  send(data: string) {
125
129
  ws.send(data);
package/src/tool-proxy.ts CHANGED
@@ -2,6 +2,7 @@ import { readdir, stat } from "node:fs/promises";
2
2
  import { join, resolve, normalize } from "node:path";
3
3
  import type { PeerManager } from "./peer-manager.ts";
4
4
  import type { ClawMatrixConfig, ToolProxyConfig } from "./config.ts";
5
+ import { spawnProcess, readFileText, writeFileText } from "./compat.ts";
5
6
  import type { ToolProxyRequest, ToolProxyResponse } from "./types.ts";
6
7
 
7
8
  const TOOL_TIMEOUT = 30_000;
@@ -198,7 +199,7 @@ export class ToolProxy {
198
199
  const cwd = (params.cwd as string) ?? process.cwd();
199
200
  const timeout = (params.timeout as number) ?? TOOL_TIMEOUT;
200
201
 
201
- const proc = Bun.spawn(["sh", "-c", command], {
202
+ const proc = spawnProcess(["sh", "-c", command], {
202
203
  cwd,
203
204
  stdout: "pipe",
204
205
  stderr: "pipe",
@@ -207,8 +208,8 @@ export class ToolProxy {
207
208
  const timeoutId = setTimeout(() => proc.kill(), timeout);
208
209
 
209
210
  const [stdout, stderr] = await Promise.all([
210
- new Response(proc.stdout).text(),
211
- new Response(proc.stderr).text(),
211
+ proc.stdout ? new Response(proc.stdout).text() : "",
212
+ proc.stderr ? new Response(proc.stderr).text() : "",
212
213
  ]);
213
214
  const exitCode = await proc.exited;
214
215
  clearTimeout(timeoutId);
@@ -229,8 +230,7 @@ export class ToolProxy {
229
230
  if (!path) throw new Error("Missing path");
230
231
  this.validatePath(path, tpConfig);
231
232
 
232
- const file = Bun.file(path);
233
- const content = await file.text();
233
+ const content = await readFileText(path);
234
234
  return {
235
235
  content: content.slice(0, tpConfig.maxOutputBytes),
236
236
  };
@@ -245,7 +245,7 @@ export class ToolProxy {
245
245
  if (!path || content === undefined) throw new Error("Missing path or content");
246
246
  this.validatePath(path, tpConfig);
247
247
 
248
- await Bun.write(path, content);
248
+ await writeFileText(path, content);
249
249
  return { success: true };
250
250
  }
251
251