clawmatrix 0.1.8 → 0.1.12

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/BOOTSTRAP.md CHANGED
@@ -92,15 +92,16 @@ openclaw clawmatrix status
92
92
  "tags": ["office", "gpu"],
93
93
  "toolProxy": {
94
94
  "enabled": true,
95
- "allow": ["exec", "read", "write", "edit", "web_search"],
96
- "deny": ["browser", "sessions_spawn"]
95
+ "allow": ["*"],
96
+ "deny": []
97
97
  }
98
98
  }
99
99
  ```
100
100
 
101
101
  注意事项:
102
- - `models` 里填这台机器上 OpenClaw 已经配置好的模型(必须本地能用才行)
103
- - **必须开启 OpenClaw chatCompletions HTTP 端点**,否则模型代理无法工作(见下方「前置配置」)
102
+ - `models` 里的 `provider` 必须与 OpenClaw `models.providers` 中的 key 一致,ClawMatrix 会自动读取对应的 `baseUrl` 和 `apiKey`
103
+ - 如果需要覆盖(比如用不同的 API 地址),可以显式设置 `baseUrl` `apiKey`
104
+ - ClawMatrix 直接调模型 API,不经过 OpenClaw Gateway 的 agent 系统
104
105
  - `toolProxy.enabled: true` 才会接受远程工具调用
105
106
  - `toolProxy.deny` 优先于 `allow`,建议禁用高风险工具
106
107
 
@@ -154,13 +155,13 @@ openclaw clawmatrix status
154
155
  | `listenPort` | number | `19000` | 入站 WebSocket 端口 |
155
156
  | `peers` | array | `[]` | 要连接的 peer:`{ nodeId, url }` |
156
157
  | `agents` | array | `[]` | 本节点提供的 Agent:`{ id, description, tags }` |
157
- | `models` | array | `[]` | 本节点共享给集群的模型:`{ id, provider, description? }` |
158
+ | `models` | array | `[]` | 本节点共享给集群的模型:`{ id, provider }`(自动读取 OpenClaw provider 的 baseUrl/apiKey,可选覆盖)|
158
159
  | `proxyModels` | array | `[]` | 从集群消费的远程模型:`{ id, nodeId?, description? }` |
159
160
  | `tags` | array | `[]` | 自由标签,用于能力路由 |
160
161
  | `proxyPort` | number | `19001` | 本地模型代理 HTTP 端口 |
161
162
  | `handoffTimeout` | number | `600000` | Handoff 超时(毫秒,默认 10 分钟) |
162
163
  | `toolProxy.enabled` | boolean | `false` | 允许远程工具执行 |
163
- | `toolProxy.allow` | array | `[]` | 允许的工具名(`[]` 或 `["*"]` = 全部允许) |
164
+ | `toolProxy.allow` | array | `[]` | 允许的工具名,`["*"]` 或 `[]` 表示全部。`exec`/`read`/`write`/`edit` 本地执行,其余走 Gateway |
164
165
  | `toolProxy.deny` | array | `[]` | 禁止的工具名(优先于 allow) |
165
166
  | `toolProxy.maxOutputBytes` | number | `1048576` | 单次响应最大字节数 |
166
167
 
@@ -181,26 +182,6 @@ openclaw clawmatrix status
181
182
  `target` 参数支持 Agent ID(如 `"coder"`)或标签查询(如 `"tags:coding"`)。
182
183
  `node` 参数支持 nodeId(如 `"office-01"`)或标签查询(如 `"tags:gpu"`)。
183
184
 
184
- ## 前置配置:开启 chatCompletions 端点
185
-
186
- **所有共享模型的节点都必须配置此项**。ClawMatrix 的模型代理通过 OpenClaw Gateway 的 `/v1/chat/completions` HTTP 端点转发请求,该端点默认关闭。
187
-
188
- 在 `openclaw.json` 的 `gateway` 字段下添加:
189
-
190
- ```json
191
- {
192
- "gateway": {
193
- "http": {
194
- "endpoints": {
195
- "chatCompletions": { "enabled": true }
196
- }
197
- }
198
- }
199
- }
200
- ```
201
-
202
- 如果不开启,模型代理请求会返回 404 错误。
203
-
204
185
  ## 前置配置:注册集群模型到 OpenClaw
205
186
 
206
187
  消费远程模型的节点需要在 `models.providers` 中注册,否则 `/models` 命令看不到集群模型。
@@ -249,7 +230,7 @@ openclaw clawmatrix status
249
230
 
250
231
  **模型代理不工作**:
251
232
  - 确认远程节点的 `models` 中声明了该模型
252
- - 确认该模型在远程节点上本地可用(OpenClaw 已配置对应 provider
233
+ - 确认该模型的 `provider` 与 OpenClaw `models.providers` 中的 key 一致(ClawMatrix 会自动读取 baseUrl 和 apiKey
253
234
  - 确认本节点使用 `clawmatrix/<模型ID>` 格式引用模型
254
235
  - 如果用了 `proxyModels.nodeId`,确认该节点在线
255
236
 
package/README.md CHANGED
@@ -61,7 +61,7 @@ openclaw plugins install clawmatrix
61
61
  { "id": "claude-sonnet", "provider": "anthropic" },
62
62
  { "id": "deepseek-coder", "provider": "ollama" }
63
63
  ],
64
- "toolProxy": { "enabled": true, "allow": ["exec", "read", "write", "edit"] }
64
+ "toolProxy": { "enabled": true, "allow": ["*"] }
65
65
  }
66
66
  ```
