multiclaws 0.4.8 → 0.4.10

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/README.md CHANGED
@@ -1,36 +1,39 @@
1
1
  # MultiClaws
2
2
 
3
- 让多个 OpenClaw 实例通过 [A2A 协议](https://google.github.io/A2A/) 组成团队、互相委派任务。
3
+ Multi-agent collaboration plugin for [OpenClaw](https://openclaw.ai). Connect multiple OpenClaw instances into a team and delegate tasks between them using the [A2A protocol](https://google.github.io/A2A/).
4
4
 
5
- ## 安装
5
+ [中文文档](README.zh-CN.md)
6
6
 
7
- 对你的 AI 说:
7
+ ## Installation
8
8
 
9
- > 请运行 `openclaw plugins install multiclaws`,安装完成后告诉我这个插件能做什么
9
+ Just tell your AI:
10
10
 
11
- AI 会自动完成安装、配置和档案生成,无需手动修改任何文件。
11
+ > Run `openclaw plugins install multiclaws` and tell me what it can do.
12
12
 
13
- ## 使用
13
+ Your AI handles the rest — installation, configuration, and profile setup — no manual steps required.
14
14
 
15
- 一切通过自然语言完成:
15
+ ## Usage
16
16
 
17
- - **「创建一个叫 my-team 的团队」** — 创建团队并获取邀请码
18
- - **「用邀请码 mc:xxxxx 加入团队」** — 加入队友的团队
19
- - **「让 Bob 总结一下最新报告」** — 把任务委派给队友的 AI
20
- - **「显示所有智能体」** — 查看团队成员及其能力
17
+ Everything works through natural language:
21
18
 
22
- ## 跨网络
19
+ - **"Create a team called my-team"** — creates a team and generates an invite code
20
+ - **"Join team with invite code mc:xxxxx"** — join a teammate's team
21
+ - **"Ask Bob to summarize the latest report"** — delegate a task to a teammate's AI
22
+ - **"Show all agents"** — list team members and their capabilities
23
23
 
24
- 同局域网开箱即用。不同网络安装 [Tailscale](https://tailscale.com/download),插件自动检测。
24
+ ## How It Works
25
25
 
26
- ## 详细文档
26
+ MultiClaws enables multiple OpenClaw instances to collaborate as a team. Each instance acts as both a client (delegating tasks) and a server (receiving tasks from others). Tasks are executed by the remote AI and results are returned directly.
27
27
 
28
- [SKILL.md](skills/multiclaws/SKILL.md)。
28
+ Works out of the box on the same local network. Cross-network collaboration is also supported.
29
29
 
30
- ## 开发
30
+ ## Documentation
31
+
32
+ See [SKILL.md](skills/multiclaws/SKILL.md) for full details.
33
+
34
+ ## Development
31
35
 
32
36
  ```bash
33
- pnpm install
34
- pnpm run build
35
- pnpm test
37
+ npm install
38
+ npm run build
36
39
  ```
package/README.zh-CN.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # MultiClaws
2
2
 
3
- 让多个 OpenClaw 实例通过 [A2A 协议](https://google.github.io/A2A/) 组成团队、互相委派任务。
3
+ [OpenClaw](https://openclaw.ai) 多智能体协作插件。将多个 OpenClaw 实例组成团队,通过 [A2A 协议](https://google.github.io/A2A/) 互相委派任务。
4
+
5
+ [English](README.md)
4
6
 
5
7
  ## 安装
6
8
 
@@ -19,9 +21,11 @@ AI 会自动完成安装、配置和档案生成,无需手动修改任何文
19
21
  - **「让 Bob 总结一下最新报告」** — 把任务委派给队友的 AI
20
22
  - **「显示所有智能体」** — 查看团队成员及其能力
21
23
 
22
- ## 跨网络
24
+ ## 工作原理
25
+
26
+ MultiClaws 让多个 OpenClaw 实例作为一个团队协作。每个实例既可以作为客户端(委派任务),也可以作为服务端(接收他人任务)。任务由远端 AI 执行,结果直接返回。
23
27
 
24
- 同局域网开箱即用。不同网络安装 [Tailscale](https://tailscale.com/download),插件自动检测。
28
+ 同局域网开箱即用,也支持跨网络协作。
25
29
 
26
30
  ## 详细文档
27
31
 
@@ -30,7 +34,6 @@ AI 会自动完成安装、配置和档案生成,无需手动修改任何文
30
34
  ## 开发
31
35
 
32
36
  ```bash
33
- pnpm install
34
- pnpm run build
35
- pnpm test
37
+ npm install
38
+ npm run build
36
39
  ```
package/dist/index.js CHANGED
@@ -6,10 +6,23 @@ const logger_1 = require("./infra/logger");
6
6
  const telemetry_1 = require("./infra/telemetry");
7
7
  function readConfig(api) {
8
8
  const raw = (api.pluginConfig ?? {});
9
+ let tunnel;
10
+ const rawTunnel = raw.tunnel;
11
+ if (rawTunnel && rawTunnel.type === "frp") {
12
+ tunnel = {
13
+ type: "frp",
14
+ serverAddr: typeof rawTunnel.serverAddr === "string" ? rawTunnel.serverAddr : "",
15
+ serverPort: typeof rawTunnel.serverPort === "number" ? rawTunnel.serverPort : 7000,
16
+ token: typeof rawTunnel.token === "string" ? rawTunnel.token : "",
17
+ portRangeStart: typeof rawTunnel.portRangeStart === "number" ? rawTunnel.portRangeStart : 7011,
18
+ portRangeEnd: typeof rawTunnel.portRangeEnd === "number" ? rawTunnel.portRangeEnd : 7020,
19
+ };
20
+ }
9
21
  return {
10
22
  port: typeof raw.port === "number" ? raw.port : undefined,
11
23
  displayName: typeof raw.displayName === "string" ? raw.displayName : undefined,
12
24
  selfUrl: typeof raw.selfUrl === "string" ? raw.selfUrl : undefined,
25
+ tunnel,
13
26
  telemetry: {
14
27
  consoleExporter: typeof raw.telemetry?.consoleExporter === "boolean"
15
28
  ? Boolean(raw.telemetry.consoleExporter)
@@ -332,6 +345,7 @@ const plugin = {
332
345
  port: config.port,
333
346
  displayName: config.displayName,
334
347
  selfUrl: config.selfUrl,
348
+ tunnel: config.tunnel,
335
349
  gatewayConfig: gatewayConfig ?? undefined,
336
350
  logger: structured.logger,
337
351
  });
@@ -392,7 +406,7 @@ multiclaws 插件刚安装,需要在本次对话中完成以下初始化流程
392
406
  2. 向用户展示生成的档案,并逐一确认以下三项(名字和 bio 需要用户明确回答):
393
407
  - **名字**:展示推断出的名字,询问是否需要修改
394
408
  - **Bio**:展示生成的 bio,询问是否需要修改
395
- - **网络情况**:告知用户「同局域网开箱即用;跨网络需安装 Tailscale(https://tailscale.com/download)并重启 OpenClaw」,无需用户回答
409
+ - **网络情况**:告知用户「所有实例通过 FRP 隧道通信,需在插件配置中设置 tunnel 字段(包含 frps 服务器地址、端口、token 和可用端口范围),frpc 会自动下载安装」,无需用户回答
396
410
 
397
411
  3. 根据用户对名字和 bio 的回答更新档案内容(如需修改),然后调用 \`multiclaws_profile_set(ownerName="...", bio="...")\` 保存。
398
412
 
@@ -0,0 +1,55 @@
1
+ export type FrpTunnelConfig = {
2
+ serverAddr: string;
3
+ serverPort: number;
4
+ token: string;
5
+ portRangeStart: number;
6
+ portRangeEnd: number;
7
+ };
8
+ export type FrpTunnelStatus = {
9
+ status: "running";
10
+ publicUrl: string;
11
+ remotePort: number;
12
+ } | {
13
+ status: "starting";
14
+ } | {
15
+ status: "stopped";
16
+ } | {
17
+ status: "error";
18
+ reason: string;
19
+ };
20
+ type Logger = {
21
+ info: (message: string) => void;
22
+ warn: (message: string) => void;
23
+ error: (message: string) => void;
24
+ };
25
+ /** Check if frpc binary is available in system PATH */
26
+ export declare function detectFrpc(): boolean;
27
+ export declare class FrpTunnelManager {
28
+ private readonly config;
29
+ private readonly localPort;
30
+ private readonly stateDir;
31
+ private readonly logger;
32
+ private frpcProcess;
33
+ private healthCheckTimer;
34
+ private _status;
35
+ private _publicUrl;
36
+ private configPath;
37
+ private adminPort;
38
+ constructor(opts: {
39
+ config: FrpTunnelConfig;
40
+ localPort: number;
41
+ stateDir: string;
42
+ logger?: Logger;
43
+ });
44
+ get status(): FrpTunnelStatus;
45
+ get publicUrl(): string | null;
46
+ start(): Promise<string>;
47
+ stop(): Promise<void>;
48
+ private tryStartWithPort;
49
+ private waitForProxy;
50
+ private startHealthCheck;
51
+ private killProcess;
52
+ private ensureFrpcBinary;
53
+ private downloadFrpc;
54
+ }
55
+ export {};
@@ -0,0 +1,384 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.FrpTunnelManager = void 0;
7
+ exports.detectFrpc = detectFrpc;
8
+ const node_child_process_1 = require("node:child_process");
9
+ const node_net_1 = __importDefault(require("node:net"));
10
+ const promises_1 = __importDefault(require("node:fs/promises"));
11
+ const node_path_1 = __importDefault(require("node:path"));
12
+ const node_crypto_1 = require("node:crypto");
13
+ /* ------------------------------------------------------------------ */
14
+ /* Constants */
15
+ /* ------------------------------------------------------------------ */
16
+ const FRP_VERSION = "0.61.1";
17
+ const ADMIN_API_POLL_INTERVAL_MS = 1_000;
18
+ const ADMIN_API_POLL_MAX_RETRIES = 15;
19
+ const HEALTH_CHECK_INTERVAL_MS = 30_000;
20
+ const PROCESS_KILL_TIMEOUT_MS = 3_000;
21
+ /* ------------------------------------------------------------------ */
22
+ /* Helpers */
23
+ /* ------------------------------------------------------------------ */
24
+ function run(cmd, timeoutMs = 5_000) {
25
+ return (0, node_child_process_1.execSync)(cmd, { timeout: timeoutMs, stdio: ["ignore", "pipe", "pipe"] })
26
+ .toString()
27
+ .trim();
28
+ }
29
+ /** Check if frpc binary is available in system PATH */
30
+ function detectFrpc() {
31
+ try {
32
+ const cmd = process.platform === "win32" ? "where frpc" : "which frpc";
33
+ run(cmd);
34
+ return true;
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ /** Find an available port by briefly binding to port 0 */
41
+ async function findFreePort() {
42
+ return new Promise((resolve, reject) => {
43
+ const server = node_net_1.default.createServer();
44
+ server.listen(0, "127.0.0.1", () => {
45
+ const addr = server.address();
46
+ if (!addr || typeof addr === "string") {
47
+ server.close(() => reject(new Error("failed to get port")));
48
+ return;
49
+ }
50
+ const port = addr.port;
51
+ server.close(() => resolve(port));
52
+ });
53
+ server.on("error", reject);
54
+ });
55
+ }
56
+ /** Parse remote port from frpc admin API response (format: ":12345" or "[::]:12345") */
57
+ function parseRemotePort(remoteAddr) {
58
+ const colonIdx = remoteAddr.lastIndexOf(":");
59
+ if (colonIdx === -1)
60
+ throw new Error(`unexpected remote_addr format: ${remoteAddr}`);
61
+ const portStr = remoteAddr.slice(colonIdx + 1);
62
+ const port = parseInt(portStr, 10);
63
+ if (isNaN(port) || port <= 0)
64
+ throw new Error(`invalid port in remote_addr: ${remoteAddr}`);
65
+ return port;
66
+ }
67
+ /** Get platform identifier for frp release download */
68
+ function getFrpPlatform() {
69
+ const platform = process.platform;
70
+ const arch = process.arch;
71
+ let frpOs;
72
+ if (platform === "linux")
73
+ frpOs = "linux";
74
+ else if (platform === "darwin")
75
+ frpOs = "darwin";
76
+ else if (platform === "win32")
77
+ frpOs = "windows";
78
+ else
79
+ throw new Error(`unsupported platform: ${platform}`);
80
+ let frpArch;
81
+ if (arch === "x64")
82
+ frpArch = "amd64";
83
+ else if (arch === "arm64")
84
+ frpArch = "arm64";
85
+ else if (arch === "ia32")
86
+ frpArch = "386";
87
+ else
88
+ throw new Error(`unsupported architecture: ${arch}`);
89
+ const ext = platform === "win32" ? "zip" : "tar.gz";
90
+ return { os: frpOs, arch: frpArch, ext };
91
+ }
92
+ /** Shuffle an array in place (Fisher-Yates) */
93
+ function shuffle(arr) {
94
+ for (let i = arr.length - 1; i > 0; i--) {
95
+ const j = Math.floor(Math.random() * (i + 1));
96
+ [arr[i], arr[j]] = [arr[j], arr[i]];
97
+ }
98
+ return arr;
99
+ }
100
+ /** Generate a range of numbers [start, end) */
101
+ function range(start, end) {
102
+ const result = [];
103
+ for (let i = start; i < end; i++)
104
+ result.push(i);
105
+ return result;
106
+ }
107
+ /* ------------------------------------------------------------------ */
108
+ /* FrpTunnelManager */
109
+ /* ------------------------------------------------------------------ */
110
+ class FrpTunnelManager {
111
+ config;
112
+ localPort;
113
+ stateDir;
114
+ logger;
115
+ frpcProcess = null;
116
+ healthCheckTimer = null;
117
+ _status = { status: "stopped" };
118
+ _publicUrl = null;
119
+ configPath = "";
120
+ adminPort = 0;
121
+ constructor(opts) {
122
+ this.config = opts.config;
123
+ this.localPort = opts.localPort;
124
+ this.stateDir = opts.stateDir;
125
+ this.logger = opts.logger ?? {
126
+ info: () => { },
127
+ warn: () => { },
128
+ error: () => { },
129
+ };
130
+ }
131
+ get status() {
132
+ return this._status;
133
+ }
134
+ get publicUrl() {
135
+ return this._publicUrl;
136
+ }
137
+ /* ── Start ─────────────────────────────────────────────────────── */
138
+ async start() {
139
+ this._status = { status: "starting" };
140
+ // 1. Ensure frpc binary exists
141
+ const frpcPath = await this.ensureFrpcBinary();
142
+ this.logger.info(`[frp] using frpc binary: ${frpcPath}`);
143
+ // 2. Find free port for admin API
144
+ this.adminPort = await findFreePort();
145
+ // 3. Try ports in random order from range
146
+ const ports = shuffle(range(this.config.portRangeStart, this.config.portRangeEnd + 1));
147
+ for (const port of ports) {
148
+ try {
149
+ const publicUrl = await this.tryStartWithPort(frpcPath, port);
150
+ this._publicUrl = publicUrl;
151
+ this._status = { status: "running", publicUrl, remotePort: port };
152
+ // Start health monitoring
153
+ this.startHealthCheck();
154
+ return publicUrl;
155
+ }
156
+ catch (err) {
157
+ this.logger.warn(`[frp] port ${port} unavailable: ${err instanceof Error ? err.message : String(err)}`);
158
+ // Kill process if started, try next port
159
+ await this.killProcess();
160
+ }
161
+ }
162
+ this._status = {
163
+ status: "error",
164
+ reason: `all ports in range ${this.config.portRangeStart}-${this.config.portRangeEnd} exhausted`,
165
+ };
166
+ throw new Error(`FRP tunnel failed: all ports in range ${this.config.portRangeStart}-${this.config.portRangeEnd} are unavailable`);
167
+ }
168
+ /* ── Stop ──────────────────────────────────────────────────────── */
169
+ async stop() {
170
+ if (this.healthCheckTimer) {
171
+ clearInterval(this.healthCheckTimer);
172
+ this.healthCheckTimer = null;
173
+ }
174
+ await this.killProcess();
175
+ // Cleanup config file
176
+ if (this.configPath) {
177
+ try {
178
+ await promises_1.default.unlink(this.configPath);
179
+ }
180
+ catch {
181
+ // ignore if already removed
182
+ }
183
+ }
184
+ this._status = { status: "stopped" };
185
+ this._publicUrl = null;
186
+ }
187
+ /* ── Private: try a specific port ──────────────────────────────── */
188
+ async tryStartWithPort(frpcPath, remotePort) {
189
+ const proxyName = `multiclaws-${(0, node_crypto_1.randomBytes)(4).toString("hex")}`;
190
+ this.configPath = node_path_1.default.join(this.stateDir, "frpc.toml");
191
+ const configContent = [
192
+ `serverAddr = "${this.config.serverAddr}"`,
193
+ `serverPort = ${this.config.serverPort}`,
194
+ `auth.token = "${this.config.token}"`,
195
+ ``,
196
+ `webServer.addr = "127.0.0.1"`,
197
+ `webServer.port = ${this.adminPort}`,
198
+ ``,
199
+ `[[proxies]]`,
200
+ `name = "${proxyName}"`,
201
+ `type = "tcp"`,
202
+ `localIP = "127.0.0.1"`,
203
+ `localPort = ${this.localPort}`,
204
+ `remotePort = ${remotePort}`,
205
+ ].join("\n");
206
+ // Ensure stateDir exists
207
+ await promises_1.default.mkdir(this.stateDir, { recursive: true });
208
+ await promises_1.default.writeFile(this.configPath, configContent, "utf8");
209
+ // Spawn frpc
210
+ this.frpcProcess = (0, node_child_process_1.spawn)(frpcPath, ["-c", this.configPath], {
211
+ stdio: ["ignore", "pipe", "pipe"],
212
+ });
213
+ // Capture stdout/stderr for logging
214
+ this.frpcProcess.stdout?.on("data", (data) => {
215
+ const line = data.toString().trim();
216
+ if (line)
217
+ this.logger.info(`[frpc] ${line}`);
218
+ });
219
+ this.frpcProcess.stderr?.on("data", (data) => {
220
+ const line = data.toString().trim();
221
+ if (line)
222
+ this.logger.warn(`[frpc:stderr] ${line}`);
223
+ });
224
+ this.frpcProcess.on("exit", (code, signal) => {
225
+ if (this._status.status === "running") {
226
+ this.logger.error(`[frp] frpc process exited unexpectedly (code=${code}, signal=${signal})`);
227
+ this._status = { status: "error", reason: `frpc exited (code=${code})` };
228
+ }
229
+ });
230
+ // Poll admin API to confirm proxy is running
231
+ await this.waitForProxy(proxyName);
232
+ return `http://${this.config.serverAddr}:${remotePort}`;
233
+ }
234
+ /* ── Private: poll admin API ──────────────────────────────────── */
235
+ async waitForProxy(proxyName) {
236
+ const url = `http://127.0.0.1:${this.adminPort}/api/proxy/tcp`;
237
+ for (let attempt = 0; attempt < ADMIN_API_POLL_MAX_RETRIES; attempt++) {
238
+ await new Promise((r) => setTimeout(r, ADMIN_API_POLL_INTERVAL_MS));
239
+ // Check if process has already exited
240
+ if (!this.frpcProcess || this.frpcProcess.exitCode !== null) {
241
+ throw new Error("frpc process exited before proxy became ready");
242
+ }
243
+ try {
244
+ const res = await fetch(url, { signal: AbortSignal.timeout(3_000) });
245
+ if (!res.ok)
246
+ continue;
247
+ const data = (await res.json());
248
+ const proxy = data.proxies?.find((p) => p.name === proxyName);
249
+ if (!proxy)
250
+ continue;
251
+ if (proxy.status === "running") {
252
+ this.logger.info(`[frp] proxy "${proxyName}" is running (remote_addr: ${proxy.remote_addr})`);
253
+ return;
254
+ }
255
+ if (proxy.err) {
256
+ throw new Error(`frp proxy error: ${proxy.err}`);
257
+ }
258
+ // status might be "new" or "wait_start" — keep polling
259
+ }
260
+ catch (err) {
261
+ if (err instanceof Error && err.message.startsWith("frp proxy error:")) {
262
+ throw err;
263
+ }
264
+ // fetch failed (admin API not yet ready) — keep polling
265
+ }
266
+ }
267
+ throw new Error("timeout waiting for frpc proxy to become running");
268
+ }
269
+ /* ── Private: health check ─────────────────────────────────────── */
270
+ startHealthCheck() {
271
+ this.healthCheckTimer = setInterval(async () => {
272
+ if (this._status.status !== "running")
273
+ return;
274
+ try {
275
+ const res = await fetch(`http://127.0.0.1:${this.adminPort}/api/proxy/tcp`, { signal: AbortSignal.timeout(5_000) });
276
+ if (!res.ok) {
277
+ this.logger.warn("[frp] health check: admin API returned non-OK");
278
+ }
279
+ }
280
+ catch {
281
+ this.logger.warn("[frp] health check: failed to reach admin API");
282
+ }
283
+ }, HEALTH_CHECK_INTERVAL_MS);
284
+ // Don't prevent Node from exiting
285
+ if (this.healthCheckTimer.unref) {
286
+ this.healthCheckTimer.unref();
287
+ }
288
+ }
289
+ /* ── Private: kill process ─────────────────────────────────────── */
290
+ async killProcess() {
291
+ const proc = this.frpcProcess;
292
+ if (!proc)
293
+ return;
294
+ this.frpcProcess = null;
295
+ // Phase 1: graceful kill
296
+ proc.kill();
297
+ const exited = await Promise.race([
298
+ new Promise((resolve) => proc.on("exit", () => resolve(true))),
299
+ new Promise((resolve) => setTimeout(() => resolve(false), PROCESS_KILL_TIMEOUT_MS)),
300
+ ]);
301
+ if (!exited) {
302
+ // Phase 2: force kill
303
+ try {
304
+ if (process.platform === "win32" && proc.pid) {
305
+ (0, node_child_process_1.execSync)(`taskkill /pid ${proc.pid} /f /t`, { stdio: "ignore" });
306
+ }
307
+ else {
308
+ proc.kill("SIGKILL");
309
+ }
310
+ }
311
+ catch {
312
+ // process may have already exited
313
+ }
314
+ }
315
+ }
316
+ /* ── Private: ensure frpc binary ───────────────────────────────── */
317
+ async ensureFrpcBinary() {
318
+ const ext = process.platform === "win32" ? ".exe" : "";
319
+ const localBinary = node_path_1.default.join(this.stateDir, `frpc${ext}`);
320
+ // 1. Check stateDir
321
+ try {
322
+ await promises_1.default.access(localBinary);
323
+ return localBinary;
324
+ }
325
+ catch {
326
+ // not found locally
327
+ }
328
+ // 2. Check system PATH
329
+ if (detectFrpc()) {
330
+ return "frpc";
331
+ }
332
+ // 3. Auto-download
333
+ this.logger.info(`[frp] frpc not found, downloading v${FRP_VERSION}...`);
334
+ return await this.downloadFrpc(localBinary);
335
+ }
336
+ async downloadFrpc(targetPath) {
337
+ const { os: frpOs, arch: frpArch, ext } = getFrpPlatform();
338
+ const archiveName = `frp_${FRP_VERSION}_${frpOs}_${frpArch}`;
339
+ const url = `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/${archiveName}.${ext}`;
340
+ const downloadDir = node_path_1.default.join(this.stateDir, "frpc-download");
341
+ await promises_1.default.mkdir(downloadDir, { recursive: true });
342
+ const archivePath = node_path_1.default.join(downloadDir, `${archiveName}.${ext}`);
343
+ // Download
344
+ this.logger.info(`[frp] downloading from ${url}`);
345
+ const res = await fetch(url);
346
+ if (!res.ok) {
347
+ throw new Error(`failed to download frpc: HTTP ${res.status} from ${url}`);
348
+ }
349
+ const buffer = Buffer.from(await res.arrayBuffer());
350
+ await promises_1.default.writeFile(archivePath, buffer);
351
+ // Extract
352
+ this.logger.info(`[frp] extracting ${archivePath}`);
353
+ const binaryName = process.platform === "win32" ? "frpc.exe" : "frpc";
354
+ try {
355
+ if (ext === "tar.gz") {
356
+ (0, node_child_process_1.execSync)(`tar -xzf "${archivePath}" -C "${downloadDir}"`, { stdio: "ignore" });
357
+ }
358
+ else {
359
+ // Windows: use tar (available since Windows 10 1803)
360
+ (0, node_child_process_1.execSync)(`tar -xf "${archivePath}" -C "${downloadDir}"`, { stdio: "ignore" });
361
+ }
362
+ // Move binary to target
363
+ const extractedBinary = node_path_1.default.join(downloadDir, archiveName, binaryName);
364
+ await promises_1.default.mkdir(node_path_1.default.dirname(targetPath), { recursive: true });
365
+ await promises_1.default.copyFile(extractedBinary, targetPath);
366
+ // Make executable on Unix
367
+ if (process.platform !== "win32") {
368
+ await promises_1.default.chmod(targetPath, 0o755);
369
+ }
370
+ this.logger.info(`[frp] frpc installed to ${targetPath}`);
371
+ }
372
+ finally {
373
+ // Cleanup download directory
374
+ try {
375
+ await promises_1.default.rm(downloadDir, { recursive: true, force: true });
376
+ }
377
+ catch {
378
+ // ignore cleanup errors
379
+ }
380
+ }
381
+ return targetPath;
382
+ }
383
+ }
384
+ exports.FrpTunnelManager = FrpTunnelManager;
@@ -110,8 +110,8 @@ class OpenClawAgentExecutor {
110
110
  const gateway = this.gatewayConfig;
111
111
  const startTime = Date.now();
112
112
  let attempt = 0;
113
- // Aggressive early polls, then back off: 300ms, 500ms, 1s, 2s, 3s, 3s...
114
- const pollDelays = [300, 500, 1000, 2000, 3000];
113
+ // Start aggressive, max out at 500ms to minimize result latency
114
+ const pollDelays = [100, 200, 300, 500];
115
115
  while (Date.now() - startTime < timeoutMs) {
116
116
  const delay = pollDelays[Math.min(attempt, pollDelays.length - 1)];
117
117
  await sleep(delay);
@@ -1,4 +1,5 @@
1
1
  import { EventEmitter } from "node:events";
2
+ import { type FrpTunnelConfig } from "../infra/frp";
2
3
  import { type AgentRecord } from "./agent-registry";
3
4
  import { type AgentProfile } from "./agent-profile";
4
5
  import { type TeamRecord, type TeamMember } from "../team/team-store";
@@ -8,6 +9,9 @@ export type MulticlawsServiceOptions = {
8
9
  port?: number;
9
10
  displayName?: string;
10
11
  selfUrl?: string;
12
+ tunnel?: FrpTunnelConfig & {
13
+ type: "frp";
14
+ };
11
15
  gatewayConfig?: GatewayConfig;
12
16
  logger?: {
13
17
  info: (message: string) => void;
@@ -35,6 +39,7 @@ export declare class MulticlawsService extends EventEmitter {
35
39
  private agentCard;
36
40
  private readonly clientFactory;
37
41
  private readonly httpRateLimiter;
42
+ private frpTunnel;
38
43
  private selfUrl;
39
44
  private profileDescription;
40
45
  constructor(options: MulticlawsServiceOptions);
@@ -89,9 +94,13 @@ export declare class MulticlawsService extends EventEmitter {
89
94
  private fetchMemberDescriptions;
90
95
  private syncTeamToRegistry;
91
96
  private createA2AClient;
97
+ /**
98
+ * Send a message using A2A streaming to minimize latency.
99
+ * Instead of a single blocking HTTP call, consume the SSE stream and
100
+ * return the final Task or Message as soon as B signals completion.
101
+ */
92
102
  private processTaskResult;
93
103
  private extractArtifactText;
94
- private notifyTailscaleSetup;
95
104
  /** Fetch with up to 2 retries and exponential backoff. */
96
105
  private fetchWithRetry;
97
106
  private log;
@@ -9,7 +9,7 @@ const node_os_1 = __importDefault(require("node:os"));
9
9
  const node_http_1 = __importDefault(require("node:http"));
10
10
  const node_path_1 = __importDefault(require("node:path"));
11
11
  const promises_1 = __importDefault(require("node:fs/promises"));
12
- const tailscale_1 = require("../infra/tailscale");
12
+ const frp_1 = require("../infra/frp");
13
13
  const json_store_1 = require("../infra/json-store");
14
14
  const express_1 = __importDefault(require("express"));
15
15
  const server_1 = require("@a2a-js/sdk/server");
@@ -21,7 +21,6 @@ const agent_profile_1 = require("./agent-profile");
21
21
  const team_store_1 = require("../team/team-store");
22
22
  const tracker_1 = require("../task/tracker");
23
23
  const zod_1 = require("zod");
24
- const gateway_client_1 = require("../infra/gateway-client");
25
24
  const rate_limiter_1 = require("../infra/rate-limiter");
26
25
  /* ------------------------------------------------------------------ */
27
26
  /* Service */
@@ -39,6 +38,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
39
38
  agentCard = null;
40
39
  clientFactory = new client_1.ClientFactory();
41
40
  httpRateLimiter = new rate_limiter_1.RateLimiter({ windowMs: 60_000, maxRequests: 60 });
41
+ frpTunnel = null;
42
42
  selfUrl;
43
43
  profileDescription = "OpenClaw agent";
44
44
  constructor(options) {
@@ -51,33 +51,28 @@ class MulticlawsService extends node_events_1.EventEmitter {
51
51
  this.taskTracker = new tracker_1.TaskTracker({
52
52
  filePath: node_path_1.default.join(multiclawsStateDir, "tasks.json"),
53
53
  });
54
- const port = options.port ?? 3100;
55
- // selfUrl resolved later in start() after Tailscale detection; use placeholder for now
56
- this.selfUrl = options.selfUrl ?? `http://${getLocalIp()}:${port}`;
54
+ // selfUrl resolved later in start() after FRP tunnel setup
55
+ this.selfUrl = options.selfUrl ?? "";
57
56
  }
58
57
  async start() {
59
58
  if (this.started)
60
59
  return;
61
- // Auto-detect Tailscale if selfUrl not explicitly configured
60
+ // Resolve selfUrl: explicit config > FRP tunnel
62
61
  if (!this.options.selfUrl) {
63
62
  const port = this.options.port ?? 3100;
64
- // Fast path: Tailscale already active — just read from network interfaces, no subprocess
65
- const tsIp = (0, tailscale_1.getTailscaleIpFromInterfaces)();
66
- if (tsIp) {
67
- this.selfUrl = `http://${tsIp}:${port}`;
68
- this.log("info", `Tailscale IP detected: ${tsIp}`);
69
- }
70
- else {
71
- // Slow path: Tailscale not active — run full detection and notify user
72
- const tailscale = await (0, tailscale_1.detectTailscale)();
73
- if (tailscale.status === "ready") {
74
- this.selfUrl = `http://${tailscale.ip}:${port}`;
75
- this.log("info", `Tailscale IP detected: ${tailscale.ip}`);
76
- }
77
- else {
78
- void this.notifyTailscaleSetup(tailscale);
79
- }
63
+ if (!this.options.tunnel || this.options.tunnel.type !== "frp") {
64
+ throw new Error("multiclaws requires either 'selfUrl' or 'tunnel' configuration. " +
65
+ "Please configure tunnel in plugin settings.");
80
66
  }
67
+ this.frpTunnel = new frp_1.FrpTunnelManager({
68
+ config: this.options.tunnel,
69
+ localPort: port,
70
+ stateDir: node_path_1.default.join(this.options.stateDir, "multiclaws"),
71
+ logger: this.options.logger,
72
+ });
73
+ const publicUrl = await this.frpTunnel.start();
74
+ this.selfUrl = publicUrl;
75
+ this.log("info", `FRP tunnel ready: ${publicUrl}`);
81
76
  }
82
77
  // Load profile for AgentCard description
83
78
  let profile = await this.profileStore.load();
@@ -149,6 +144,10 @@ class MulticlawsService extends node_events_1.EventEmitter {
149
144
  this.started = false;
150
145
  this.taskTracker.destroy();
151
146
  this.httpRateLimiter.destroy();
147
+ if (this.frpTunnel) {
148
+ await this.frpTunnel.stop();
149
+ this.frpTunnel = null;
150
+ }
152
151
  await new Promise((resolve) => {
153
152
  if (!this.httpServer) {
154
153
  resolve();
@@ -618,6 +617,11 @@ class MulticlawsService extends node_events_1.EventEmitter {
618
617
  async createA2AClient(agent) {
619
618
  return await this.clientFactory.createFromUrl(agent.url);
620
619
  }
620
+ /**
621
+ * Send a message using A2A streaming to minimize latency.
622
+ * Instead of a single blocking HTTP call, consume the SSE stream and
623
+ * return the final Task or Message as soon as B signals completion.
624
+ */
621
625
  processTaskResult(trackId, result) {
622
626
  if ("status" in result && result.status) {
623
627
  const task = result;
@@ -648,49 +652,6 @@ class MulticlawsService extends node_events_1.EventEmitter {
648
652
  .map((p) => p.text)
649
653
  .join("\n");
650
654
  }
651
- async notifyTailscaleSetup(tailscale) {
652
- let message;
653
- if (tailscale.status === "needs_auth") {
654
- message = [
655
- "🔗 **MultiClaws: Tailscale 登录**",
656
- "",
657
- "Tailscale 已安装但未登录,跨网络协作需要完成登录。",
658
- "",
659
- `👉 **请在浏览器打开:** ${tailscale.authUrl}`,
660
- "",
661
- "登录完成后重启 OpenClaw 即可。",
662
- "_(局域网内协作无需此步骤,现在即可使用)_",
663
- ].join("\n");
664
- }
665
- else {
666
- // not_installed or unavailable
667
- message = [
668
- "🌐 **MultiClaws: 跨网络协作提示**",
669
- "",
670
- "**局域网内已可直接协作,无需任何配置。**",
671
- "",
672
- "如需跨网络(不同局域网间)协作,请安装 Tailscale:",
673
- "https://tailscale.com/download",
674
- "",
675
- "安装并登录后重启 OpenClaw,将自动配置跨网络连接。",
676
- ].join("\n");
677
- }
678
- // Send to user via gateway (best-effort, don't throw)
679
- if (this.options.gatewayConfig) {
680
- try {
681
- await (0, gateway_client_1.invokeGatewayTool)({
682
- gateway: this.options.gatewayConfig,
683
- tool: "message",
684
- args: { action: "send", message },
685
- timeoutMs: 5_000,
686
- });
687
- }
688
- catch {
689
- // Fallback to log
690
- this.log("warn", message.replace(/\*\*/g, "").replace(/```[^`]*```/gs, ""));
691
- }
692
- }
693
- }
694
655
  /** Fetch with up to 2 retries and exponential backoff. */
695
656
  async fetchWithRetry(url, init, retries = 2) {
696
657
  let lastError = null;
@@ -715,23 +676,3 @@ class MulticlawsService extends node_events_1.EventEmitter {
715
676
  }
716
677
  }
717
678
  exports.MulticlawsService = MulticlawsService;
718
- function getLocalIp() {
719
- // Prefer Tailscale IP if available
720
- const tsIp = (0, tailscale_1.getTailscaleIpFromInterfaces)();
721
- if (tsIp)
722
- return tsIp;
723
- const interfaces = node_os_1.default.networkInterfaces();
724
- let fallback;
725
- for (const addrs of Object.values(interfaces)) {
726
- if (!addrs)
727
- continue;
728
- for (const addr of addrs) {
729
- if (addr.family === "IPv4" && !addr.internal) {
730
- if (addr.address.startsWith("192.168."))
731
- return addr.address;
732
- fallback ??= addr.address;
733
- }
734
- }
735
- }
736
- return fallback ?? node_os_1.default.hostname();
737
- }
@@ -20,7 +20,20 @@
20
20
  },
21
21
  "selfUrl": {
22
22
  "type": "string",
23
- "description": "Publicly reachable URL for this agent. Auto-detected from hostname if not set."
23
+ "description": "Publicly reachable URL for this agent. If not set, tunnel configuration is required."
24
+ },
25
+ "tunnel": {
26
+ "type": "object",
27
+ "description": "FRP tunnel configuration for cross-network connectivity. Required if selfUrl is not set.",
28
+ "properties": {
29
+ "type": { "type": "string", "enum": ["frp"] },
30
+ "serverAddr": { "type": "string", "description": "FRP server address (IP or domain)" },
31
+ "serverPort": { "type": "integer", "default": 7000, "description": "FRP server bind port" },
32
+ "token": { "type": "string", "description": "FRP authentication token" },
33
+ "portRangeStart": { "type": "integer", "description": "Start of available remote port range" },
34
+ "portRangeEnd": { "type": "integer", "description": "End of available remote port range (inclusive)" }
35
+ },
36
+ "required": ["type", "serverAddr", "serverPort", "token", "portRangeStart", "portRangeEnd"]
24
37
  },
25
38
  "telemetry": {
26
39
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multiclaws",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
4
4
  "description": "MultiClaws plugin for OpenClaw collaboration via A2A protocol",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -23,7 +23,7 @@ MultiClaws 让多个 OpenClaw 实例通过 [A2A(Agent-to-Agent)](https://goo
23
23
  2. 向用户展示生成的档案,并逐一确认以下三项:
24
24
  - **名字**:展示推断出的名字,询问是否需要修改(需用户明确回答)
25
25
  - **Bio**:展示生成的 bio,询问是否需要修改(需用户明确回答)
26
- - **网络情况**:告知用户「同局域网开箱即用;跨网络需安装 Tailscale(https://tailscale.com/download)并重启 OpenClaw」,无需用户回答
26
+ - **网络情况**:告知用户「所有实例通过 FRP 隧道通信,需在插件配置中设置 tunnel 字段(frps 地址、端口、token、可用端口范围),frpc 会自动下载安装」,无需用户回答
27
27
 
28
28
  3. 根据用户对名字和 bio 的回答更新内容后,调用 `multiclaws_profile_set(ownerName="...", bio="...")` 保存档案。
29
29
 
@@ -153,15 +153,29 @@ multiclaws_profile_show()
153
153
 
154
154
  ---
155
155
 
156
- ## 跨网络
157
-
158
- **同局域网:** 开箱即用,无需配置。
159
-
160
- **不同网络:** 每人安装 [Tailscale](https://tailscale.com/download) 并登录同一 tailnet,插件自动检测 Tailscale IP。
156
+ ## 网络配置
157
+
158
+ 所有实例通过 FRP 隧道通信。需要一台运行 frps 的公网服务器。
159
+ frpc 会在首次启动时自动下载安装,无需手动安装。
160
+
161
+ 在插件配置中添加 tunnel 字段即可:
162
+ ```json
163
+ {
164
+ "tunnel": {
165
+ "type": "frp",
166
+ "serverAddr": "你的frps地址",
167
+ "serverPort": 7000,
168
+ "token": "你的认证令牌",
169
+ "portRangeStart": 7011,
170
+ "portRangeEnd": 7020
171
+ }
172
+ }
173
+ ```
161
174
 
162
175
  当加入或委派因网络错误失败时(ECONNREFUSED、ETIMEDOUT、fetch failed),引导用户:
163
176
 
164
- > 你和对方不在同一网络。跨网络协作需要每个成员安装 Tailscale:
165
- > 1. 下载安装:https://tailscale.com/download
166
- > 2. 登录同一个 Tailscale 账号(或同一 tailnet)
167
- > 3. 重启 OpenClaw,插件会自动检测 Tailscale IP
177
+ > 网络连接失败,请检查:
178
+ > 1. 确认 frpc 已正常启动(查看日志中的 `[frp] FRP tunnel ready` 信息)
179
+ > 2. 检查插件配置中的 `tunnel.serverAddr` / `tunnel.token` 是否正确
180
+ > 3. 确认 frps 服务器可达(`telnet frps地址 端口`)
181
+ > 4. 重启 OpenClaw