clawmatrix 0.1.8 → 0.1.11

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.8",
3
+ "version": "0.1.11",
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",
@@ -28,7 +28,11 @@
28
28
  },
29
29
  "scripts": {
30
30
  "test": "bun test",
31
- "prepublishOnly": "bun test"
31
+ "prepublishOnly": "bun test",
32
+ "release": "bun scripts/release.ts",
33
+ "release:minor": "bun scripts/release.ts minor",
34
+ "release:major": "bun scripts/release.ts major",
35
+ "release:dry": "bun scripts/release.ts --dry-run"
32
36
  },
33
37
  "dependencies": {
34
38
  "ws": "^8.19.0",
package/src/cli.ts CHANGED
@@ -62,21 +62,32 @@ export const registerClusterCli = ({ program }: { program: Command }) => {
62
62
  return;
63
63
  }
64
64
 
65
+ // Style helpers
66
+ const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
67
+ const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;
68
+ const green = (s: string) => `\x1b[32m${s}\x1b[39m`;
69
+ const red = (s: string) => `\x1b[31m${s}\x1b[39m`;
70
+ const cyan = (s: string) => `\x1b[36m${s}\x1b[39m`;
71
+ const yellow = (s: string) => `\x1b[33m${s}\x1b[39m`;
72
+ const bar = dim("│");
73
+ const lbl = (text: string) => dim(text.padEnd(13));
74
+
65
75
  const agents = data.agents as Array<{ id: string }>;
66
76
  const models = data.models as Array<{ id: string }>;
67
77
  const tags = data.tags as string[];
68
78
 
69
- console.log("");
70
- console.log("ClawMatrix Cluster");
71
- console.log("");
72
- console.log(` Node: ${data.nodeId}`);
79
+ // Local node
80
+ console.log();
81
+ console.log(` ${cyan("")} ${bold("ClawMatrix Cluster")}`);
82
+ console.log(` ${bar}`);
83
+ console.log(` ${bar} ${lbl("Node")}${bold(String(data.nodeId))}`);
73
84
  if (tags.length > 0) {
74
- console.log(` Tags: ${tags.join(", ")}`);
85
+ console.log(` ${bar} ${lbl("Tags")}${tags.join(dim(", "))}`);
75
86
  }
76
- console.log(` Listen: ${data.listen !== false ? `port ${data.listen}` : "disabled"}`);
77
- console.log(` Model proxy: port ${data.proxyPort}`);
78
- console.log(` Agents: ${agents.map((a) => a.id).join(", ") || "-"}`);
79
- console.log(` Models: ${models.map((m) => m.id).join(", ") || "-"}`);
87
+ console.log(` ${bar} ${lbl("Listen")}${data.listen !== false ? `:${data.listen}` : dim("disabled")}`);
88
+ console.log(` ${bar} ${lbl("Model Proxy")}:${data.proxyPort}`);
89
+ console.log(` ${bar} ${lbl("Agents")}${agents.map((a) => a.id).join(dim(", ")) || dim("")}`);
90
+ console.log(` ${bar} ${lbl("Models")}${models.map((m) => m.id).join(dim(", ")) || dim("")}`);
80
91
 
81
92
  const peers = data.peers as Array<{
82
93
  nodeId: string;
@@ -87,34 +98,48 @@ export const registerClusterCli = ({ program }: { program: Command }) => {
87
98
  latencyMs: number;
88
99
  }>;
89
100
 
90
- const connected = peers.filter((p) => p.connected).length;
91
- console.log("");
92
101
  if (!peers || peers.length === 0) {
93
- console.log(" No peers discovered.");
94
- console.log("");
102
+ console.log(` ${bar}`);
103
+ console.log(` ${dim("")} ${dim("No peers discovered")}`);
104
+ console.log();
95
105
  return;
96
106
  }
97
107
 
98
- console.log(`Peers (${connected}/${peers.length} connected)`);
99
- console.log("");
108
+ const connected = peers.filter((p) => p.connected).length;
109
+ const countStr = `${connected}/${peers.length} connected`;
110
+ const countColor = connected === peers.length ? green : connected > 0 ? yellow : red;
111
+
112
+ console.log(` ${bar}`);
113
+ console.log(` ${cyan("◆")} ${bold("Peers")} ${countColor(countStr)}`);
114
+ console.log(` ${bar}`);
115
+
116
+ for (let i = 0; i < peers.length; i++) {
117
+ const peer = peers[i];
118
+ const dot = peer.connected ? green("●") : red("○");
119
+ const latency = peer.connected && peer.latencyMs > 0 ? dim(` ${peer.latencyMs}ms`) : "";
120
+ const status = peer.connected ? "" : red(" disconnected");
121
+ console.log(` ${bar} ${dot} ${bold(peer.nodeId)}${status}${latency}`);
100
122
 
101
- for (const peer of peers) {
102
- const status = peer.connected ? "connected" : "unreachable";
103
- const latency = peer.latencyMs > 0 ? `, ${peer.latencyMs}ms` : "";
104
- console.log(` ${peer.nodeId} (${status}${latency})`);
105
123
  if (peer.tags.length > 0) {
106
- console.log(` Tags: ${peer.tags.join(", ")}`);
124
+ console.log(` ${bar} ${lbl("Tags")}${peer.tags.join(dim(", "))}`);
107
125
  }
108
- const peerAgents = peer.agents.map((a) => a.id).join(", ");
126
+ const peerAgents = peer.agents.map((a) => a.id).join(dim(", "));
109
127
  if (peerAgents) {
110
- console.log(` Agents: ${peerAgents}`);
128
+ console.log(` ${bar} ${lbl("Agents")}${peerAgents}`);
111
129
  }
112
- const peerModels = peer.models.map((m) => m.id).join(", ");
130
+ const peerModels = peer.models.map((m) => m.id).join(dim(", "));
113
131
  if (peerModels) {
114
- console.log(` Models: ${peerModels}`);
132
+ console.log(` ${bar} ${lbl("Models")}${peerModels}`);
133
+ }
134
+
135
+ if (i < peers.length - 1) {
136
+ console.log(` ${bar}`);
115
137
  }
116
138
  }
117
- console.log("");
139
+
140
+ console.log(` ${bar}`);
141
+ console.log(` ${dim("◇")}`);
142
+ console.log();
118
143
  });
119
144
 
120
145
  cmd
@@ -6,6 +6,7 @@ import type {
6
6
  } from "openclaw/plugin-sdk";
7
7
  import type { ClawMatrixConfig } from "./config.ts";
8
8
  import { spawnProcess } from "./compat.ts";
9
+ import { debug } from "./debug.ts";
9
10
  import { PeerManager } from "./peer-manager.ts";
10
11
  import { HandoffManager } from "./handoff.ts";
11
12
  import { ModelProxy } from "./model-proxy.ts";
@@ -88,6 +89,9 @@ export class ClusterRuntime {
88
89
  }
89
90
 
90
91
  private dispatchFrame(frame: AnyClusterFrame) {
92
+ if (frame.type.startsWith("model_")) {
93
+ debug("dispatch", `${frame.type} id=${frame.id} from=${(frame as ClusterFrame).from}`);
94
+ }
91
95
  switch (frame.type) {
92
96
  case "handoff_req":
93
97
  this.handoffManager.handleRequest(frame as HandoffRequest).catch((err) => {
@@ -143,10 +147,13 @@ export class ClusterRuntime {
143
147
  }
144
148
 
145
149
  // Fire-and-forget: inject message via openclaw agent CLI
146
- spawnProcess(["openclaw", "agent", "--agent", agent.id, "--message", message], {
150
+ const proc = spawnProcess(["openclaw", "agent", "--agent", agent.id, "--message", message], {
147
151
  stdout: "ignore",
148
152
  stderr: "ignore",
149
153
  });
154
+ proc.exited.catch((err) => {
155
+ this.logger.error(`[clawmatrix] Failed to inject message to agent "${agent.id}": ${err}`);
156
+ });
150
157
  }
151
158
  }
152
159
 
package/src/config.ts CHANGED
@@ -6,6 +6,21 @@ const AgentInfoSchema = z.object({
6
6
  tags: z.array(z.string()).default([]),
7
7
  });
8
8
 
9
+ const ModelCompatSchema = z.object({
10
+ supportsTools: z.boolean().optional(),
11
+ supportsStore: z.boolean().optional(),
12
+ supportsDeveloperRole: z.boolean().optional(),
13
+ supportsReasoningEffort: z.boolean().optional(),
14
+ supportsUsageInStreaming: z.boolean().optional(),
15
+ supportsStrictMode: z.boolean().optional(),
16
+ maxTokensField: z.enum(["max_completion_tokens", "max_tokens"]).optional(),
17
+ thinkingFormat: z.enum(["openai", "zai", "qwen"]).optional(),
18
+ requiresToolResultName: z.boolean().optional(),
19
+ requiresAssistantAfterToolResult: z.boolean().optional(),
20
+ requiresThinkingAsText: z.boolean().optional(),
21
+ requiresMistralToolIds: z.boolean().optional(),
22
+ }).optional();
23
+
9
24
  const ModelParamsSchema = {
10
25
  api: z.enum([
11
26
  "openai-completions", "openai-responses", "openai-codex-responses",
@@ -22,6 +37,7 @@ const ModelParamsSchema = {
22
37
  cacheRead: z.number(),
23
38
  cacheWrite: z.number(),
24
39
  }).optional(),
40
+ compat: ModelCompatSchema,
25
41
  };
26
42
 
27
43
  const ModelInfoSchema = z.object({
package/src/connection.ts CHANGED
@@ -284,6 +284,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
284
284
  // already closed
285
285
  }
286
286
  this.emit("close", code, reason);
287
+ this.removeAllListeners();
287
288
  }
288
289
 
289
290
  get isOpen(): boolean {
package/src/debug.ts ADDED
@@ -0,0 +1,5 @@
1
+ const enabled = process.env.CLAWMATRIX_DEBUG !== "0";
2
+
3
+ export function debug(tag: string, msg: string) {
4
+ if (enabled) console.error(`[clawmatrix:${tag}] ${msg}`);
5
+ }
package/src/index.ts CHANGED
@@ -45,20 +45,35 @@ const plugin = {
45
45
  const baseUrl = `http://127.0.0.1:${config.proxyPort}/v1`;
46
46
  const modelsByNode = groupModelsByNode(config);
47
47
 
48
- // Patch openclaw config so auth resolution can find a dummy API key
49
- // (resolveApiKeyForProvider checks cfg.models.providers, not plugin registry)
50
- const ocModels = ((api.config as Record<string, unknown>).models ??= {}) as Record<string, unknown>;
51
- const ocProviders = (ocModels.providers ??= {}) as Record<string, unknown>;
48
+ // Patch openclaw config so auth resolution can find a dummy API key.
49
+ // resolveApiKeyForProvider checks cfg.models.providers, not the plugin registry.
50
+ // We must patch BOTH api.config (cfgAtStart) AND the runtimeConfigSnapshot
51
+ // (returned by loadConfig()) because activateSecretsRuntimeSnapshot clones the
52
+ // config before plugins load, so api.config and the snapshot are separate objects.
53
+ const patchProviders = (cfg: Record<string, unknown>) => {
54
+ const models = ((cfg).models ??= {}) as Record<string, unknown>;
55
+ const providers = (models.providers ??= {}) as Record<string, unknown>;
56
+ for (const [nodeId, nodeModels] of Object.entries(modelsByNode)) {
57
+ if (!providers[nodeId]) {
58
+ providers[nodeId] = { baseUrl, apiKey: "sk-clawmatrix-proxy", api: "openai-completions", models: nodeModels };
59
+ }
60
+ }
61
+ };
52
62
 
53
- for (const [nodeId, models] of Object.entries(modelsByNode)) {
54
- if (!ocProviders[nodeId]) {
55
- ocProviders[nodeId] = {
56
- baseUrl,
57
- apiKey: "sk-clawmatrix-proxy",
58
- models,
59
- };
63
+ patchProviders(api.config as Record<string, unknown>);
64
+
65
+ // Also patch the runtime config snapshot (loadConfig returns it by reference).
66
+ // api.runtime.config.loadConfig() returns runtimeConfigSnapshot directly.
67
+ try {
68
+ const snapshot = api.runtime.config.loadConfig();
69
+ if (snapshot && snapshot !== api.config) {
70
+ patchProviders(snapshot as Record<string, unknown>);
60
71
  }
72
+ } catch {
73
+ // Best-effort; api.config patch is the fallback
74
+ }
61
75
 
76
+ for (const [nodeId, models] of Object.entries(modelsByNode)) {
62
77
  api.registerProvider({
63
78
  id: nodeId,
64
79
  label: `ClawMatrix: ${nodeId}`,
@@ -186,8 +201,8 @@ const plugin = {
186
201
  },
187
202
  };
188
203
 
189
- function formatModel(m: { id: string; description?: string; api?: string; reasoning?: boolean; input?: string[]; cost?: { input: number; output: number; cacheRead: number; cacheWrite: number }; contextWindow?: number; maxTokens?: number }) {
190
- return {
204
+ function formatModel(m: { id: string; description?: string; api?: string; reasoning?: boolean; input?: string[]; cost?: { input: number; output: number; cacheRead: number; cacheWrite: number }; contextWindow?: number; maxTokens?: number; compat?: Record<string, unknown> }) {
205
+ const result: Record<string, unknown> = {
191
206
  id: m.id,
192
207
  name: m.description ?? m.id,
193
208
  api: m.api ?? ("openai-completions" as const),
@@ -197,6 +212,8 @@ function formatModel(m: { id: string; description?: string; api?: string; reason
197
212
  contextWindow: m.contextWindow ?? 128_000,
198
213
  maxTokens: m.maxTokens ?? 4096,
199
214
  };
215
+ if (m.compat) result.compat = m.compat;
216
+ return result;
200
217
  }
201
218
 
202
219
  function groupModelsByNode(config: ReturnType<typeof parseConfig>): Record<string, ReturnType<typeof formatModel>[]> {
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Local tool execution for exec/read/write/edit.
3
+ *
4
+ * These tools cannot go through the Gateway HTTP `/tools/invoke` endpoint
5
+ * because it only exposes `createOpenClawTools()` (safe tools), not
6
+ * `createOpenClawCodingTools()` (which includes exec/read/write/edit).
7
+ *
8
+ * Instead, we execute them directly in the same Node.js process.
9
+ * - exec: simplified child_process.spawn (no sandbox/PTY/background)
10
+ * - read/write/edit: reuse @mariozechner/pi-coding-agent factories
11
+ */
12
+
13
+ import { spawn } from "node:child_process";
14
+ import {
15
+ createReadTool,
16
+ createWriteTool,
17
+ createEditTool,
18
+ } from "@mariozechner/pi-coding-agent";
19
+
20
+ // ── Types ──────────────────────────────────────────────────────────
21
+
22
+ interface ExecParams {
23
+ command: string;
24
+ workdir?: string;
25
+ env?: Record<string, string>;
26
+ timeout?: number;
27
+ }
28
+
29
+ type ToolResult = Record<string, unknown>;
30
+
31
+ // ── Constants ──────────────────────────────────────────────────────
32
+
33
+ const LOCAL_TOOLS = new Set(["exec", "read", "write", "edit"]);
34
+ const DEFAULT_EXEC_TIMEOUT = 300; // seconds
35
+ const MAX_OUTPUT_BYTES = 512 * 1024; // 512KB
36
+
37
+ // ── Public API ─────────────────────────────────────────────────────
38
+
39
+ export function isLocalTool(tool: string): boolean {
40
+ return LOCAL_TOOLS.has(tool);
41
+ }
42
+
43
+ export async function executeLocally(
44
+ tool: string,
45
+ params: Record<string, unknown>,
46
+ ): Promise<ToolResult> {
47
+ switch (tool) {
48
+ case "exec":
49
+ return executeExec(params as unknown as ExecParams);
50
+ case "read":
51
+ return executePiTool("read", params);
52
+ case "write":
53
+ return executePiTool("write", params);
54
+ case "edit":
55
+ return executePiTool("edit", params);
56
+ default:
57
+ throw new Error(`Unknown local tool: ${tool}`);
58
+ }
59
+ }
60
+
61
+ // ── exec: simplified child_process.spawn ───────────────────────────
62
+
63
+ async function executeExec(params: ExecParams): Promise<ToolResult> {
64
+ const { command, workdir, env, timeout } = params;
65
+ if (!command) throw new Error("exec: command is required");
66
+
67
+ const shell = process.env.SHELL || "/bin/bash";
68
+ const timeoutMs = (timeout ?? DEFAULT_EXEC_TIMEOUT) * 1000;
69
+
70
+ return new Promise<ToolResult>((resolve, reject) => {
71
+ const child = spawn(shell, ["-c", command], {
72
+ cwd: workdir || process.cwd(),
73
+ env: env ? { ...process.env, ...env } : process.env,
74
+ stdio: ["ignore", "pipe", "pipe"],
75
+ detached: true,
76
+ });
77
+
78
+ let stdout = "";
79
+ let stderr = "";
80
+ let killed = false;
81
+
82
+ child.stdout?.on("data", (chunk: Buffer) => {
83
+ stdout += chunk.toString();
84
+ if (stdout.length > MAX_OUTPUT_BYTES) {
85
+ stdout = stdout.slice(0, MAX_OUTPUT_BYTES);
86
+ }
87
+ });
88
+
89
+ child.stderr?.on("data", (chunk: Buffer) => {
90
+ stderr += chunk.toString();
91
+ if (stderr.length > MAX_OUTPUT_BYTES) {
92
+ stderr = stderr.slice(0, MAX_OUTPUT_BYTES);
93
+ }
94
+ });
95
+
96
+ const timer = setTimeout(() => {
97
+ killed = true;
98
+ // Kill the process group
99
+ try {
100
+ process.kill(-child.pid!, "SIGKILL");
101
+ } catch {
102
+ child.kill("SIGKILL");
103
+ }
104
+ }, timeoutMs);
105
+
106
+ child.on("close", (code, signal) => {
107
+ clearTimeout(timer);
108
+ const truncated =
109
+ stdout.length >= MAX_OUTPUT_BYTES || stderr.length >= MAX_OUTPUT_BYTES;
110
+ resolve({
111
+ exitCode: code ?? 1,
112
+ stdout,
113
+ stderr,
114
+ ...(signal && { signal }),
115
+ ...(killed && { timedOut: true }),
116
+ ...(truncated && { truncated: true }),
117
+ });
118
+ });
119
+
120
+ child.on("error", (err) => {
121
+ clearTimeout(timer);
122
+ reject(new Error(`exec: failed to spawn: ${err.message}`));
123
+ });
124
+ });
125
+ }
126
+
127
+ // ── read/write/edit: reuse pi-coding-agent factories ───────────────
128
+
129
+ const piToolCache = new Map<string, { execute: Function }>();
130
+
131
+ function getPiTool(name: string): { execute: Function } {
132
+ let tool = piToolCache.get(name);
133
+ if (tool) return tool;
134
+
135
+ const cwd = process.cwd();
136
+ switch (name) {
137
+ case "read":
138
+ tool = createReadTool(cwd);
139
+ break;
140
+ case "write":
141
+ tool = createWriteTool(cwd);
142
+ break;
143
+ case "edit":
144
+ tool = createEditTool(cwd);
145
+ break;
146
+ default:
147
+ throw new Error(`Unknown pi tool: ${name}`);
148
+ }
149
+
150
+ piToolCache.set(name, tool);
151
+ return tool;
152
+ }
153
+
154
+ async function executePiTool(
155
+ name: string,
156
+ params: Record<string, unknown>,
157
+ ): Promise<ToolResult> {
158
+ const tool = getPiTool(name);
159
+ const toolCallId = crypto.randomUUID();
160
+
161
+ const result = (await tool.execute(toolCallId, params)) as {
162
+ content: Array<{ type: string; text?: string }>;
163
+ details: unknown;
164
+ };
165
+
166
+ // Flatten content array to a single text result
167
+ const text = result.content
168
+ .filter((c) => c.type === "text" && c.text)
169
+ .map((c) => c.text)
170
+ .join("\n");
171
+
172
+ return {
173
+ content: text,
174
+ details: result.details,
175
+ };
176
+ }
@@ -7,6 +7,7 @@ import type {
7
7
  ModelResponse,
8
8
  ModelStreamChunk,
9
9
  } from "./types.ts";
10
+ import { debug } from "./debug.ts";
10
11
 
11
12
  const MODEL_TIMEOUT = 120_000; // 2 minutes
12
13
 
@@ -35,21 +36,37 @@ export class ModelProxy {
35
36
  /** Start the local HTTP proxy server for OpenAI-compatible requests. */
36
37
  start() {
37
38
  this.httpServer = createServer(async (req, res) => {
38
- const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
39
-
40
- if (url.pathname === "/v1/chat/completions" && req.method === "POST") {
41
- const body = await this.readBody(req);
42
- const response = await this.handleChatCompletion(body);
43
- this.sendResponse(res, response);
44
- } else if (url.pathname === "/v1/models" && req.method === "GET") {
45
- const response = this.handleListModels();
46
- this.sendResponse(res, response);
47
- } else {
48
- res.writeHead(404, { "Content-Type": "text/plain" });
49
- res.end("Not Found");
39
+ try {
40
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
41
+
42
+ const p = url.pathname.replace(/^\/v1/, "");
43
+ debug("proxy", `${req.method} ${url.pathname} ${p}`);
44
+
45
+ if (p === "/chat/completions" && req.method === "POST") {
46
+ const body = await this.readBody(req);
47
+ const response = await this.handleChatCompletion(body);
48
+ debug("proxy", `response status=${response.status}`);
49
+ this.sendResponse(res, response);
50
+ } else if (p === "/models" && req.method === "GET") {
51
+ const response = this.handleListModels();
52
+ this.sendResponse(res, response);
53
+ } else {
54
+ res.writeHead(404, { "Content-Type": "application/json" });
55
+ res.end(JSON.stringify({ error: { message: `No handler for ${req.method} ${url.pathname}` } }));
56
+ }
57
+ } catch {
58
+ if (!res.headersSent) {
59
+ res.writeHead(500, { "Content-Type": "application/json" });
60
+ }
61
+ res.end(JSON.stringify({ error: { message: "Internal proxy error" } }));
50
62
  }
51
63
  });
52
64
 
65
+ this.httpServer.on("error", (err) => {
66
+ // Log but don't crash — port conflict or other server error
67
+ console.error(`[clawmatrix] Model proxy server error: ${err.message}`);
68
+ });
69
+
53
70
  this.httpServer.listen(this.config.proxyPort, "127.0.0.1");
54
71
  }
55
72
 
@@ -84,12 +101,16 @@ export class ModelProxy {
84
101
  const pump = (): void => {
85
102
  reader.read().then(({ done, value }) => {
86
103
  if (done) {
104
+ reader.releaseLock();
87
105
  res.end();
88
106
  return;
89
107
  }
90
108
  res.write(value);
91
109
  pump();
92
- }).catch(() => res.end());
110
+ }).catch(() => {
111
+ reader.releaseLock();
112
+ res.end();
113
+ });
93
114
  };
94
115
  pump();
95
116
  }
@@ -115,17 +136,32 @@ export class ModelProxy {
115
136
  };
116
137
  }
117
138
 
118
- const modelId = body.model;
119
- // Check proxyModels for a nodeId routing hint
120
- const proxyModel = this.config.proxyModels.find((m) => m.id === modelId);
121
- const route = proxyModel?.nodeId
122
- ? this.peerManager.router.getRoute(proxyModel.nodeId)
139
+ const rawModelId = body.model;
140
+ // Parse "nodeId/model" format: first segment is nodeId, rest is model ID.
141
+ // OpenClaw sends "providerId/modelId" where providerId = nodeId, so this
142
+ // naturally handles both OpenClaw calls and direct curl calls.
143
+ // If no "/" present, treat entire string as model ID and auto-resolve.
144
+ let nodeId: string | undefined;
145
+ let modelId: string;
146
+ const slashIdx = rawModelId.indexOf("/");
147
+ if (slashIdx > 0) {
148
+ nodeId = rawModelId.slice(0, slashIdx);
149
+ modelId = rawModelId.slice(slashIdx + 1);
150
+ } else {
151
+ modelId = rawModelId;
152
+ }
153
+ debug("proxy", `model raw="${rawModelId}" nodeId=${nodeId ?? "auto"} modelId="${modelId}" stream=${body.stream ?? false}`);
154
+ const proxyModel = this.config.proxyModels.find((m) => m.id === modelId && (!nodeId || m.nodeId === nodeId))
155
+ ?? this.config.proxyModels.find((m) => m.id === modelId);
156
+ const route = nodeId
157
+ ? this.peerManager.router.getRoute(nodeId)
123
158
  : this.peerManager.router.resolveModel(modelId);
159
+ debug("proxy", `proxyModel=${proxyModel?.id ?? "none"} route=${route?.nodeId ?? "none"} reachable=${route ? this.peerManager.canReach(route.nodeId) : false}`);
124
160
  if (!route) {
125
161
  return {
126
162
  status: 404,
127
163
  headers: { "Content-Type": "application/json" },
128
- body: JSON.stringify({ error: { message: `Model "${modelId}" not found in cluster` } }),
164
+ body: JSON.stringify({ error: { message: `Model "${modelId}" not found in cluster (proxyModels: [${this.config.proxyModels.map(m => m.id).join(", ")}])` } }),
129
165
  };
130
166
  }
131
167
 
@@ -158,6 +194,15 @@ export class ModelProxy {
158
194
  },
159
195
  };
160
196
 
197
+ // Pre-check reachability before starting a stream (avoids silent empty response)
198
+ if (!this.peerManager.canReach(route.nodeId)) {
199
+ return {
200
+ status: 502,
201
+ headers: { "Content-Type": "application/json" },
202
+ body: JSON.stringify({ error: { message: `Cannot reach model node "${route.nodeId}"` } }),
203
+ };
204
+ }
205
+
161
206
  if (stream) {
162
207
  return this.handleStreamRequest(requestId, route.nodeId, frame);
163
208
  } else {
@@ -178,8 +223,13 @@ export class ModelProxy {
178
223
  this.pending.delete(requestId);
179
224
  this.peerManager.router.markFailed(requestId);
180
225
  try {
226
+ const errorChunk = {
227
+ id: `chatcmpl-${requestId}`,
228
+ object: "chat.completion.chunk",
229
+ choices: [{ index: 0, delta: { content: "\n\n[ClawMatrix] Error: model request timed out" }, finish_reason: "stop" }],
230
+ };
181
231
  controller.enqueue(
182
- encoder.encode(`data: ${JSON.stringify({ error: "timeout" })}\n\n`),
232
+ encoder.encode(`data: ${JSON.stringify(errorChunk)}\n\n`),
183
233
  );
184
234
  controller.enqueue(encoder.encode("data: [DONE]\n\n"));
185
235
  controller.close();
@@ -201,10 +251,13 @@ export class ModelProxy {
201
251
  if (!sent) {
202
252
  this.pending.delete(requestId);
203
253
  clearTimeout(timer);
254
+ const errChunk = {
255
+ id: `chatcmpl-${requestId}`,
256
+ object: "chat.completion.chunk",
257
+ choices: [{ index: 0, delta: { content: `[ClawMatrix] Cannot reach model node "${targetNodeId}"` }, finish_reason: "stop" }],
258
+ };
204
259
  controller.enqueue(
205
- encoder.encode(
206
- `data: ${JSON.stringify({ error: "Cannot reach model node" })}\n\n`,
207
- ),
260
+ encoder.encode(`data: ${JSON.stringify(errChunk)}\n\n`),
208
261
  );
209
262
  controller.enqueue(encoder.encode("data: [DONE]\n\n"));
210
263
  controller.close();
@@ -317,7 +370,31 @@ export class ModelProxy {
317
370
  handleModelResponse(frame: ModelResponse) {
318
371
  if (this.peerManager.router.isFailed(frame.id)) return;
319
372
  const pending = this.pending.get(frame.id);
320
- if (!pending || pending.stream) return;
373
+ if (!pending) return;
374
+
375
+ // For stream requests, handle error responses (the remote node couldn't
376
+ // process the request and sent model_res instead of model_stream).
377
+ if (pending.stream) {
378
+ if (!frame.payload.success && pending.controller && pending.encoder) {
379
+ clearTimeout(pending.timer);
380
+ this.pending.delete(frame.id);
381
+ try {
382
+ const errChunk = {
383
+ id: `chatcmpl-${frame.id}`,
384
+ object: "chat.completion.chunk",
385
+ choices: [{ index: 0, delta: { content: `[ClawMatrix] Remote error: ${frame.payload.error}` }, finish_reason: "stop" }],
386
+ };
387
+ pending.controller.enqueue(
388
+ pending.encoder.encode(`data: ${JSON.stringify(errChunk)}\n\n`),
389
+ );
390
+ pending.controller.enqueue(pending.encoder.encode("data: [DONE]\n\n"));
391
+ pending.controller.close();
392
+ } catch {
393
+ // controller may already be closed
394
+ }
395
+ }
396
+ return;
397
+ }
321
398
 
322
399
  clearTimeout(pending.timer);
323
400
  this.pending.delete(frame.id);
@@ -325,27 +402,28 @@ export class ModelProxy {
325
402
  }
326
403
 
327
404
  handleModelStream(frame: ModelStreamChunk) {
405
+ debug("stream", `id=${frame.id} done=${frame.payload.done} delta=${JSON.stringify(frame.payload.delta?.slice?.(0, 50) ?? frame.payload.delta)} failed=${this.peerManager.router.isFailed(frame.id)} hasPending=${this.pending.has(frame.id)}`);
328
406
  if (this.peerManager.router.isFailed(frame.id)) return;
329
407
  const pending = this.pending.get(frame.id);
330
408
  if (!pending?.stream || !pending.controller || !pending.encoder) return;
331
409
 
332
410
  try {
333
411
  if (frame.payload.done) {
412
+ const finalChunk: Record<string, unknown> = {
413
+ id: `chatcmpl-${frame.id}`,
414
+ object: "chat.completion.chunk",
415
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
416
+ };
334
417
  if (frame.payload.usage) {
335
- const usageChunk = {
336
- id: `chatcmpl-${frame.id}`,
337
- object: "chat.completion.chunk",
338
- choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
339
- usage: {
340
- prompt_tokens: frame.payload.usage.inputTokens,
341
- completion_tokens: frame.payload.usage.outputTokens,
342
- total_tokens: frame.payload.usage.inputTokens + frame.payload.usage.outputTokens,
343
- },
418
+ finalChunk.usage = {
419
+ prompt_tokens: frame.payload.usage.inputTokens,
420
+ completion_tokens: frame.payload.usage.outputTokens,
421
+ total_tokens: frame.payload.usage.inputTokens + frame.payload.usage.outputTokens,
344
422
  };
345
- pending.controller.enqueue(
346
- pending.encoder.encode(`data: ${JSON.stringify(usageChunk)}\n\n`),
347
- );
348
423
  }
424
+ pending.controller.enqueue(
425
+ pending.encoder.encode(`data: ${JSON.stringify(finalChunk)}\n\n`),
426
+ );
349
427
  pending.controller.enqueue(pending.encoder.encode("data: [DONE]\n\n"));
350
428
  pending.controller.close();
351
429
  clearTimeout(pending.timer);
@@ -375,6 +453,7 @@ export class ModelProxy {
375
453
  /** Handle model_req locally: forward to OpenClaw's configured model provider. */
376
454
  async handleModelRequest(frame: ModelRequest): Promise<void> {
377
455
  const { id, from, payload } = frame;
456
+ debug("model_req", `handling model="${payload.model}" from=${from} stream=${payload.stream}`);
378
457
 
379
458
  const model = this.config.models.find((m) => m.id === payload.model);
380
459
  if (!model) {
@@ -403,57 +482,88 @@ export class ModelProxy {
403
482
  temperature: payload.temperature,
404
483
  max_tokens: payload.maxTokens,
405
484
  stream: payload.stream,
485
+ ...(payload.stream ? { stream_options: { include_usage: true } } : {}),
406
486
  }),
407
487
  });
408
488
 
489
+ if (!response.ok) {
490
+ const errBody = await response.text();
491
+ throw new Error(`Upstream ${response.status}: ${errBody.slice(0, 200)}`);
492
+ }
493
+
409
494
  if (payload.stream) {
410
495
  const reader = response.body?.getReader();
411
496
  if (!reader) throw new Error("No response body");
412
497
 
413
- const decoder = new TextDecoder();
414
- let buffer = "";
415
-
416
- while (true) {
417
- const { done, value } = await reader.read();
418
- if (done) break;
419
-
420
- buffer += decoder.decode(value, { stream: true });
421
- const lines = buffer.split("\n");
422
- buffer = lines.pop()!;
423
-
424
- for (const line of lines) {
425
- if (!line.startsWith("data: ")) continue;
426
- const data = line.slice(6).trim();
427
- if (data === "[DONE]") {
428
- this.peerManager.sendTo(from, {
429
- type: "model_stream",
430
- id,
431
- from: this.config.nodeId,
432
- to: from,
433
- timestamp: Date.now(),
434
- payload: { delta: "", done: true },
435
- } satisfies ModelStreamChunk);
436
- break;
437
- }
498
+ try {
499
+ const decoder = new TextDecoder();
500
+ let buffer = "";
501
+ let lastUsage: { inputTokens: number; outputTokens: number } | undefined;
502
+ let streamDone = false;
438
503
 
439
- try {
440
- const parsed = JSON.parse(data);
441
- const d = parsed.choices?.[0]?.delta;
442
- const delta = d?.content || d?.reasoning_content || "";
443
- if (delta) {
504
+ while (!streamDone) {
505
+ const { done, value } = await reader.read();
506
+ if (done) break;
507
+
508
+ buffer += decoder.decode(value, { stream: true });
509
+ const lines = buffer.split("\n");
510
+ buffer = lines.pop()!;
511
+
512
+ for (const line of lines) {
513
+ if (!line.startsWith("data: ")) continue;
514
+ const data = line.slice(6).trim();
515
+ if (data === "[DONE]") {
444
516
  this.peerManager.sendTo(from, {
445
517
  type: "model_stream",
446
518
  id,
447
519
  from: this.config.nodeId,
448
520
  to: from,
449
521
  timestamp: Date.now(),
450
- payload: { delta, done: false },
522
+ payload: { delta: "", done: true, usage: lastUsage },
451
523
  } satisfies ModelStreamChunk);
524
+ streamDone = true;
525
+ break;
526
+ }
527
+
528
+ try {
529
+ const parsed = JSON.parse(data);
530
+ if (parsed.usage) {
531
+ lastUsage = {
532
+ inputTokens: parsed.usage.prompt_tokens,
533
+ outputTokens: parsed.usage.completion_tokens,
534
+ };
535
+ }
536
+ const d = parsed.choices?.[0]?.delta;
537
+ const delta = d?.content || d?.reasoning_content || "";
538
+ if (delta) {
539
+ this.peerManager.sendTo(from, {
540
+ type: "model_stream",
541
+ id,
542
+ from: this.config.nodeId,
543
+ to: from,
544
+ timestamp: Date.now(),
545
+ payload: { delta, done: false },
546
+ } satisfies ModelStreamChunk);
547
+ }
548
+ } catch {
549
+ // skip malformed chunks
452
550
  }
453
- } catch {
454
- // skip malformed chunks
455
551
  }
456
552
  }
553
+ // If the upstream closed without sending [DONE], send a completion
554
+ // frame so the requesting side doesn't hang until MODEL_TIMEOUT.
555
+ if (!streamDone) {
556
+ this.peerManager.sendTo(from, {
557
+ type: "model_stream",
558
+ id,
559
+ from: this.config.nodeId,
560
+ to: from,
561
+ timestamp: Date.now(),
562
+ payload: { delta: "", done: true, usage: lastUsage },
563
+ } satisfies ModelStreamChunk);
564
+ }
565
+ } finally {
566
+ reader.releaseLock();
457
567
  }
458
568
  } else {
459
569
  const result = (await response.json()) as {
@@ -33,6 +33,7 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
33
33
  private stopped = false;
34
34
  /** Map from ws WebSocket to Connection for inbound connections. */
35
35
  private inboundConnections = new Map<WsWebSocket, Connection>();
36
+ private gossipDebounceTimer: ReturnType<typeof setTimeout> | null = null;
36
37
 
37
38
  constructor(config: ClawMatrixConfig) {
38
39
  super();
@@ -62,6 +63,10 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
62
63
 
63
64
  async stop() {
64
65
  this.stopped = true;
66
+ if (this.gossipDebounceTimer) {
67
+ clearTimeout(this.gossipDebounceTimer);
68
+ this.gossipDebounceTimer = null;
69
+ }
65
70
  for (const timer of this.reconnectTimers.values()) {
66
71
  clearTimeout(timer);
67
72
  }
@@ -86,6 +91,8 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
86
91
  this.httpServer.close();
87
92
  this.httpServer = null;
88
93
  }
94
+
95
+ this.router.destroy();
89
96
  }
90
97
 
91
98
  // ── Inbound WS server (node:http + ws) ──────────────────────────
@@ -119,6 +126,10 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
119
126
  });
120
127
  });
121
128
 
129
+ this.httpServer.on("error", (err) => {
130
+ console.error(`[clawmatrix] WS server error on port ${port}: ${err.message}`);
131
+ });
132
+
122
133
  this.httpServer.listen(port, hostname);
123
134
  }
124
135
 
@@ -319,13 +330,17 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
319
330
  }
320
331
  }
321
332
 
322
- // If we learned new info, re-sync with other peers
333
+ // If we learned new info, re-sync with other peers (debounced to avoid storms)
323
334
  if (changed) {
324
- for (const conn of this.router.getDirectConnections()) {
325
- if (conn !== from && conn.isOpen) {
326
- this.sendPeerSync(conn);
335
+ if (this.gossipDebounceTimer) clearTimeout(this.gossipDebounceTimer);
336
+ this.gossipDebounceTimer = setTimeout(() => {
337
+ this.gossipDebounceTimer = null;
338
+ for (const conn of this.router.getDirectConnections()) {
339
+ if (conn !== from && conn.isOpen) {
340
+ this.sendPeerSync(conn);
341
+ }
327
342
  }
328
- }
343
+ }, 100);
329
344
  }
330
345
  }
331
346
 
@@ -334,6 +349,18 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
334
349
  return this.router.sendTo(nodeId, frame);
335
350
  }
336
351
 
352
+ /** Check if a node is reachable (direct or via relay) without sending. */
353
+ canReach(nodeId: string): boolean {
354
+ const route = this.router.getRoute(nodeId);
355
+ if (!route) return false;
356
+ if (route.connection?.isOpen) return true;
357
+ if (route.reachableVia) {
358
+ const relay = this.router.getRoute(route.reachableVia);
359
+ return !!relay?.connection?.isOpen;
360
+ }
361
+ return false;
362
+ }
363
+
337
364
  broadcast(frame: ClusterFrame | AnyClusterFrame) {
338
365
  this.router.broadcast(frame);
339
366
  }
package/src/router.ts CHANGED
@@ -4,6 +4,7 @@ import type { Connection } from "./connection.ts";
4
4
  const DEFAULT_TTL = 3;
5
5
  const SEEN_FRAME_TTL = 60_000; // 60s dedup window
6
6
  const MAX_SEEN_FRAMES = 10_000;
7
+ const PRUNE_INTERVAL = 30_000; // periodic cleanup every 30s
7
8
 
8
9
  export interface RouteEntry {
9
10
  nodeId: string;
@@ -24,6 +25,7 @@ export class Router {
24
25
  private routes = new Map<string, RouteEntry>();
25
26
  private connections = new Map<string, Connection>(); // nodeId → direct connection
26
27
  private seenFrames = new Map<string, number>(); // frameId → timestamp
28
+ private pruneTimer: ReturnType<typeof setInterval> | null = null;
27
29
 
28
30
  constructor(
29
31
  nodeId: string,
@@ -33,6 +35,17 @@ export class Router {
33
35
  this.localAgents = localCapabilities?.agents ?? [];
34
36
  this.localModels = localCapabilities?.models ?? [];
35
37
  this.localTags = localCapabilities?.tags ?? [];
38
+
39
+ this.pruneTimer = setInterval(() => this.pruneSeenFrames(true), PRUNE_INTERVAL);
40
+ }
41
+
42
+ /** Stop periodic cleanup. Call on shutdown. */
43
+ destroy() {
44
+ if (this.pruneTimer) {
45
+ clearInterval(this.pruneTimer);
46
+ this.pruneTimer = null;
47
+ }
48
+ this.seenFrames.clear();
36
49
  }
37
50
 
38
51
  // ── Route table management ─────────────────────────────────────
@@ -211,7 +224,9 @@ export class Router {
211
224
  tryRelay(frame: ClusterFrame): boolean {
212
225
  if (!frame.to || frame.to === this.nodeId) return false;
213
226
 
214
- const ttl = (frame.ttl ?? DEFAULT_TTL) - 1;
227
+ const rawTtl = frame.ttl ?? DEFAULT_TTL;
228
+ if (typeof rawTtl !== "number" || !Number.isFinite(rawTtl) || rawTtl < 1) return false;
229
+ const ttl = rawTtl - 1;
215
230
  if (ttl <= 0) return false;
216
231
 
217
232
  const relayed = this.sendTo(frame.to, { ...frame, ttl });
@@ -238,8 +253,8 @@ export class Router {
238
253
  return this.seenFrames.has(`failed:${requestId}`);
239
254
  }
240
255
 
241
- private pruneSeenFrames() {
242
- if (this.seenFrames.size <= MAX_SEEN_FRAMES) return;
256
+ private pruneSeenFrames(force = false) {
257
+ if (!force && this.seenFrames.size <= MAX_SEEN_FRAMES) return;
243
258
  const now = Date.now();
244
259
  for (const [id, ts] of this.seenFrames) {
245
260
  if (now - ts > SEEN_FRAME_TTL) {
package/src/tool-proxy.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { PeerManager } from "./peer-manager.ts";
2
2
  import type { ClawMatrixConfig, ToolProxyConfig } from "./config.ts";
3
3
  import type { ToolProxyRequest, ToolProxyResponse } from "./types.ts";
4
+ import { isLocalTool, executeLocally } from "./local-tools.ts";
4
5
 
5
6
  const TOOL_TIMEOUT = 30_000;
6
7
 
@@ -113,7 +114,9 @@ export class ToolProxy {
113
114
  }
114
115
 
115
116
  try {
116
- const result = await this.executeViaGateway(payload.tool, payload.params);
117
+ const result = isLocalTool(payload.tool)
118
+ ? await executeLocally(payload.tool, payload.params)
119
+ : await this.executeViaGateway(payload.tool, payload.params);
117
120
  this.sendResponse(id, from, { success: true, result });
118
121
  } catch (err) {
119
122
  this.sendResponse(id, from, {
package/src/types.ts CHANGED
@@ -170,10 +170,26 @@ export interface AgentInfo {
170
170
  tags: string[];
171
171
  }
172
172
 
173
+ export interface ModelCompatInfo {
174
+ supportsTools?: boolean;
175
+ supportsStore?: boolean;
176
+ supportsDeveloperRole?: boolean;
177
+ supportsReasoningEffort?: boolean;
178
+ supportsUsageInStreaming?: boolean;
179
+ supportsStrictMode?: boolean;
180
+ maxTokensField?: "max_completion_tokens" | "max_tokens";
181
+ thinkingFormat?: "openai" | "zai" | "qwen";
182
+ requiresToolResultName?: boolean;
183
+ requiresAssistantAfterToolResult?: boolean;
184
+ requiresThinkingAsText?: boolean;
185
+ requiresMistralToolIds?: boolean;
186
+ }
187
+
173
188
  export interface ModelInfo {
174
189
  id: string;
175
190
  provider: string;
176
191
  description?: string;
192
+ compat?: ModelCompatInfo;
177
193
  }
178
194
 
179
195
  export interface PeerInfo {