67
67
 
@@ -77,19 +77,7 @@ openclaw plugins install clawmatrix
77
77
  }
78
78
  ```
79
79
 
80
- **重要**:共享模型的节点必须开启 chatCompletions 端点:
81
-
82
- ```json
83
- {
84
- "gateway": {
85
- "http": {
86
- "endpoints": {
87
- "chatCompletions": { "enabled": true }
88
- }
89
- }
90
- }
91
- }
92
- ```
80
+ **重要**:共享模型的节点只需在 `models` 中声明 `{ id, provider }`,ClawMatrix 会自动从 OpenClaw 的 `models.providers` 读取对应的 `baseUrl` 和 `apiKey`,直接调用模型 API(不经过 OpenClaw Gateway 的 agent 系统)。
93
81
 
94
82
  消费远程模型的节点需要在 `models.providers` 中注册(以 nodeId 为 key,baseUrl 指向 `http://127.0.0.1:19001`),详见 [BOOTSTRAP.md](BOOTSTRAP.md)。
95
83
 
@@ -129,14 +117,14 @@ openclaw clawmatrix status
129
117
  | `listenPort` | number | `19000` | 入站 WS 端口 |
130
118
  | `peers` | array | `[]` | 要连接的 peer:`{ nodeId, url }` |
131
119
  | `agents` | array | `[]` | 本节点 Agent:`{ id, description, tags }` |
132
- | `models` | array | `[]` | 共享给集群的模型:`{ id, provider }` |
120
+ | `models` | array | `[]` | 共享给集群的模型:`{ id, provider }`(自动读取 OpenClaw provider 配置,可选 `baseUrl`/`apiKey` 覆盖)|
133
121
  | `proxyModels` | array | `[]` | 要消费的远程模型:`{ id, nodeId? }` |
134
122
  | `tags` | array | `[]` | 自由标签 |
135
123
  | `proxyPort` | number | `19001` | 本地模型代理端口 |
136
124
  | `handoffTimeout` | number | `600000` | Handoff 超时(ms) |
137
125
  | `toolProxy.enabled` | boolean | `false` | 允许远程工具执行 |
138
- | `toolProxy.allow` | array | `[]` | 允许的工具(`[]` = 全部) |
139
- | `toolProxy.deny` | array | `[]` | 禁止的工具(优先) |
126
+ | `toolProxy.allow` | array | `[]` | 允许的工具名,`["*"]` `[]` 表示全部。`exec`/`read`/`write`/`edit` 本地执行,其余走 Gateway |
127
+ | `toolProxy.deny` | array | `[]` | 禁止的工具(优先于 allow) |
140
128
 
141
129
  ## 架构
