clawmatrix 0.1.6 → 0.1.8
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 +260 -0
- package/README.md +167 -6
- package/package.json +2 -2
- package/src/cli.ts +98 -49
- package/src/cluster-service.ts +4 -0
- package/src/config.ts +1 -0
- package/src/handoff.ts +112 -22
- package/src/index.ts +103 -25
- package/src/model-proxy.ts +17 -4
- package/src/router.ts +6 -0
- package/src/types.ts +10 -0
- package/llms.txt +0 -187
package/BOOTSTRAP.md
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# ClawMatrix — OpenClaw 集群插件安装指南
|
|
2
|
+
|
|
3
|
+
> 本文档供 OpenClaw Agent 参考,用于帮助用户安装和配置 ClawMatrix 插件。
|
|
4
|
+
|
|
5
|
+
## 这个插件是什么
|
|
6
|
+
|
|
7
|
+
ClawMatrix 让多个 OpenClaw Gateway 组成去中心化 mesh 网络。安装后,节点之间可以:
|
|
8
|
+
|
|
9
|
+
- **共享模型**:家庭节点借用内网/云端节点的 LLM(如 Claude、DeepSeek)
|
|
10
|
+
- **委托任务**:将复杂任务 handoff 给远程 Agent(如有代码仓库访问权限的内网 Agent)
|
|
11
|
+
- **远程工具调用**:在远程节点执行命令、读写文件,无需委托整个任务
|
|
12
|
+
- **自动发现和故障转移**:gossip 协议自动发现节点,宕机时自动路由到备份
|
|
13
|
+
|
|
14
|
+
## 安装步骤
|
|
15
|
+
|
|
16
|
+
### 第 1 步:安装插件
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
openclaw plugins install clawmatrix
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### 第 2 步:配置
|
|
23
|
+
|
|
24
|
+
编辑 `openclaw.json`,在 `plugins.entries.clawmatrix` 中添加配置。**所有要组网的节点必须使用相同的 `secret`**。
|
|
25
|
+
|
|
26
|
+
用户需要告知你:
|
|
27
|
+
1. **这个节点的角色**:公网服务器?内网办公机?家庭电脑?
|
|
28
|
+
2. **是否需要接受入站连接**(公网节点通常需要,内网/家庭节点不需要)
|
|
29
|
+
3. **要连接的 peer**(通常是公网节点的 WebSocket 地址)
|
|
30
|
+
4. **本节点提供什么**:有哪些 Agent?有哪些可共享的模型?
|
|
31
|
+
5. **是否允许远程工具执行**(toolProxy)
|
|
32
|
+
|
|
33
|
+
### 第 3 步:重启 Gateway
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
openclaw gateway restart
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 第 4 步:验证
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
openclaw clawmatrix status
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 配置模板
|
|
46
|
+
|
|
47
|
+
根据用户的节点角色,选择对应模板并替换占位值。
|
|
48
|
+
|
|
49
|
+
### 公网节点(中继 + 可选 Agent)
|
|
50
|
+
|
|
51
|
+
适用于:有公网 IP 或域名的云服务器。作为 mesh 的中继枢纽,内网和家庭节点都连它。
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"nodeId": "<唯一节点名,如 cloud-01>",
|
|
56
|
+
"secret": "<所有节点共用的密钥,至少 16 个字符>",
|
|
57
|
+
"listen": true,
|
|
58
|
+
"listenPort": 19000,
|
|
59
|
+
"peers": [],
|
|
60
|
+
"agents": [
|
|
61
|
+
{ "id": "<agent名>", "description": "<agent描述>", "tags": ["<标签>"] }
|
|
62
|
+
],
|
|
63
|
+
"models": [],
|
|
64
|
+
"tags": ["cloud"]
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
注意事项:
|
|
69
|
+
- `listenPort` 需要在防火墙/安全组中放行
|
|
70
|
+
- 生产环境建议配置 TLS,让 peer 使用 `wss://` 连接
|
|
71
|
+
- 如果不跑 Agent 可以把 `agents` 设为 `[]`
|
|
72
|
+
|
|
73
|
+
### 内网/办公节点(有模型或代码仓库)
|
|
74
|
+
|
|
75
|
+
适用于:有 GPU、有 API Key、有内网资源的机器。不需要公网 IP,主动连接公网节点。
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"nodeId": "<唯一节点名,如 office-01>",
|
|
80
|
+
"secret": "<同上>",
|
|
81
|
+
"listen": false,
|
|
82
|
+
"peers": [
|
|
83
|
+
{ "nodeId": "<公网节点名>", "url": "wss://<公网节点地址>:19000" }
|
|
84
|
+
],
|
|
85
|
+
"agents": [
|
|
86
|
+
{ "id": "coder", "description": "有代码仓库访问权限,可以读写代码和执行命令", "tags": ["coding"] }
|
|
87
|
+
],
|
|
88
|
+
"models": [
|
|
89
|
+
{ "id": "claude-sonnet", "provider": "anthropic" },
|
|
90
|
+
{ "id": "deepseek-coder", "provider": "ollama" }
|
|
91
|
+
],
|
|
92
|
+
"tags": ["office", "gpu"],
|
|
93
|
+
"toolProxy": {
|
|
94
|
+
"enabled": true,
|
|
95
|
+
"allow": ["exec", "read", "write", "edit", "web_search"],
|
|
96
|
+
"deny": ["browser", "sessions_spawn"]
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
注意事项:
|
|
102
|
+
- `models` 里填这台机器上 OpenClaw 已经配置好的模型(必须本地能用才行)
|
|
103
|
+
- **必须开启 OpenClaw 的 chatCompletions HTTP 端点**,否则模型代理无法工作(见下方「前置配置」)
|
|
104
|
+
- `toolProxy.enabled: true` 才会接受远程工具调用
|
|
105
|
+
- `toolProxy.deny` 优先于 `allow`,建议禁用高风险工具
|
|
106
|
+
|
|
107
|
+
### 家庭/轻量节点(借用集群资源)
|
|
108
|
+
|
|
109
|
+
适用于:个人电脑、轻量设备。没有模型和特殊资源,借用集群。
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"nodeId": "<唯一节点名,如 home-01>",
|
|
114
|
+
"secret": "<同上>",
|
|
115
|
+
"listen": false,
|
|
116
|
+
"peers": [
|
|
117
|
+
{ "nodeId": "<公网节点名>", "url": "wss://<公网节点地址>:19000" }
|
|
118
|
+
],
|
|
119
|
+
"agents": [
|
|
120
|
+
{ "id": "assistant", "description": "个人助手", "tags": ["general"] }
|
|
121
|
+
],
|
|
122
|
+
"models": [],
|
|
123
|
+
"proxyModels": [
|
|
124
|
+
{ "id": "claude-sonnet", "nodeId": "office-01" }
|
|
125
|
+
],
|
|
126
|
+
"tags": ["home"]
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
如果要用集群中的模型,还需要修改 agent 的模型配置:
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{
|
|
134
|
+
"agents": {
|
|
135
|
+
"defaults": {
|
|
136
|
+
"model": "clawmatrix/<模型ID>"
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
注意事项:
|
|
143
|
+
- `proxyModels` 中的 `nodeId` 可选,指定后精确路由到该节点,不指定则自动查找
|
|
144
|
+
- `proxyModels` 中的模型 ID 必须与远程节点 `models` 中声明的 ID 一致
|
|
145
|
+
|
|
146
|
+
## 完整配置字段参考
|
|
147
|
+
|
|
148
|
+
| 字段 | 类型 | 默认值 | 说明 |
|
|
149
|
+
|------|------|--------|------|
|
|
150
|
+
| `nodeId` | string | *必填* | 节点唯一标识 |
|
|
151
|
+
| `secret` | string | *必填* | 集群共享密钥,最少 16 字符 |
|
|
152
|
+
| `listen` | boolean | `false` | 是否接受入站 WebSocket 连接 |
|
|
153
|
+
| `listenHost` | string | `"0.0.0.0"` | WebSocket 监听地址 |
|
|
154
|
+
| `listenPort` | number | `19000` | 入站 WebSocket 端口 |
|
|
155
|
+
| `peers` | array | `[]` | 要连接的 peer:`{ nodeId, url }` |
|
|
156
|
+
| `agents` | array | `[]` | 本节点提供的 Agent:`{ id, description, tags }` |
|
|
157
|
+
| `models` | array | `[]` | 本节点共享给集群的模型:`{ id, provider, description? }` |
|
|
158
|
+
| `proxyModels` | array | `[]` | 从集群消费的远程模型:`{ id, nodeId?, description? }` |
|
|
159
|
+
| `tags` | array | `[]` | 自由标签,用于能力路由 |
|
|
160
|
+
| `proxyPort` | number | `19001` | 本地模型代理 HTTP 端口 |
|
|
161
|
+
| `handoffTimeout` | number | `600000` | Handoff 超时(毫秒,默认 10 分钟) |
|
|
162
|
+
| `toolProxy.enabled` | boolean | `false` | 允许远程工具执行 |
|
|
163
|
+
| `toolProxy.allow` | array | `[]` | 允许的工具名(`[]` 或 `["*"]` = 全部允许) |
|
|
164
|
+
| `toolProxy.deny` | array | `[]` | 禁止的工具名(优先于 allow) |
|
|
165
|
+
| `toolProxy.maxOutputBytes` | number | `1048576` | 单次响应最大字节数 |
|
|
166
|
+
|
|
167
|
+
## 安装后 Agent 获得的工具
|
|
168
|
+
|
|
169
|
+
安装后,本节点的 Agent 自动获得 7 个集群工具:
|
|
170
|
+
|
|
171
|
+
| 工具 | 用途 | 关键参数 |
|
|
172
|
+
|------|------|----------|
|
|
173
|
+
| `cluster_peers` | 查看集群拓扑和连接状态 | 无 |
|
|
174
|
+
| `cluster_handoff` | 委托任务给远程 Agent | `target`, `task`, `context?` |
|
|
175
|
+
| `cluster_send` | 向远程 Agent 发单向消息 | `target`, `message` |
|
|
176
|
+
| `cluster_exec` | 在远程节点执行命令 | `node`, `command`, `workdir?`, `timeout?` |
|
|
177
|
+
| `cluster_read` | 读取远程节点文件 | `node`, `path` |
|
|
178
|
+
| `cluster_write` | 写入远程节点文件 | `node`, `path`, `content` |
|
|
179
|
+
| `cluster_tool` | 调用远程节点任意 OpenClaw 工具 | `node`, `tool`, `args` |
|
|
180
|
+
|
|
181
|
+
`target` 参数支持 Agent ID(如 `"coder"`)或标签查询(如 `"tags:coding"`)。
|
|
182
|
+
`node` 参数支持 nodeId(如 `"office-01"`)或标签查询(如 `"tags:gpu"`)。
|
|
183
|
+
|
|
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
|
+
## 前置配置:注册集群模型到 OpenClaw
|
|
205
|
+
|
|
206
|
+
消费远程模型的节点需要在 `models.providers` 中注册,否则 `/models` 命令看不到集群模型。
|
|
207
|
+
|
|
208
|
+
以 nodeId 作为 provider key(推荐),baseUrl 指向本地模型代理端口:
|
|
209
|
+
|
|
210
|
+
```json
|
|
211
|
+
{
|
|
212
|
+
"models": {
|
|
213
|
+
"providers": {
|
|
214
|
+
"<远程nodeId>": {
|
|
215
|
+
"baseUrl": "http://127.0.0.1:19001",
|
|
216
|
+
"apiKey": "cluster-internal",
|
|
217
|
+
"auth": "api-key",
|
|
218
|
+
"api": "openai-completions",
|
|
219
|
+
"models": [
|
|
220
|
+
{
|
|
221
|
+
"id": "<模型ID>",
|
|
222
|
+
"name": "<显示名>",
|
|
223
|
+
"reasoning": false,
|
|
224
|
+
"input": ["text"],
|
|
225
|
+
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
|
|
226
|
+
"contextWindow": 200000,
|
|
227
|
+
"maxTokens": 32000
|
|
228
|
+
}
|
|
229
|
+
]
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
配置后使用 `/model <nodeId>/<模型ID>` 切换。
|
|
237
|
+
|
|
238
|
+
## 常见问题排查
|
|
239
|
+
|
|
240
|
+
**节点连不上**:
|
|
241
|
+
- 检查 `secret` 是否所有节点一致
|
|
242
|
+
- 检查公网节点的 `listenPort` 是否在防火墙中放行
|
|
243
|
+
- 检查 `peers` 中的 URL 格式:`wss://host:port` 或 `ws://host:port`
|
|
244
|
+
|
|
245
|
+
**远程工具调用失败**:
|
|
246
|
+
- 确认目标节点的 `toolProxy.enabled` 为 `true`
|
|
247
|
+
- 确认目标工具不在 `toolProxy.deny` 列表中
|
|
248
|
+
- 如果 `toolProxy.allow` 非空,确认目标工具在列表中
|
|
249
|
+
|
|
250
|
+
**模型代理不工作**:
|
|
251
|
+
- 确认远程节点的 `models` 中声明了该模型
|
|
252
|
+
- 确认该模型在远程节点上本地可用(OpenClaw 已配置对应 provider)
|
|
253
|
+
- 确认本节点使用 `clawmatrix/<模型ID>` 格式引用模型
|
|
254
|
+
- 如果用了 `proxyModels.nodeId`,确认该节点在线
|
|
255
|
+
|
|
256
|
+
**查看状态**:
|
|
257
|
+
```bash
|
|
258
|
+
openclaw clawmatrix status # 表格形式
|
|
259
|
+
openclaw clawmatrix peers # JSON 格式
|
|
260
|
+
```
|
package/README.md
CHANGED
|
@@ -1,15 +1,176 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ClawMatrix
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
去中心化 mesh 集群插件,让多个 [OpenClaw](https://github.com/nicepkg/openclaw) Gateway 组成 peer-to-peer 网络,跨节点共享 Agent、模型和工具。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
**模型代理** — 家庭节点没有 API Key?通过集群使用内网节点的 LLM,就像本地模型一样。
|
|
8
|
+
|
|
9
|
+
**任务 Handoff** — 需要内网资源?将任务委托给有仓库访问权限的远程 Agent,流式返回结果。
|
|
10
|
+
|
|
11
|
+
**工具代理** — 想在远程节点跑个命令或读个文件?直接调用,不用委托整个任务。
|
|
12
|
+
|
|
13
|
+
**自动发现 & 故障转移** — Gossip 协议自动发现节点,宕机时请求自动路由到备份。
|
|
14
|
+
|
|
15
|
+
## 快速开始
|
|
16
|
+
|
|
17
|
+
> **推荐**:将 [BOOTSTRAP.md](BOOTSTRAP.md) 链接发给 OpenClaw Agent,它会引导你完成安装和配置。
|
|
18
|
+
|
|
19
|
+
### 安装
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
openclaw plugins install clawmatrix
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### 配置
|
|
26
|
+
|
|
27
|
+
编辑 `openclaw.json`,添加插件配置。所有节点共享同一 `secret`。
|
|
28
|
+
|
|
29
|
+
**公网节点**(中继枢纽):
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"plugins": {
|
|
34
|
+
"entries": {
|
|
35
|
+
"clawmatrix": {
|
|
36
|
+
"enabled": true,
|
|
37
|
+
"config": {
|
|
38
|
+
"nodeId": "cloud-01",
|
|
39
|
+
"secret": "your-shared-secret-min-16-chars",
|
|
40
|
+
"listen": true,
|
|
41
|
+
"listenPort": 19000,
|
|
42
|
+
"agents": [
|
|
43
|
+
{ "id": "reviewer", "description": "代码审查", "tags": ["review"] }
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**内网节点**(有模型和仓库):
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"nodeId": "office-01",
|
|
57
|
+
"secret": "your-shared-secret-min-16-chars",
|
|
58
|
+
"peers": [{ "nodeId": "cloud-01", "url": "wss://cloud-01.example.com:19000" }],
|
|
59
|
+
"agents": [{ "id": "coder", "description": "代码开发", "tags": ["coding"] }],
|
|
60
|
+
"models": [
|
|
61
|
+
{ "id": "claude-sonnet", "provider": "anthropic" },
|
|
62
|
+
{ "id": "deepseek-coder", "provider": "ollama" }
|
|
63
|
+
],
|
|
64
|
+
"toolProxy": { "enabled": true, "allow": ["exec", "read", "write", "edit"] }
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**家庭节点**(借用集群资源):
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"nodeId": "home-01",
|
|
73
|
+
"secret": "your-shared-secret-min-16-chars",
|
|
74
|
+
"peers": [{ "nodeId": "cloud-01", "url": "wss://cloud-01.example.com:19000" }],
|
|
75
|
+
"agents": [{ "id": "assistant", "description": "个人助手", "tags": ["general"] }],
|
|
76
|
+
"proxyModels": [{ "id": "claude-sonnet", "nodeId": "office-01" }]
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**重要**:共享模型的节点必须开启 chatCompletions 端点:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"gateway": {
|
|
85
|
+
"http": {
|
|
86
|
+
"endpoints": {
|
|
87
|
+
"chatCompletions": { "enabled": true }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
消费远程模型的节点需要在 `models.providers` 中注册(以 nodeId 为 key,baseUrl 指向 `http://127.0.0.1:19001`),详见 [BOOTSTRAP.md](BOOTSTRAP.md)。
|
|
95
|
+
|
|
96
|
+
使用集群模型:`/model <nodeId>/<模型ID>`。
|
|
97
|
+
|
|
98
|
+
### 启动
|
|
4
99
|
|
|
5
100
|
```bash
|
|
6
|
-
|
|
101
|
+
openclaw gateway restart
|
|
102
|
+
openclaw clawmatrix status
|
|
7
103
|
```
|
|
8
104
|
|
|
9
|
-
|
|
105
|
+
## Agent 工具
|
|
106
|
+
|
|
107
|
+
安装后 Agent 自动获得以下工具:
|
|
108
|
+
|
|
109
|
+
| 工具 | 说明 |
|
|
110
|
+
|------|------|
|
|
111
|
+
| `cluster_handoff` | 委托任务给远程 Agent,等待结果 |
|
|
112
|
+
| `cluster_send` | 向远程 Agent 发送单向消息 |
|
|
113
|
+
| `cluster_peers` | 查看集群拓扑和连接状态 |
|
|
114
|
+
| `cluster_exec` | 在远程节点执行命令 |
|
|
115
|
+
| `cluster_read` | 读取远程节点文件 |
|
|
116
|
+
| `cluster_write` | 写入远程节点文件 |
|
|
117
|
+
| `cluster_tool` | 调用远程节点任意 OpenClaw 工具 |
|
|
118
|
+
|
|
119
|
+
目标参数支持 nodeId(`"office-01"`)或标签查询(`"tags:coding"`)。
|
|
120
|
+
|
|
121
|
+
## 配置参考
|
|
122
|
+
|
|
123
|
+
| 字段 | 类型 | 默认值 | 说明 |
|
|
124
|
+
|------|------|--------|------|
|
|
125
|
+
| `nodeId` | string | 必填 | 节点唯一标识 |
|
|
126
|
+
| `secret` | string | 必填 | 集群共享密钥(>= 16 字符) |
|
|
127
|
+
| `listen` | boolean | `false` | 接受入站 WS 连接 |
|
|
128
|
+
| `listenHost` | string | `"0.0.0.0"` | 监听地址 |
|
|
129
|
+
| `listenPort` | number | `19000` | 入站 WS 端口 |
|
|
130
|
+
| `peers` | array | `[]` | 要连接的 peer:`{ nodeId, url }` |
|
|
131
|
+
| `agents` | array | `[]` | 本节点 Agent:`{ id, description, tags }` |
|
|
132
|
+
| `models` | array | `[]` | 共享给集群的模型:`{ id, provider }` |
|
|
133
|
+
| `proxyModels` | array | `[]` | 要消费的远程模型:`{ id, nodeId? }` |
|
|
134
|
+
| `tags` | array | `[]` | 自由标签 |
|
|
135
|
+
| `proxyPort` | number | `19001` | 本地模型代理端口 |
|
|
136
|
+
| `handoffTimeout` | number | `600000` | Handoff 超时(ms) |
|
|
137
|
+
| `toolProxy.enabled` | boolean | `false` | 允许远程工具执行 |
|
|
138
|
+
| `toolProxy.allow` | array | `[]` | 允许的工具(`[]` = 全部) |
|
|
139
|
+
| `toolProxy.deny` | array | `[]` | 禁止的工具(优先) |
|
|
140
|
+
|
|
141
|
+
## 架构
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
┌──────────────────────────┐
|
|
145
|
+
│ 公网 Gateway │
|
|
146
|
+
│ listen: true │
|
|
147
|
+
│ listenPort: 19000 │
|
|
148
|
+
└──┬──────────────────┬────┘
|
|
149
|
+
inbound │ │ inbound
|
|
150
|
+
WS conn │ │ WS conn
|
|
151
|
+
┌──┴────┐ ┌────┴──────┐
|
|
152
|
+
│ 家庭 │ │ 内网办公室 │
|
|
153
|
+
└───────┘ └──────────┘
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- 去中心化 mesh,无 leader,无共识协议
|
|
157
|
+
- HMAC-SHA256 challenge-response 认证
|
|
158
|
+
- 消息中继 + TTL 防环 + ID 去重
|
|
159
|
+
- 心跳检测 + 指数退避重连 + 自动故障转移
|
|
160
|
+
- 生产环境建议 `wss://`(TLS)
|
|
161
|
+
|
|
162
|
+
## 开发
|
|
10
163
|
|
|
11
164
|
```bash
|
|
12
|
-
bun
|
|
165
|
+
bun install # 安装依赖
|
|
166
|
+
bun test # 运行测试
|
|
13
167
|
```
|
|
14
168
|
|
|
15
|
-
|
|
169
|
+
## 文档
|
|
170
|
+
|
|
171
|
+
- [技术规格](docs/SPEC.md) — 完整协议、消息类型、安全设计
|
|
172
|
+
- [安装指南](BOOTSTRAP.md) — AI Agent 辅助安装配置
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clawmatrix",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
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",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"src/",
|
|
19
19
|
"!src/**/*.test.ts",
|
|
20
20
|
"openclaw.plugin.json",
|
|
21
|
-
"
|
|
21
|
+
"BOOTSTRAP.md",
|
|
22
22
|
"README.md"
|
|
23
23
|
],
|
|
24
24
|
"openclaw": {
|
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
|
-
import {
|
|
2
|
+
import { spawnProcess } from "./compat.ts";
|
|
3
|
+
|
|
4
|
+
async function callGateway(method: string): Promise<unknown> {
|
|
5
|
+
const proc = spawnProcess(["openclaw", "gateway", "call", method, "--json"], {
|
|
6
|
+
stdout: "pipe",
|
|
7
|
+
stderr: "pipe",
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const stdoutChunks: Uint8Array[] = [];
|
|
11
|
+
const stderrChunks: Uint8Array[] = [];
|
|
12
|
+
|
|
13
|
+
const readStream = async (stream: ReadableStream | null, target: Uint8Array[]) => {
|
|
14
|
+
if (!stream) return;
|
|
15
|
+
const reader = stream.getReader();
|
|
16
|
+
while (true) {
|
|
17
|
+
const { done, value } = await reader.read();
|
|
18
|
+
if (done) break;
|
|
19
|
+
target.push(value);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
await Promise.all([
|
|
24
|
+
readStream(proc.stdout, stdoutChunks),
|
|
25
|
+
readStream(proc.stderr, stderrChunks),
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const code = await proc.exited;
|
|
29
|
+
const stdout = Buffer.concat(stdoutChunks).toString("utf-8").trim();
|
|
30
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
|
|
31
|
+
|
|
32
|
+
if (code !== 0) {
|
|
33
|
+
// Extract meaningful error from stderr
|
|
34
|
+
const errLine = stderr.split("\n").find((l) => l.includes("Error:") || l.includes("error"));
|
|
35
|
+
throw new Error(errLine || stderr || "Gateway call failed (exit code " + code + ")");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!stdout) {
|
|
39
|
+
throw new Error("Empty response from gateway");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return JSON.parse(stdout);
|
|
43
|
+
}
|
|
3
44
|
|
|
4
45
|
export const registerClusterCli = ({ program }: { program: Command }) => {
|
|
5
46
|
const cmd = program.command("clawmatrix").description("ClawMatrix cluster management");
|
|
@@ -7,48 +48,71 @@ export const registerClusterCli = ({ program }: { program: Command }) => {
|
|
|
7
48
|
cmd
|
|
8
49
|
.command("status")
|
|
9
50
|
.description("Show cluster topology and peer status")
|
|
10
|
-
.action(() => {
|
|
11
|
-
let
|
|
51
|
+
.action(async () => {
|
|
52
|
+
let data: Record<string, unknown>;
|
|
12
53
|
try {
|
|
13
|
-
|
|
54
|
+
data = (await callGateway("clawmatrix.status")) as Record<string, unknown>;
|
|
14
55
|
} catch {
|
|
15
|
-
console.log("
|
|
56
|
+
console.log("Could not reach gateway. Is it running?");
|
|
16
57
|
return;
|
|
17
58
|
}
|
|
18
59
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
console.log(
|
|
29
|
-
console.log(
|
|
30
|
-
`Models: [${config.models.map((m) => m.id).join(", ")}]`,
|
|
31
|
-
);
|
|
60
|
+
if (data.error) {
|
|
61
|
+
console.log(String(data.error));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const agents = data.agents as Array<{ id: string }>;
|
|
66
|
+
const models = data.models as Array<{ id: string }>;
|
|
67
|
+
const tags = data.tags as string[];
|
|
68
|
+
|
|
69
|
+
console.log("");
|
|
70
|
+
console.log("ClawMatrix Cluster");
|
|
32
71
|
console.log("");
|
|
72
|
+
console.log(` Node: ${data.nodeId}`);
|
|
73
|
+
if (tags.length > 0) {
|
|
74
|
+
console.log(` Tags: ${tags.join(", ")}`);
|
|
75
|
+
}
|
|
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(", ") || "-"}`);
|
|
80
|
+
|
|
81
|
+
const peers = data.peers as Array<{
|
|
82
|
+
nodeId: string;
|
|
83
|
+
agents: Array<{ id: string }>;
|
|
84
|
+
models: Array<{ id: string }>;
|
|
85
|
+
tags: string[];
|
|
86
|
+
connected: boolean;
|
|
87
|
+
latencyMs: number;
|
|
88
|
+
}>;
|
|
33
89
|
|
|
34
|
-
|
|
35
|
-
|
|
90
|
+
const connected = peers.filter((p) => p.connected).length;
|
|
91
|
+
console.log("");
|
|
92
|
+
if (!peers || peers.length === 0) {
|
|
93
|
+
console.log(" No peers discovered.");
|
|
94
|
+
console.log("");
|
|
36
95
|
return;
|
|
37
96
|
}
|
|
38
97
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
console.log(header);
|
|
42
|
-
console.log("-".repeat(header.length));
|
|
98
|
+
console.log(`Peers (${connected}/${peers.length} connected)`);
|
|
99
|
+
console.log("");
|
|
43
100
|
|
|
44
101
|
for (const peer of peers) {
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
);
|
|
102
|
+
const status = peer.connected ? "connected" : "unreachable";
|
|
103
|
+
const latency = peer.latencyMs > 0 ? `, ${peer.latencyMs}ms` : "";
|
|
104
|
+
console.log(` ${peer.nodeId} (${status}${latency})`);
|
|
105
|
+
if (peer.tags.length > 0) {
|
|
106
|
+
console.log(` Tags: ${peer.tags.join(", ")}`);
|
|
107
|
+
}
|
|
108
|
+
const peerAgents = peer.agents.map((a) => a.id).join(", ");
|
|
109
|
+
if (peerAgents) {
|
|
110
|
+
console.log(` Agents: ${peerAgents}`);
|
|
111
|
+
}
|
|
112
|
+
const peerModels = peer.models.map((m) => m.id).join(", ");
|
|
113
|
+
if (peerModels) {
|
|
114
|
+
console.log(` Models: ${peerModels}`);
|
|
115
|
+
}
|
|
52
116
|
}
|
|
53
117
|
console.log("");
|
|
54
118
|
});
|
|
@@ -56,29 +120,14 @@ export const registerClusterCli = ({ program }: { program: Command }) => {
|
|
|
56
120
|
cmd
|
|
57
121
|
.command("peers")
|
|
58
122
|
.description("List known peers (JSON)")
|
|
59
|
-
.action(() => {
|
|
60
|
-
let
|
|
123
|
+
.action(async () => {
|
|
124
|
+
let peers: unknown;
|
|
61
125
|
try {
|
|
62
|
-
|
|
126
|
+
peers = await callGateway("clawmatrix.peers");
|
|
63
127
|
} catch {
|
|
64
128
|
console.log("[]");
|
|
65
129
|
return;
|
|
66
130
|
}
|
|
67
|
-
|
|
68
|
-
const peers = runtime.peerManager.router.getAllPeers().map((p) => ({
|
|
69
|
-
nodeId: p.nodeId,
|
|
70
|
-
agents: p.agents,
|
|
71
|
-
models: p.models,
|
|
72
|
-
tags: p.tags,
|
|
73
|
-
connected: !!p.connection?.isOpen,
|
|
74
|
-
reachableVia: p.reachableVia,
|
|
75
|
-
latencyMs: p.latencyMs,
|
|
76
|
-
}));
|
|
77
131
|
console.log(JSON.stringify(peers, null, 2));
|
|
78
132
|
});
|
|
79
133
|
};
|
|
80
|
-
|
|
81
|
-
function padRow(...cols: string[]): string {
|
|
82
|
-
const widths = [16, 24, 30, 14, 8];
|
|
83
|
-
return cols.map((c, i) => c.padEnd(widths[i] ?? 12)).join(" ");
|
|
84
|
-
}
|
package/src/cluster-service.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
AnyClusterFrame,
|
|
15
15
|
HandoffRequest,
|
|
16
16
|
HandoffResponse,
|
|
17
|
+
HandoffStreamChunk,
|
|
17
18
|
ModelRequest,
|
|
18
19
|
ModelResponse,
|
|
19
20
|
ModelStreamChunk,
|
|
@@ -93,6 +94,9 @@ export class ClusterRuntime {
|
|
|
93
94
|
this.logger.error(`[clawmatrix] Handoff request error: ${err}`);
|
|
94
95
|
});
|
|
95
96
|
break;
|
|
97
|
+
case "handoff_stream":
|
|
98
|
+
this.handoffManager.handleStream(frame as HandoffStreamChunk);
|
|
99
|
+
break;
|
|
96
100
|
case "handoff_res":
|
|
97
101
|
this.handoffManager.handleResponse(frame as HandoffResponse);
|
|
98
102
|
break;
|
package/src/config.ts
CHANGED
|
@@ -63,6 +63,7 @@ export const ClawMatrixConfigSchema = z.object({
|
|
|
63
63
|
tags: z.array(z.string()).default([]),
|
|
64
64
|
proxyPort: z.number().default(19001),
|
|
65
65
|
toolProxy: ToolProxyConfigSchema.optional(),
|
|
66
|
+
handoffTimeout: z.number().default(600_000),
|
|
66
67
|
});
|
|
67
68
|
|
|
68
69
|
export type ClawMatrixConfig = z.infer<typeof ClawMatrixConfigSchema>;
|
package/src/handoff.ts
CHANGED
|
@@ -4,10 +4,10 @@ import { spawnProcess } from "./compat.ts";
|
|
|
4
4
|
import type {
|
|
5
5
|
HandoffRequest,
|
|
6
6
|
HandoffResponse,
|
|
7
|
-
|
|
7
|
+
HandoffStreamChunk,
|
|
8
8
|
} from "./types.ts";
|
|
9
9
|
|
|
10
|
-
const
|
|
10
|
+
const DEFAULT_HANDOFF_TIMEOUT = 600_000; // 10 minutes (resets on each stream chunk)
|
|
11
11
|
const MAX_RETRIES = 2;
|
|
12
12
|
|
|
13
13
|
interface PendingHandoff {
|
|
@@ -18,6 +18,7 @@ interface PendingHandoff {
|
|
|
18
18
|
retriesLeft: number;
|
|
19
19
|
task: string;
|
|
20
20
|
context?: string;
|
|
21
|
+
accumulated: string;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export class HandoffManager {
|
|
@@ -54,24 +55,9 @@ export class HandoffManager {
|
|
|
54
55
|
const id = crypto.randomUUID();
|
|
55
56
|
|
|
56
57
|
return new Promise<HandoffResponse["payload"]>((resolve, reject) => {
|
|
57
|
-
const timer =
|
|
58
|
-
this.pending.delete(id);
|
|
59
|
-
this.peerManager.router.markFailed(id);
|
|
60
|
-
|
|
61
|
-
// Retry with failover
|
|
62
|
-
if (retriesLeft > 0) {
|
|
63
|
-
const nextRoute = this.peerManager.router.resolveAgent(target);
|
|
64
|
-
if (nextRoute && nextRoute.nodeId !== targetNodeId) {
|
|
65
|
-
this.sendHandoff(nextRoute.nodeId, target, task, context, retriesLeft - 1)
|
|
66
|
-
.then(resolve)
|
|
67
|
-
.catch(reject);
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
reject(new Error(`Handoff to "${target}" timed out`));
|
|
72
|
-
}, HANDOFF_TIMEOUT);
|
|
58
|
+
const timer = this.createTimeout(id, targetNodeId, target, task, context, retriesLeft, resolve, reject);
|
|
73
59
|
|
|
74
|
-
this.pending.set(id, { resolve, reject, timer, target, retriesLeft, task, context });
|
|
60
|
+
this.pending.set(id, { resolve, reject, timer, target, retriesLeft, task, context, accumulated: "" });
|
|
75
61
|
|
|
76
62
|
const frame: HandoffRequest = {
|
|
77
63
|
type: "handoff_req",
|
|
@@ -102,6 +88,57 @@ export class HandoffManager {
|
|
|
102
88
|
});
|
|
103
89
|
}
|
|
104
90
|
|
|
91
|
+
private createTimeout(
|
|
92
|
+
id: string,
|
|
93
|
+
targetNodeId: string,
|
|
94
|
+
target: string,
|
|
95
|
+
task: string,
|
|
96
|
+
context: string | undefined,
|
|
97
|
+
retriesLeft: number,
|
|
98
|
+
resolve: (result: HandoffResponse["payload"]) => void,
|
|
99
|
+
reject: (error: Error) => void,
|
|
100
|
+
): ReturnType<typeof setTimeout> {
|
|
101
|
+
return setTimeout(() => {
|
|
102
|
+
this.pending.delete(id);
|
|
103
|
+
this.peerManager.router.markFailed(id);
|
|
104
|
+
|
|
105
|
+
// Retry with failover
|
|
106
|
+
if (retriesLeft > 0) {
|
|
107
|
+
const nextRoute = this.peerManager.router.resolveAgent(target);
|
|
108
|
+
if (nextRoute && nextRoute.nodeId !== targetNodeId) {
|
|
109
|
+
this.sendHandoff(nextRoute.nodeId, target, task, context, retriesLeft - 1)
|
|
110
|
+
.then(resolve)
|
|
111
|
+
.catch(reject);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
reject(new Error(`Handoff to "${target}" timed out`));
|
|
116
|
+
}, this.config.handoffTimeout ?? DEFAULT_HANDOFF_TIMEOUT);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Handle incoming stream chunk — reset timeout & accumulate. */
|
|
120
|
+
handleStream(frame: HandoffStreamChunk) {
|
|
121
|
+
if (this.peerManager.router.isFailed(frame.id)) return;
|
|
122
|
+
|
|
123
|
+
const pending = this.pending.get(frame.id);
|
|
124
|
+
if (!pending) return;
|
|
125
|
+
|
|
126
|
+
pending.accumulated += frame.payload.delta;
|
|
127
|
+
|
|
128
|
+
// Reset timeout — the remote agent is still working
|
|
129
|
+
clearTimeout(pending.timer);
|
|
130
|
+
pending.timer = this.createTimeout(
|
|
131
|
+
frame.id,
|
|
132
|
+
frame.from,
|
|
133
|
+
pending.target,
|
|
134
|
+
pending.task,
|
|
135
|
+
pending.context,
|
|
136
|
+
pending.retriesLeft,
|
|
137
|
+
pending.resolve,
|
|
138
|
+
pending.reject,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
105
142
|
/** Handle incoming handoff response. */
|
|
106
143
|
handleResponse(frame: HandoffResponse) {
|
|
107
144
|
if (this.peerManager.router.isFailed(frame.id)) return;
|
|
@@ -111,6 +148,12 @@ export class HandoffManager {
|
|
|
111
148
|
|
|
112
149
|
clearTimeout(pending.timer);
|
|
113
150
|
this.pending.delete(frame.id);
|
|
151
|
+
|
|
152
|
+
// If the response has no result but we accumulated stream data, use that
|
|
153
|
+
if (frame.payload.success && !frame.payload.result && pending.accumulated) {
|
|
154
|
+
frame.payload.result = pending.accumulated;
|
|
155
|
+
}
|
|
156
|
+
|
|
114
157
|
pending.resolve(frame.payload);
|
|
115
158
|
}
|
|
116
159
|
|
|
@@ -153,7 +196,8 @@ export class HandoffManager {
|
|
|
153
196
|
{ stdout: "pipe", stderr: "pipe" },
|
|
154
197
|
);
|
|
155
198
|
|
|
156
|
-
|
|
199
|
+
// Stream stdout chunks back to the caller
|
|
200
|
+
const fullOutput = await this.streamStdout(proc.stdout, id, from);
|
|
157
201
|
const exitCode = await proc.exited;
|
|
158
202
|
|
|
159
203
|
if (exitCode !== 0) {
|
|
@@ -171,7 +215,7 @@ export class HandoffManager {
|
|
|
171
215
|
success: true,
|
|
172
216
|
nodeId: this.config.nodeId,
|
|
173
217
|
agent: agent.id,
|
|
174
|
-
result:
|
|
218
|
+
result: fullOutput.trim(),
|
|
175
219
|
},
|
|
176
220
|
} satisfies HandoffResponse);
|
|
177
221
|
} catch (err) {
|
|
@@ -191,9 +235,55 @@ export class HandoffManager {
|
|
|
191
235
|
}
|
|
192
236
|
}
|
|
193
237
|
|
|
238
|
+
/** Read stdout incrementally, sending handoff_stream chunks to the caller. */
|
|
239
|
+
private async streamStdout(
|
|
240
|
+
stdout: ReadableStream | null,
|
|
241
|
+
handoffId: string,
|
|
242
|
+
to: string,
|
|
243
|
+
): Promise<string> {
|
|
244
|
+
if (!stdout) return "";
|
|
245
|
+
|
|
246
|
+
const reader = stdout.getReader();
|
|
247
|
+
const decoder = new TextDecoder();
|
|
248
|
+
let full = "";
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
while (true) {
|
|
252
|
+
const { done, value } = await reader.read();
|
|
253
|
+
if (done) break;
|
|
254
|
+
|
|
255
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
256
|
+
full += chunk;
|
|
257
|
+
|
|
258
|
+
this.peerManager.sendTo(to, {
|
|
259
|
+
type: "handoff_stream",
|
|
260
|
+
id: handoffId,
|
|
261
|
+
from: this.config.nodeId,
|
|
262
|
+
to,
|
|
263
|
+
timestamp: Date.now(),
|
|
264
|
+
payload: { delta: chunk, done: false },
|
|
265
|
+
} satisfies HandoffStreamChunk);
|
|
266
|
+
}
|
|
267
|
+
} finally {
|
|
268
|
+
reader.releaseLock();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Send final done marker
|
|
272
|
+
this.peerManager.sendTo(to, {
|
|
273
|
+
type: "handoff_stream",
|
|
274
|
+
id: handoffId,
|
|
275
|
+
from: this.config.nodeId,
|
|
276
|
+
to,
|
|
277
|
+
timestamp: Date.now(),
|
|
278
|
+
payload: { delta: "", done: true },
|
|
279
|
+
} satisfies HandoffStreamChunk);
|
|
280
|
+
|
|
281
|
+
return full;
|
|
282
|
+
}
|
|
283
|
+
|
|
194
284
|
/** Clean up on shutdown. */
|
|
195
285
|
destroy() {
|
|
196
|
-
for (const [
|
|
286
|
+
for (const [, pending] of this.pending) {
|
|
197
287
|
clearTimeout(pending.timer);
|
|
198
288
|
pending.reject(new Error("Shutting down"));
|
|
199
289
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
1
|
+
import type { OpenClawPluginApi, GatewayRequestHandlerOptions } from "openclaw/plugin-sdk";
|
|
2
2
|
import { ClawMatrixConfigSchema, parseConfig } from "./config.ts";
|
|
3
3
|
import { createClusterService, getClusterRuntime } from "./cluster-service.ts";
|
|
4
4
|
import { createClusterHandoffTool } from "./tools/cluster-handoff.ts";
|
|
@@ -41,18 +41,36 @@ const plugin = {
|
|
|
41
41
|
// Background service: manages mesh connections, WS listener, heartbeat
|
|
42
42
|
api.registerService(createClusterService(config, api.config));
|
|
43
43
|
|
|
44
|
-
// Model
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
44
|
+
// Model providers: register per-node providers so models are accessed as nodeId/modelId
|
|
45
|
+
const baseUrl = `http://127.0.0.1:${config.proxyPort}/v1`;
|
|
46
|
+
const modelsByNode = groupModelsByNode(config);
|
|
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>;
|
|
52
|
+
|
|
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
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
api.registerProvider({
|
|
63
|
+
id: nodeId,
|
|
64
|
+
label: `ClawMatrix: ${nodeId}`,
|
|
65
|
+
docsPath: "/plugins/clawmatrix",
|
|
66
|
+
auth: [],
|
|
67
|
+
models: {
|
|
68
|
+
baseUrl,
|
|
69
|
+
apiKey: "sk-clawmatrix-proxy",
|
|
70
|
+
models,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
56
74
|
|
|
57
75
|
// Agent tools
|
|
58
76
|
api.registerTool(createClusterHandoffTool(), { optional: true });
|
|
@@ -63,6 +81,57 @@ const plugin = {
|
|
|
63
81
|
api.registerTool(createClusterWriteTool(), { optional: true });
|
|
64
82
|
api.registerTool(createClusterToolTool(), { optional: true });
|
|
65
83
|
|
|
84
|
+
// Gateway methods (queried by CLI via `openclaw gateway call`)
|
|
85
|
+
api.registerGatewayMethod(
|
|
86
|
+
"clawmatrix.status",
|
|
87
|
+
({ respond }: GatewayRequestHandlerOptions) => {
|
|
88
|
+
try {
|
|
89
|
+
const runtime = getClusterRuntime();
|
|
90
|
+
const peers = runtime.peerManager.router.getAllPeers();
|
|
91
|
+
respond(true, {
|
|
92
|
+
nodeId: config.nodeId,
|
|
93
|
+
listen: config.listen ? config.listenPort : false,
|
|
94
|
+
proxyPort: config.proxyPort,
|
|
95
|
+
agents: config.agents.map((a) => ({ id: a.id, description: a.description })),
|
|
96
|
+
models: config.models.map((m) => ({ id: m.id })),
|
|
97
|
+
tags: config.tags,
|
|
98
|
+
peers: peers.map((p) => ({
|
|
99
|
+
nodeId: p.nodeId,
|
|
100
|
+
agents: p.agents,
|
|
101
|
+
models: p.models,
|
|
102
|
+
tags: p.tags,
|
|
103
|
+
connected: !!p.connection?.isOpen,
|
|
104
|
+
reachableVia: p.reachableVia,
|
|
105
|
+
latencyMs: p.latencyMs,
|
|
106
|
+
})),
|
|
107
|
+
});
|
|
108
|
+
} catch {
|
|
109
|
+
respond(false, { error: "ClawMatrix service not running" });
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
api.registerGatewayMethod(
|
|
115
|
+
"clawmatrix.peers",
|
|
116
|
+
({ respond }: GatewayRequestHandlerOptions) => {
|
|
117
|
+
try {
|
|
118
|
+
const runtime = getClusterRuntime();
|
|
119
|
+
const peers = runtime.peerManager.router.getAllPeers().map((p) => ({
|
|
120
|
+
nodeId: p.nodeId,
|
|
121
|
+
agents: p.agents,
|
|
122
|
+
models: p.models,
|
|
123
|
+
tags: p.tags,
|
|
124
|
+
connected: !!p.connection?.isOpen,
|
|
125
|
+
reachableVia: p.reachableVia,
|
|
126
|
+
latencyMs: p.latencyMs,
|
|
127
|
+
}));
|
|
128
|
+
respond(true, peers);
|
|
129
|
+
} catch {
|
|
130
|
+
respond(true, []);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
|
|
66
135
|
// CLI subcommand
|
|
67
136
|
api.registerCli(registerClusterCli, { commands: ["clawmatrix"] });
|
|
68
137
|
|
|
@@ -117,17 +186,8 @@ const plugin = {
|
|
|
117
186
|
},
|
|
118
187
|
};
|
|
119
188
|
|
|
120
|
-
function
|
|
121
|
-
|
|
122
|
-
// proxyModels = remote models this node wants to consume from the cluster
|
|
123
|
-
// Both need to be registered with the OpenClaw provider so it routes requests to our local proxy.
|
|
124
|
-
const seen = new Set<string>();
|
|
125
|
-
const all = [...config.models, ...config.proxyModels].filter((m) => {
|
|
126
|
-
if (seen.has(m.id)) return false;
|
|
127
|
-
seen.add(m.id);
|
|
128
|
-
return true;
|
|
129
|
-
});
|
|
130
|
-
return all.map((m) => ({
|
|
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 {
|
|
131
191
|
id: m.id,
|
|
132
192
|
name: m.description ?? m.id,
|
|
133
193
|
api: m.api ?? ("openai-completions" as const),
|
|
@@ -136,7 +196,25 @@ function getAllClusterModels(config: ReturnType<typeof parseConfig>) {
|
|
|
136
196
|
cost: m.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
137
197
|
contextWindow: m.contextWindow ?? 128_000,
|
|
138
198
|
maxTokens: m.maxTokens ?? 4096,
|
|
139
|
-
}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function groupModelsByNode(config: ReturnType<typeof parseConfig>): Record<string, ReturnType<typeof formatModel>[]> {
|
|
203
|
+
const result: Record<string, ReturnType<typeof formatModel>[]> = {};
|
|
204
|
+
|
|
205
|
+
// Local models served by this node
|
|
206
|
+
for (const m of config.models) {
|
|
207
|
+
const nodeId = config.nodeId;
|
|
208
|
+
(result[nodeId] ??= []).push(formatModel(m));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Remote models consumed from peers (proxyModels have nodeId)
|
|
212
|
+
for (const m of config.proxyModels) {
|
|
213
|
+
const nodeId = m.nodeId;
|
|
214
|
+
(result[nodeId] ??= []).push(formatModel(m));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return result;
|
|
140
218
|
}
|
|
141
219
|
|
|
142
220
|
export default plugin;
|
package/src/model-proxy.ts
CHANGED
|
@@ -129,6 +129,17 @@ export class ModelProxy {
|
|
|
129
129
|
};
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
// Inject model identity so the LLM knows what it is
|
|
133
|
+
const messages = body.messages;
|
|
134
|
+
if (proxyModel?.description) {
|
|
135
|
+
const first = messages[0] as { role?: string; content?: string } | undefined;
|
|
136
|
+
if (first?.role === "system" && typeof first.content === "string") {
|
|
137
|
+
first.content = `[Model: ${proxyModel.description}]\n${first.content}`;
|
|
138
|
+
} else {
|
|
139
|
+
messages.unshift({ role: "system", content: `[Model: ${proxyModel.description}]` });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
132
143
|
const stream = body.stream ?? false;
|
|
133
144
|
const requestId = crypto.randomUUID();
|
|
134
145
|
|
|
@@ -140,7 +151,7 @@ export class ModelProxy {
|
|
|
140
151
|
timestamp: Date.now(),
|
|
141
152
|
payload: {
|
|
142
153
|
model: modelId,
|
|
143
|
-
messages
|
|
154
|
+
messages,
|
|
144
155
|
temperature: body.temperature,
|
|
145
156
|
maxTokens: body.max_tokens,
|
|
146
157
|
stream,
|
|
@@ -427,7 +438,8 @@ export class ModelProxy {
|
|
|
427
438
|
|
|
428
439
|
try {
|
|
429
440
|
const parsed = JSON.parse(data);
|
|
430
|
-
const
|
|
441
|
+
const d = parsed.choices?.[0]?.delta;
|
|
442
|
+
const delta = d?.content || d?.reasoning_content || "";
|
|
431
443
|
if (delta) {
|
|
432
444
|
this.peerManager.sendTo(from, {
|
|
433
445
|
type: "model_stream",
|
|
@@ -445,10 +457,11 @@ export class ModelProxy {
|
|
|
445
457
|
}
|
|
446
458
|
} else {
|
|
447
459
|
const result = (await response.json()) as {
|
|
448
|
-
choices?: { message?: { content?: string } }[];
|
|
460
|
+
choices?: { message?: { content?: string; reasoning_content?: string } }[];
|
|
449
461
|
usage?: { prompt_tokens: number; completion_tokens: number };
|
|
450
462
|
};
|
|
451
|
-
const
|
|
463
|
+
const msg = result.choices?.[0]?.message;
|
|
464
|
+
const content = msg?.content || msg?.reasoning_content || "";
|
|
452
465
|
const usage = result.usage;
|
|
453
466
|
|
|
454
467
|
this.peerManager.sendTo(from, {
|
package/src/router.ts
CHANGED
|
@@ -120,6 +120,12 @@ export class Router {
|
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
// Fallback: if no agent ID or tag matched, try matching by nodeId
|
|
124
|
+
if (candidates.length === 0 && !isTagQuery) {
|
|
125
|
+
const byNode = this.routes.get(target);
|
|
126
|
+
if (byNode) candidates.push(byNode);
|
|
127
|
+
}
|
|
128
|
+
|
|
123
129
|
if (candidates.length === 0) return undefined;
|
|
124
130
|
|
|
125
131
|
// Sort: direct connections first, then by latency
|
package/src/types.ts
CHANGED
|
@@ -113,6 +113,15 @@ export interface HandoffRequest extends ClusterFrame {
|
|
|
113
113
|
};
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
export interface HandoffStreamChunk extends ClusterFrame {
|
|
117
|
+
type: "handoff_stream";
|
|
118
|
+
id: string;
|
|
119
|
+
payload: {
|
|
120
|
+
delta: string;
|
|
121
|
+
done: boolean;
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
116
125
|
export interface HandoffResponse extends ClusterFrame {
|
|
117
126
|
type: "handoff_res";
|
|
118
127
|
id: string;
|
|
@@ -197,6 +206,7 @@ export type AnyClusterFrame =
|
|
|
197
206
|
| ModelResponse
|
|
198
207
|
| ModelStreamChunk
|
|
199
208
|
| HandoffRequest
|
|
209
|
+
| HandoffStreamChunk
|
|
200
210
|
| HandoffResponse
|
|
201
211
|
| SendMessage
|
|
202
212
|
| ToolProxyRequest
|
package/llms.txt
DELETED
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
# ClawMatrix — OpenClaw Mesh Cluster Plugin
|
|
2
|
-
|
|
3
|
-
> Decentralized mesh network plugin for OpenClaw. Multiple Gateways install the same plugin, form a peer-to-peer mesh via WebSocket, and share agents, models, and tools across nodes.
|
|
4
|
-
|
|
5
|
-
## Install
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
openclaw plugins install clawmatrix
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
Then restart the Gateway:
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
openclaw gateway restart
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
## What it does
|
|
18
|
-
|
|
19
|
-
- **Model Proxy** — Use LLMs hosted on remote nodes as if they were local. A local HTTP proxy bridges cluster WebSocket to OpenAI-compatible API.
|
|
20
|
-
- **Task Handoff** — Delegate complex tasks to agents running on other nodes (e.g. hand off to an internal "coder" agent that has repo access).
|
|
21
|
-
- **Tool Proxy** — Invoke any OpenClaw tool (exec, read, write, edit, web_search, etc.) on remote nodes without delegating the entire task.
|
|
22
|
-
- **Gossip Discovery** — Nodes discover each other via gossip protocol; no central registry needed.
|
|
23
|
-
- **Auto Failover** — If a node goes down, requests route to backup nodes automatically.
|
|
24
|
-
|
|
25
|
-
## Configuration
|
|
26
|
-
|
|
27
|
-
Add to `plugins.entries.clawmatrix.config` in your `openclaw.json`:
|
|
28
|
-
|
|
29
|
-
### Public node (relay + own agents)
|
|
30
|
-
|
|
31
|
-
```json
|
|
32
|
-
{
|
|
33
|
-
"nodeId": "cloud-01",
|
|
34
|
-
"secret": "your-shared-secret",
|
|
35
|
-
"listen": true,
|
|
36
|
-
"listenPort": 19000,
|
|
37
|
-
"peers": [],
|
|
38
|
-
"agents": [
|
|
39
|
-
{ "id": "reviewer", "description": "Reviews code and PRs", "tags": ["review"] }
|
|
40
|
-
],
|
|
41
|
-
"models": [],
|
|
42
|
-
"tags": ["cloud"]
|
|
43
|
-
}
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
### Internal node (has models + code repos)
|
|
47
|
-
|
|
48
|
-
```json
|
|
49
|
-
{
|
|
50
|
-
"nodeId": "office-01",
|
|
51
|
-
"secret": "your-shared-secret",
|
|
52
|
-
"listen": false,
|
|
53
|
-
"peers": [
|
|
54
|
-
{ "nodeId": "cloud-01", "url": "wss://cloud-01.example.com:19000" }
|
|
55
|
-
],
|
|
56
|
-
"agents": [
|
|
57
|
-
{ "id": "coder", "description": "Writes and debugs code, has repo access", "tags": ["coding"] }
|
|
58
|
-
],
|
|
59
|
-
"models": [
|
|
60
|
-
{ "id": "claude-sonnet", "provider": "anthropic" },
|
|
61
|
-
{ "id": "deepseek-coder", "provider": "ollama" }
|
|
62
|
-
],
|
|
63
|
-
"tags": ["office", "gpu"],
|
|
64
|
-
"toolProxy": {
|
|
65
|
-
"enabled": true,
|
|
66
|
-
"allow": ["exec", "read", "edit", "web_search"],
|
|
67
|
-
"deny": ["write", "browser", "sessions_spawn"]
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
### Home node (lightweight, borrows cluster resources)
|
|
73
|
-
|
|
74
|
-
```json
|
|
75
|
-
{
|
|
76
|
-
"nodeId": "home-01",
|
|
77
|
-
"secret": "your-shared-secret",
|
|
78
|
-
"listen": false,
|
|
79
|
-
"peers": [
|
|
80
|
-
{ "nodeId": "cloud-01", "url": "wss://cloud-01.example.com:19000" }
|
|
81
|
-
],
|
|
82
|
-
"agents": [
|
|
83
|
-
{ "id": "assistant", "description": "Personal assistant", "tags": ["general"] }
|
|
84
|
-
],
|
|
85
|
-
"models": [],
|
|
86
|
-
"tags": ["home"]
|
|
87
|
-
}
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
To use cluster models, set your agent's model to a cluster-proxied model:
|
|
91
|
-
|
|
92
|
-
```json
|
|
93
|
-
{
|
|
94
|
-
"agents": {
|
|
95
|
-
"defaults": {
|
|
96
|
-
"model": "clawmatrix/claude-sonnet"
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
## Config reference
|
|
103
|
-
|
|
104
|
-
| Field | Type | Default | Description |
|
|
105
|
-
|-------|------|---------|-------------|
|
|
106
|
-
| `nodeId` | string | *required* | Unique identifier for this node |
|
|
107
|
-
| `secret` | string | *required* | Shared HMAC secret for cluster authentication |
|
|
108
|
-
| `listen` | boolean | `false` | Accept inbound WebSocket connections |
|
|
109
|
-
| `listenHost` | string | `"0.0.0.0"` | Bind address for WebSocket listener |
|
|
110
|
-
| `listenPort` | number | `19000` | Port for inbound WebSocket connections |
|
|
111
|
-
| `peers` | array | `[]` | List of `{ nodeId, url }` peers to connect to |
|
|
112
|
-
| `agents` | array | `[]` | Agents this node provides: `{ id, description, tags }` |
|
|
113
|
-
| `models` | array | `[]` | Models this node shares: `{ id, provider, description? }` |
|
|
114
|
-
| `tags` | array | `[]` | Free-form tags for capability routing |
|
|
115
|
-
| `proxyPort` | number | `19001` | Local HTTP proxy port for model requests |
|
|
116
|
-
| `toolProxy` | object | — | Tool proxy settings (see below) |
|
|
117
|
-
|
|
118
|
-
### toolProxy
|
|
119
|
-
|
|
120
|
-
| Field | Type | Default | Description |
|
|
121
|
-
|-------|------|---------|-------------|
|
|
122
|
-
| `enabled` | boolean | `false` | Allow remote tool execution on this node |
|
|
123
|
-
| `allow` | array | `[]` | Allowed OpenClaw tool names. `["*"]` or `[]` = all allowed |
|
|
124
|
-
| `deny` | array | `[]` | Denied OpenClaw tool names (takes precedence over allow) |
|
|
125
|
-
| `maxOutputBytes` | number | `1048576` | Max output size per tool response (1 MB) |
|
|
126
|
-
|
|
127
|
-
## Agent tools
|
|
128
|
-
|
|
129
|
-
ClawMatrix registers 7 tools available to agents (tool proxy tools correspond to OpenClaw's built-in tools):
|
|
130
|
-
|
|
131
|
-
### cluster_peers
|
|
132
|
-
List all reachable peers, their agents, and available models.
|
|
133
|
-
|
|
134
|
-
### cluster_handoff
|
|
135
|
-
Hand off a task to another agent in the cluster and wait for the result.
|
|
136
|
-
- `target`: Agent ID or `"tags:<tag>"` expression
|
|
137
|
-
- `task`: Task description
|
|
138
|
-
- `context`: Optional additional context
|
|
139
|
-
|
|
140
|
-
### cluster_send
|
|
141
|
-
Send a one-way message to another agent (injected into their session).
|
|
142
|
-
- `target`: Agent ID or `"tags:<tag>"` expression
|
|
143
|
-
- `message`: Message content
|
|
144
|
-
|
|
145
|
-
### cluster_exec
|
|
146
|
-
Execute a shell command on a remote node (invokes OpenClaw `exec` tool).
|
|
147
|
-
- `node`: Target nodeId or `"tags:<tag>"`
|
|
148
|
-
- `command`: Shell command
|
|
149
|
-
- `workdir`: Optional working directory
|
|
150
|
-
- `timeout`: Optional timeout in seconds (default 1800)
|
|
151
|
-
|
|
152
|
-
### cluster_read
|
|
153
|
-
Read a file from a remote node (invokes OpenClaw `read` tool).
|
|
154
|
-
- `node`: Target nodeId or `"tags:<tag>"`
|
|
155
|
-
- `path`: File path
|
|
156
|
-
|
|
157
|
-
### cluster_write
|
|
158
|
-
Write content to a file on a remote node (invokes OpenClaw `write` tool).
|
|
159
|
-
- `node`: Target nodeId or `"tags:<tag>"`
|
|
160
|
-
- `path`: File path
|
|
161
|
-
- `content`: File content
|
|
162
|
-
|
|
163
|
-
### cluster_tool
|
|
164
|
-
Invoke any OpenClaw tool on a remote node (exec, read, write, edit, web_search, web_fetch, browser, process, etc.).
|
|
165
|
-
- `node`: Target nodeId or `"tags:<tag>"`
|
|
166
|
-
- `tool`: OpenClaw tool name
|
|
167
|
-
- `args`: Tool arguments (tool-specific)
|
|
168
|
-
|
|
169
|
-
## Verify cluster status
|
|
170
|
-
|
|
171
|
-
```bash
|
|
172
|
-
openclaw clawmatrix status
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
## Architecture
|
|
176
|
-
|
|
177
|
-
- Nodes form a decentralized mesh over WebSocket (no leader, no consensus protocol)
|
|
178
|
-
- Authentication via HMAC-SHA256 challenge-response (secret never sent in plaintext)
|
|
179
|
-
- Messages relay through intermediate nodes with TTL-based loop prevention
|
|
180
|
-
- Heartbeat every 15s; 3 missed pings = disconnect + peer_leave broadcast
|
|
181
|
-
- Reconnection with exponential backoff (1s to 60s max)
|
|
182
|
-
- Production deployments should use `wss://` (TLS)
|
|
183
|
-
|
|
184
|
-
## Source
|
|
185
|
-
|
|
186
|
-
- GitHub: https://github.com/nicepkg/clawmatrix
|
|
187
|
-
- npm: https://www.npmjs.com/package/clawmatrix
|