142
130
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmatrix",
3
- "version": "0.1.8",
3
+ "version": "0.1.12",
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,8 @@
28
28
  },
29
29
  "scripts": {
30
30
  "test": "bun test",
31
- "prepublishOnly": "bun test"
31
+ "prepublishOnly": "bun test",
32
+ "release": "bunx bumpp"
32
33
  },
33
34
  "dependencies": {
34
35
  "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";
@@ -50,7 +51,7 @@ export class ClusterRuntime {
50
51
  const gatewayInfo = resolveGatewayInfo(openclawConfig);
51
52
  this.peerManager = new PeerManager(config);
52
53
  this.handoffManager = new HandoffManager(config, this.peerManager);
53
- this.modelProxy = new ModelProxy(config, this.peerManager, gatewayInfo);
54
+ this.modelProxy = new ModelProxy(config, this.peerManager, gatewayInfo, openclawConfig);
54
55
  this.toolProxy = new ToolProxy(config, this.peerManager, gatewayInfo);
55
56
  }
56
57
 
@@ -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,12 +37,15 @@ 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({
28
44
  id: z.string(),
29
45
  provider: z.string(),
30
46
  description: z.string().optional(),
47
+ baseUrl: z.string().optional(),
48
+ apiKey: z.string().optional(),
31
49
  ...ModelParamsSchema,
32
50
  });
33
51
 
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}`,
@@ -176,6 +191,12 @@ const plugin = {
176
191
  " - cluster_peers — inspect cluster topology",
177
192
  "Use the node's description and tags to decide which node to target. " +
178
193
  "For simple operations, prefer cluster_exec/read/write; for complex multi-step tasks, prefer cluster_handoff.",
194
+ "",
195
+ "IMPORTANT: Before calling any cluster tool or using a remote model, you MUST explicitly tell the user " +
196
+ "which remote node you are targeting and what you are about to do. For example:",
197
+ ' "I\'m going to run this command on remote node «coder» ..."',
198
+ ' "I\'m delegating this task to agent «reviewer» on node «server-b» ..."',
199
+ "This ensures the user always knows when operations leave the local node.",
179
200
  );
180
201
 
181
202
  return { prependContext: lines.join("\n") };
@@ -186,8 +207,8 @@ const plugin = {
186
207
  },
187
208
  };
188
209
 
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 {
210
+ 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> }) {
211
+ const result: Record<string, unknown> = {
191
212
  id: m.id,
192
213
  name: m.description ?? m.id,
193
214
  api: m.api ?? ("openai-completions" as const),
@@ -197,6 +218,8 @@ function formatModel(m: { id: string; description?: string; api?: string; reason
197
218
  contextWindow: m.contextWindow ?? 128_000,
198
219
  maxTokens: m.maxTokens ?? 4096,
199
220
  };
221
+ if (m.compat) result.compat = m.compat;
222
+ return result;
200
223
  }
201
224
 
202
225
  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
+ }
@@ -2,11 +2,13 @@ import { createServer, type Server } from "node:http";
2
2
  import type { PeerManager } from "./peer-manager.ts";
3
3
  import type { ClawMatrixConfig } from "./config.ts";
4
4
  import type { GatewayInfo } from "./tool-proxy.ts";
5
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
5
6
  import type {
6
7
  ModelRequest,
7
8
  ModelResponse,
8
9
  ModelStreamChunk,
9
10
  } from "./types.ts";
11
+ import { debug } from "./debug.ts";
10
12
 
11
13
  const MODEL_TIMEOUT = 120_000; // 2 minutes
12
14
 
@@ -25,31 +27,81 @@ export class ModelProxy {
25
27
  private pending = new Map<string, PendingModelReq>();
26
28
  private httpServer: Server | null = null;
27
29
  private gatewayInfo: GatewayInfo;
30
+ private openclawConfig: OpenClawConfig;
28
31
 
29
- constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo) {
32
+ constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo, openclawConfig: OpenClawConfig) {
30
33
  this.config = config;
31
34
  this.peerManager = peerManager;
32
35
  this.gatewayInfo = gatewayInfo;
36
+ this.openclawConfig = openclawConfig;
37
+ }
38
+
39
+ /** Resolve API endpoint for a model: explicit config > OpenClaw provider > gateway fallback */
40
+ private resolveModelEndpoint(model: { id: string; provider: string; baseUrl?: string; apiKey?: string }): { url: string; apiKey?: string; direct: boolean } {
41
+ // 1. Explicit baseUrl in ClawMatrix model config
42
+ if (model.baseUrl) {
43
+ return {
44
+ url: `${model.baseUrl.replace(/\/$/, "")}/chat/completions`,
45
+ apiKey: model.apiKey,
46
+ direct: true,
47
+ };
48
+ }
49
+
50
+ // 2. Read from OpenClaw's models.providers[provider]
51
+ const providers = (this.openclawConfig as Record<string, unknown>).models as
52
+ { providers?: Record<string, { baseUrl?: string; apiKey?: string }> } | undefined;
53
+ const providerConfig = providers?.providers?.[model.provider];
54
+ if (providerConfig?.baseUrl) {
55
+ return {
56
+ url: `${providerConfig.baseUrl.replace(/\/$/, "")}/chat/completions`,
57
+ apiKey: typeof providerConfig.apiKey === "string" ? providerConfig.apiKey : undefined,
58
+ direct: true,
59
+ };
60
+ }
61
+
62
+ // 3. Fallback: OpenClaw gateway (goes through agent system — not recommended)
63
+ const { port } = this.gatewayInfo;
64
+ return {
65
+ url: `http://127.0.0.1:${port}/v1/chat/completions`,
66
+ apiKey: undefined,
67
+ direct: false,
68
+ };
33
69
  }
34
70
 
35
71
  /** Start the local HTTP proxy server for OpenAI-compatible requests. */
36
72
  start() {
37
73
  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");
74
+ try {
75
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
76
+
77
+ const p = url.pathname.replace(/^\/v1/, "");
78
+ debug("proxy", `${req.method} ${url.pathname} ${p}`);
79
+
80
+ if (p === "/chat/completions" && req.method === "POST") {
81
+ const body = await this.readBody(req);
82
+ const response = await this.handleChatCompletion(body);
83
+ debug("proxy", `response status=${response.status}`);
84
+ this.sendResponse(res, response);
85
+ } else if (p === "/models" && req.method === "GET") {
86
+ const response = this.handleListModels();
87
+ this.sendResponse(res, response);
88
+ } else {
89
+ res.writeHead(404, { "Content-Type": "application/json" });
90
+ res.end(JSON.stringify({ error: { message: `No handler for ${req.method} ${url.pathname}` } }));
91
+ }
92
+ } catch {
93
+ if (!res.headersSent) {
94
+ res.writeHead(500, { "Content-Type": "application/json" });
95
+ }
96
+ res.end(JSON.stringify({ error: { message: "Internal proxy error" } }));
50
97
  }
51
98
  });
52
99
 
100
+ this.httpServer.on("error", (err) => {
101
+ // Log but don't crash — port conflict or other server error
102
+ console.error(`[clawmatrix] Model proxy server error: ${err.message}`);
103
+ });
104
+
53
105
  this.httpServer.listen(this.config.proxyPort, "127.0.0.1");
54
106
  }
55
107
 
@@ -84,12 +136,16 @@ export class ModelProxy {
84
136
  const pump = (): void => {
85
137
  reader.read().then(({ done, value }) => {
86
138
  if (done) {
139
+ reader.releaseLock();
87
140
  res.end();
88
141
  return;
89
142
  }
90
143
  res.write(value);
91
144
  pump();
92
- }).catch(() => res.end());
145
+ }).catch(() => {
146
+ reader.releaseLock();
147
+ res.end();
148
+ });
93
149
  };
94
150
  pump();
95
151
  }
@@ -115,17 +171,32 @@ export class ModelProxy {
115
171
  };
116
172
  }
117
173
 
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)
174
+ const rawModelId = body.model;
175
+ // Parse "nodeId/model" format: first segment is nodeId, rest is model ID.
176
+ // OpenClaw sends "providerId/modelId" where providerId = nodeId, so this
177
+ // naturally handles both OpenClaw calls and direct curl calls.
178
+ // If no "/" present, treat entire string as model ID and auto-resolve.
179
+ let nodeId: string | undefined;
180
+ let modelId: string;
181
+ const slashIdx = rawModelId.indexOf("/");
182
+ if (slashIdx > 0) {
183
+ nodeId = rawModelId.slice(0, slashIdx);
184
+ modelId = rawModelId.slice(slashIdx + 1);
185
+ } else {
186
+ modelId = rawModelId;
187
+ }
188
+ debug("proxy", `model raw="${rawModelId}" nodeId=${nodeId ?? "auto"} modelId="${modelId}" stream=${body.stream ?? false}`);
189
+ const proxyModel = this.config.proxyModels.find((m) => m.id === modelId && (!nodeId || m.nodeId === nodeId))
190
+ ?? this.config.proxyModels.find((m) => m.id === modelId);
191
+ const route = nodeId
192
+ ? this.peerManager.router.getRoute(nodeId)
123
193
  : this.peerManager.router.resolveModel(modelId);
194
+ debug("proxy", `proxyModel=${proxyModel?.id ?? "none"} route=${route?.nodeId ?? "none"} reachable=${route ? this.peerManager.canReach(route.nodeId) : false}`);
124
195
  if (!route) {
125
196
  return {
126
197
  status: 404,
127
198
  headers: { "Content-Type": "application/json" },
128
- body: JSON.stringify({ error: { message: `Model "${modelId}" not found in cluster` } }),
199
+ body: JSON.stringify({ error: { message: `Model "${modelId}" not found in cluster (proxyModels: [${this.config.proxyModels.map(m => m.id).join(", ")}])` } }),
129
200
  };
130
201
  }
131
202
 
@@ -158,6 +229,15 @@ export class ModelProxy {
158
229
  },
159
230
  };
160
231
 
232
+ // Pre-check reachability before starting a stream (avoids silent empty response)
233
+ if (!this.peerManager.canReach(route.nodeId)) {
234
+ return {
235
+ status: 502,
236
+ headers: { "Content-Type": "application/json" },
237
+ body: JSON.stringify({ error: { message: `Cannot reach model node "${route.nodeId}"` } }),
238
+ };
239
+ }
240
+
161
241
  if (stream) {
162
242
  return this.handleStreamRequest(requestId, route.nodeId, frame);
163
243
  } else {
@@ -178,8 +258,13 @@ export class ModelProxy {
178
258
  this.pending.delete(requestId);
179
259
  this.peerManager.router.markFailed(requestId);
180
260
  try {
261
+ const errorChunk = {
262
+ id: `chatcmpl-${requestId}`,
263
+ object: "chat.completion.chunk",
264
+ choices: [{ index: 0, delta: { content: "\n\n[ClawMatrix] Error: model request timed out" }, finish_reason: "stop" }],
265
+ };
181
266
  controller.enqueue(
182
- encoder.encode(`data: ${JSON.stringify({ error: "timeout" })}\n\n`),
267
+ encoder.encode(`data: ${JSON.stringify(errorChunk)}\n\n`),
183
268
  );
184
269
  controller.enqueue(encoder.encode("data: [DONE]\n\n"));
185
270
  controller.close();
@@ -201,10 +286,13 @@ export class ModelProxy {
201
286
  if (!sent) {
202
287
  this.pending.delete(requestId);
203
288
  clearTimeout(timer);
289
+ const errChunk = {
290
+ id: `chatcmpl-${requestId}`,
291
+ object: "chat.completion.chunk",
292
+ choices: [{ index: 0, delta: { content: `[ClawMatrix] Cannot reach model node "${targetNodeId}"` }, finish_reason: "stop" }],
293
+ };
204
294
  controller.enqueue(
205
- encoder.encode(
206
- `data: ${JSON.stringify({ error: "Cannot reach model node" })}\n\n`,
207
- ),
295
+ encoder.encode(`data: ${JSON.stringify(errChunk)}\n\n`),
208
296
  );
209
297
  controller.enqueue(encoder.encode("data: [DONE]\n\n"));
210
298
  controller.close();
@@ -317,7 +405,31 @@ export class ModelProxy {
317
405
  handleModelResponse(frame: ModelResponse) {
318
406
  if (this.peerManager.router.isFailed(frame.id)) return;
319
407
  const pending = this.pending.get(frame.id);
320
- if (!pending || pending.stream) return;
408
+ if (!pending) return;
409
+
410
+ // For stream requests, handle error responses (the remote node couldn't
411
+ // process the request and sent model_res instead of model_stream).
412
+ if (pending.stream) {
413
+ if (!frame.payload.success && pending.controller && pending.encoder) {
414
+ clearTimeout(pending.timer);
415
+ this.pending.delete(frame.id);
416
+ try {
417
+ const errChunk = {
418
+ id: `chatcmpl-${frame.id}`,
419
+ object: "chat.completion.chunk",
420
+ choices: [{ index: 0, delta: { content: `[ClawMatrix] Remote error: ${frame.payload.error}` }, finish_reason: "stop" }],
421
+ };
422
+ pending.controller.enqueue(
423
+ pending.encoder.encode(`data: ${JSON.stringify(errChunk)}\n\n`),
424
+ );
425
+ pending.controller.enqueue(pending.encoder.encode("data: [DONE]\n\n"));
426
+ pending.controller.close();
427
+ } catch {
428
+ // controller may already be closed
429
+ }
430
+ }
431
+ return;
432
+ }
321
433
 
322
434
  clearTimeout(pending.timer);
323
435
  this.pending.delete(frame.id);
@@ -325,27 +437,28 @@ export class ModelProxy {
325
437
  }
326
438
 
327
439
  handleModelStream(frame: ModelStreamChunk) {
440
+ 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
441
  if (this.peerManager.router.isFailed(frame.id)) return;
329
442
  const pending = this.pending.get(frame.id);
330
443
  if (!pending?.stream || !pending.controller || !pending.encoder) return;
331
444
 
332
445
  try {
333
446
  if (frame.payload.done) {
447
+ const finalChunk: Record<string, unknown> = {
448
+ id: `chatcmpl-${frame.id}`,
449
+ object: "chat.completion.chunk",
450
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
451
+ };
334
452
  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
- },
453
+ finalChunk.usage = {
454
+ prompt_tokens: frame.payload.usage.inputTokens,
455
+ completion_tokens: frame.payload.usage.outputTokens,
456
+ total_tokens: frame.payload.usage.inputTokens + frame.payload.usage.outputTokens,
344
457
  };
345
- pending.controller.enqueue(
346
- pending.encoder.encode(`data: ${JSON.stringify(usageChunk)}\n\n`),
347
- );
348
458
  }
459
+ pending.controller.enqueue(
460
+ pending.encoder.encode(`data: ${JSON.stringify(finalChunk)}\n\n`),
461
+ );
349
462
  pending.controller.enqueue(pending.encoder.encode("data: [DONE]\n\n"));
350
463
  pending.controller.close();
351
464
  clearTimeout(pending.timer);
@@ -372,9 +485,10 @@ export class ModelProxy {
372
485
  }
373
486
  }
374
487
 
375
- /** Handle model_req locally: forward to OpenClaw's configured model provider. */
488
+ /** Handle model_req locally: call the model API directly or fall back to OpenClaw gateway. */
376
489
  async handleModelRequest(frame: ModelRequest): Promise<void> {
377
490
  const { id, from, payload } = frame;
491
+ debug("model_req", `handling model="${payload.model}" from=${from} stream=${payload.stream}`);
378
492
 
379
493
  const model = this.config.models.find((m) => m.id === payload.model);
380
494
  if (!model) {
@@ -390,70 +504,109 @@ export class ModelProxy {
390
504
  }
391
505
 
392
506
  try {
393
- const { port, authHeader } = this.gatewayInfo;
507
+ const endpoint = this.resolveModelEndpoint(model);
394
508
  const headers: Record<string, string> = { "Content-Type": "application/json" };
395
- if (authHeader) headers["Authorization"] = authHeader;
396
509
 
397
- const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
510
+ if (endpoint.direct) {
511
+ if (endpoint.apiKey) headers["Authorization"] = `Bearer ${endpoint.apiKey}`;
512
+ debug("model_req", `direct API call to ${endpoint.url}`);
513
+ } else {
514
+ const { authHeader } = this.gatewayInfo;
515
+ if (authHeader) headers["Authorization"] = authHeader;
516
+ debug("model_req", `gateway fallback to ${endpoint.url} (not recommended)`);
517
+ }
518
+
519
+ const response = await fetch(endpoint.url, {
398
520
  method: "POST",
399
521
  headers,
400
522
  body: JSON.stringify({
401
- model: `${model.provider}/${model.id}`,
523
+ model: endpoint.direct ? model.id : `${model.provider}/${model.id}`,
402
524
  messages: payload.messages,
403
525
  temperature: payload.temperature,
404
526
  max_tokens: payload.maxTokens,
405
527
  stream: payload.stream,
528
+ ...(payload.stream ? { stream_options: { include_usage: true } } : {}),
406
529
  }),
407
530
  });
408
531
 
532
+ if (!response.ok) {
533
+ const errBody = await response.text();
534
+ throw new Error(`Upstream ${response.status}: ${errBody.slice(0, 200)}`);
535
+ }
536
+
409
537
  if (payload.stream) {
410
538
  const reader = response.body?.getReader();
411
539
  if (!reader) throw new Error("No response body");
412
540
 
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
- }
541
+ try {
542
+ const decoder = new TextDecoder();
543
+ let buffer = "";
544
+ let lastUsage: { inputTokens: number; outputTokens: number } | undefined;
545
+ let streamDone = false;
546
+
547
+ while (!streamDone) {
548
+ const { done, value } = await reader.read();
549
+ if (done) break;
550
+
551
+ buffer += decoder.decode(value, { stream: true });
552
+ const lines = buffer.split("\n");
553
+ buffer = lines.pop()!;
438
554
 
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) {
555
+ for (const line of lines) {
556
+ if (!line.startsWith("data: ")) continue;
557
+ const data = line.slice(6).trim();
558
+ if (data === "[DONE]") {
444
559
  this.peerManager.sendTo(from, {
445
560
  type: "model_stream",
446
561
  id,
447
562
  from: this.config.nodeId,
448
563
  to: from,
449
564
  timestamp: Date.now(),
450
- payload: { delta, done: false },
565
+ payload: { delta: "", done: true, usage: lastUsage },
451
566
  } satisfies ModelStreamChunk);
567
+ streamDone = true;
568
+ break;
569
+ }
570
+
571
+ try {
572
+ const parsed = JSON.parse(data);
573
+ if (parsed.usage) {
574
+ lastUsage = {
575
+ inputTokens: parsed.usage.prompt_tokens,
576
+ outputTokens: parsed.usage.completion_tokens,
577
+ };
578
+ }
579
+ const d = parsed.choices?.[0]?.delta;
580
+ const delta = d?.content || d?.reasoning_content || "";
581
+ if (delta) {
582
+ this.peerManager.sendTo(from, {
583
+ type: "model_stream",
584
+ id,
585
+ from: this.config.nodeId,
586
+ to: from,
587
+ timestamp: Date.now(),
588
+ payload: { delta, done: false },
589
+ } satisfies ModelStreamChunk);
590
+ }
591
+ } catch {
592
+ // skip malformed chunks
452
593
  }
453
- } catch {
454
- // skip malformed chunks
455
594
  }
456
595
  }
596
+ // If the upstream closed without sending [DONE], send a completion
597
+ // frame so the requesting side doesn't hang until MODEL_TIMEOUT.
598
+ if (!streamDone) {
599
+ this.peerManager.sendTo(from, {
600
+ type: "model_stream",
601
+ id,
602
+ from: this.config.nodeId,
603
+ to: from,
604
+ timestamp: Date.now(),
605
+ payload: { delta: "", done: true, usage: lastUsage },
606
+ } satisfies ModelStreamChunk);
607
+ }
608
+ } finally {
609
+ reader.releaseLock();
457
610
  }
458
611
  } else {
459
612
  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 {