multiclaws 0.4.9 → 0.4.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,75 +1,9 @@
1
1
  # MultiClaws
2
2
 
3
- Multi-agent collaboration plugin for [OpenClaw](https://openclaw.ai). Connect multiple OpenClaw instances into a team so their AIs can delegate tasks to each other using the [A2A protocol](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
- ## The Core Idea
8
-
9
- Every OpenClaw instance is a gateway to data and systems that only *that machine* can reach — local files, logged-in accounts, connected devices, internal networks. MultiClaws connects these isolated islands so AI agents can collaborate across them.
10
-
11
- **The division of labor isn't about who is smarter. It's about who has the key.**
12
-
13
- - Your laptop has your codebase and personal email
14
- - Your colleague's machine has their Google Workspace and internal database access
15
- - The office server has the production logs
16
-
17
- None of these can reach the others directly. MultiClaws lets each agent do what only it can do, and routes the results back to whoever asked.
18
-
19
- ## Two Core Features
20
-
21
- ### 1. Profiles Make Every OpenClaw Discoverable
22
-
23
- Each OpenClaw instance has a profile — a bio written in plain text that describes what data and systems it can access. When your agent needs to get something done, it reads the team profiles and figures out who to ask. No manual routing, no hardcoded assignments.
24
-
25
- The profile is what turns each OpenClaw into a callable unit for others. Your colleague writes "I have access to Google Workspace and the sales spreadsheets" in their bio — and your agent can now delegate Google Sheets tasks to them, without you configuring anything. Add a new teammate to the team, their profile appears, your agent starts routing tasks to them automatically.
26
-
27
- Getting started takes one sentence to your AI. No config files, no YAML, no A2A protocol knowledge required. The profile is the only interface.
28
-
29
- ### 2. Collaborate by Who Has the Key, Not Who Is Better
30
-
31
- Traditional task routing assumes you split work by skill: "this agent is good at data, that one is good at writing." That's the wrong model for distributed systems.
32
-
33
- Each OpenClaw can only reach what its own machine can reach. The right question isn't *who is more capable* — it's *who has access*. MultiClaws routes tasks based on data ownership: your colleague's agent handles their Google Sheets because they're the only one logged in. The office server's agent handles production logs because it's the only one on that network.
34
-
35
- **The profile bio is a declaration of access, not a resume.**
36
-
37
- ## Example
38
-
39
- *Eric needs a monthly business review report.*
40
-
41
- ```
42
- A (Eric's MacBook)
43
- → has: local git history, personal Telegram, own email
44
-
45
- B (zxj's Windows PC)
46
- → has: Google Sheets with sales data (logged in as zxj)
47
-
48
- C (ljl's machine)
49
- → has: internal OA system (browser already logged in), local MySQL
50
- ```
51
-
52
- Eric tells his AI: *"Generate this month's business review."*
53
-
54
- ```
55
- A reads team profiles
56
- → B's bio: "access to Google Workspace, sales spreadsheets"
57
- → C's bio: "access to internal OA system and local database"
58
-
59
- A delegates:
60
- → B: "pull this month's sales figures from Google Sheets"
61
- → C: "get project status and attendance from the OA system"
62
- → A: reads local git log for code activity
63
-
64
- B uses its own Google credentials → returns sales data
65
- C uses its own logged-in browser → returns OA data
66
- A merges all three → generates the report
67
- ```
68
-
69
- B can also delegate further. If B needs a chart generated and sees from the team profiles that D has that capability, B delegates to D autonomously — without A needing to orchestrate it.
70
-
71
- **The profile bio describes what data and systems each instance can access.** That's how agents decide who to ask.
72
-
73
7
  ## Installation
74
8
 
75
9
  Just tell your AI:
@@ -84,19 +18,14 @@ Everything works through natural language:
84
18
 
85
19
  - **"Create a team called my-team"** — creates a team and generates an invite code
86
20
  - **"Join team with invite code mc:xxxxx"** — join a teammate's team
87
- - **"Ask Bob to pull the sales data"** — delegate a task to a teammate's AI
88
- - **"Show all agents"** — list team members and their data access
89
-
90
- ## Roadmap
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
91
23
 
92
- ### Async Delegation
93
- Currently, task delegation is synchronous — the delegating agent waits for the result before continuing. The next major version will support fire-and-forget delegation: dispatch multiple tasks to different agents simultaneously, receive results as push notifications, and aggregate them when all are ready. This enables true parallel execution across agents.
24
+ ## How It Works
94
25
 
95
- ### Multi-turn Collaboration
96
- Today each delegation is a single round-trip. Planned support for multi-turn sessions where agents can exchange follow-up messages within the same context — useful for tasks that require clarification, intermediate feedback, or iterative refinement between agents.
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.
97
27
 
98
- ### Permissions
99
- Currently any agent with a valid invite code can send tasks to your OpenClaw. A permission layer is planned: define which agents are allowed to delegate to you, what kinds of tasks they can request, and whether approval is required before execution.
28
+ Works out of the box on the same local network. Cross-network collaboration is also supported.
100
29
 
101
30
  ## Documentation
102
31
 
package/README.zh-CN.md CHANGED
@@ -1,75 +1,9 @@
1
1
  # MultiClaws
2
2
 
3
- [OpenClaw](https://openclaw.ai) 多智能体协作插件。将多个 OpenClaw 实例组成团队,通过 [A2A 协议](https://google.github.io/A2A/) 让各个 AI 互相委派任务。
3
+ [OpenClaw](https://openclaw.ai) 多智能体协作插件。将多个 OpenClaw 实例组成团队,通过 [A2A 协议](https://google.github.io/A2A/) 互相委派任务。
4
4
 
5
5
  [English](README.md)
6
6
 
7
- ## 核心理念
8
-
9
- 每个 OpenClaw 实例都是一扇门——只有*那台机器*才能打开的门:本地文件、已登录的账号、连接的设备、内部网络。MultiClaws 把这些孤立的数据孤岛连接起来,让 AI 跨越它们协作完成任务。
10
-
11
- **分工的依据不是谁更擅长,而是谁有钥匙。**
12
-
13
- - 你的电脑有你的代码仓库和个人邮件
14
- - 同事的机器有他的 Google Workspace 和内部数据库权限
15
- - 办公室服务器有生产日志
16
-
17
- 这些数据互相访问不到。MultiClaws 让每个 Agent 只做它能做的事,把结果汇总回发起方。
18
-
19
- ## 两个核心产品特色
20
-
21
- ### 一、档案让每个 OpenClaw 对外可发现
22
-
23
- 每个 OpenClaw 实例都有一个档案——一段纯文本的 bio,描述这台机器能访问哪些数据和系统。当你的 Agent 需要完成某件事时,它读取团队档案,自己判断找谁。无需手动路由,无需硬编码分配。
24
-
25
- 档案就是让每个 OpenClaw 对其他人可调用的接口。同事在 bio 里写"我可以访问 Google Workspace 和销售数据表格"——你的 Agent 就能把 Google Sheets 相关任务委派给他,不需要你做任何配置。新队友加入团队,档案出现,你的 Agent 自动开始向他路由任务。
26
-
27
- 上手只需要对 AI 说一句话,没有配置文件、没有 YAML、不需要了解 A2A 协议。档案是唯一的接口。
28
-
29
- ### 二、按"谁有钥匙"分工,而不是"谁更擅长"
30
-
31
- 传统的任务路由假设按能力分工:"这个 Agent 擅长数据,那个擅长写作。"对于分布式系统,这是错误的模型。
32
-
33
- 每个 OpenClaw 只能访问它所在机器能访问的东西。正确的问题不是*谁更有能力*,而是*谁有权限*。MultiClaws 按数据所有权路由任务:同事的 Agent 处理他们的 Google Sheets,因为只有他们登录了;办公室服务器的 Agent 处理生产日志,因为只有它在那个网络里。
34
-
35
- **档案里的 bio 是访问权限的声明,不是简历。**
36
-
37
- ## 案例
38
-
39
- *Eric 需要生成一份月度业务复盘报告。*
40
-
41
- ```
42
- A(Eric 的 MacBook)
43
- → 有:本地 git 记录、个人 Telegram、自己的邮件
44
-
45
- B(zxj 的 Windows 电脑)
46
- → 有:Google Sheets 销售数据(以 zxj 账号登录)
47
-
48
- C(ljl 的机器)
49
- → 有:内部 OA 系统(浏览器已登录)、本地 MySQL
50
- ```
51
-
52
- Eric 对自己的 AI 说:"帮我生成本月复盘报告。"
53
-
54
- ```
55
- A 读取团队档案
56
- → B 的 bio:"可访问 Google Workspace、销售数据表格"
57
- → C 的 bio:"可访问内部 OA 系统和本地数据库"
58
-
59
- A 拆解任务:
60
- → 委派 B:"从 Google Sheets 拿本月销售数字"
61
- → 委派 C:"从 OA 系统查项目进度和出勤情况"
62
- → 自己读本地 git log 获取代码提交记录
63
-
64
- B 用自己的 Google 凭据读取表格 → 返回数据
65
- C 用自己已登录的浏览器抓取 OA → 返回数据
66
- A 合并三份数据 → 生成报告
67
- ```
68
-
69
- B 还可以进一步委派。如果 B 需要生成图表,看到团队档案里 D 有这个能力,B 会自主委派给 D——不需要 A 来调度。
70
-
71
- **档案里的 bio 描述的是每个实例能访问什么数据和系统**,这就是 Agent 决定找谁的依据。
72
-
73
7
  ## 安装
74
8
 
75
9
  对你的 AI 说:
@@ -84,19 +18,14 @@ AI 会自动完成安装、配置和档案生成,无需手动修改任何文
84
18
 
85
19
  - **「创建一个叫 my-team 的团队」** — 创建团队并获取邀请码
86
20
  - **「用邀请码 mc:xxxxx 加入团队」** — 加入队友的团队
87
- - **「让 Bob 去拿销售数据」** — 把任务委派给队友的 AI
88
- - **「显示所有智能体」** — 查看团队成员及其数据访问权限
89
-
90
- ## 后续计划
21
+ - **「让 Bob 总结一下最新报告」** — 把任务委派给队友的 AI
22
+ - **「显示所有智能体」** — 查看团队成员及其能力
91
23
 
92
- ### 异步委派
93
- 当前委派是同步的——发起方等待结果返回后才继续。下一个大版本将支持异步委派:同时向多个 Agent 派发任务,结果通过推送通知返回,全部完成后统一汇总。这将实现真正的跨 Agent 并行执行。
24
+ ## 工作原理
94
25
 
95
- ### 多轮协作
96
- 当前每次委派是单次请求-响应。计划支持多轮 Session:Agent 之间可以在同一上下文中来回交流,适用于需要补充信息、中间反馈或多轮迭代的任务。
26
+ MultiClaws 让多个 OpenClaw 实例作为一个团队协作。每个实例既可以作为客户端(委派任务),也可以作为服务端(接收他人任务)。任务由远端 AI 执行,结果直接返回。
97
27
 
98
- ### 权限管理
99
- 当前持有邀请码的任何 Agent 都可以向你的 OpenClaw 发送任务。计划添加权限层:定义哪些 Agent 可以委派任务给你、允许哪类任务、以及是否需要人工确认才能执行。
28
+ 同局域网开箱即用,也支持跨网络协作。
100
29
 
101
30
  ## 详细文档
102
31
 
package/dist/index.js CHANGED
@@ -4,12 +4,38 @@ const handlers_1 = require("./gateway/handlers");
4
4
  const multiclaws_service_1 = require("./service/multiclaws-service");
5
5
  const logger_1 = require("./infra/logger");
6
6
  const telemetry_1 = require("./infra/telemetry");
7
+ /** Default FRP tunnel config for demo/testing */
8
+ const DEFAULT_TUNNEL = {
9
+ type: "frp",
10
+ serverAddr: "39.105.143.2",
11
+ serverPort: 7000,
12
+ token: "jushi@5202fRp",
13
+ portRangeStart: 7011,
14
+ portRangeEnd: 7020,
15
+ };
7
16
  function readConfig(api) {
8
17
  const raw = (api.pluginConfig ?? {});
18
+ let tunnel;
19
+ const rawTunnel = raw.tunnel;
20
+ if (rawTunnel && rawTunnel.type === "frp") {
21
+ tunnel = {
22
+ type: "frp",
23
+ serverAddr: typeof rawTunnel.serverAddr === "string" ? rawTunnel.serverAddr : DEFAULT_TUNNEL.serverAddr,
24
+ serverPort: typeof rawTunnel.serverPort === "number" ? rawTunnel.serverPort : DEFAULT_TUNNEL.serverPort,
25
+ token: typeof rawTunnel.token === "string" ? rawTunnel.token : DEFAULT_TUNNEL.token,
26
+ portRangeStart: typeof rawTunnel.portRangeStart === "number" ? rawTunnel.portRangeStart : DEFAULT_TUNNEL.portRangeStart,
27
+ portRangeEnd: typeof rawTunnel.portRangeEnd === "number" ? rawTunnel.portRangeEnd : DEFAULT_TUNNEL.portRangeEnd,
28
+ };
29
+ }
30
+ else {
31
+ // No tunnel configured — use built-in default for demo
32
+ tunnel = { ...DEFAULT_TUNNEL };
33
+ }
9
34
  return {
10
35
  port: typeof raw.port === "number" ? raw.port : undefined,
11
36
  displayName: typeof raw.displayName === "string" ? raw.displayName : undefined,
12
37
  selfUrl: typeof raw.selfUrl === "string" ? raw.selfUrl : undefined,
38
+ tunnel,
13
39
  telemetry: {
14
40
  consoleExporter: typeof raw.telemetry?.consoleExporter === "boolean"
15
41
  ? Boolean(raw.telemetry.consoleExporter)
@@ -332,6 +358,7 @@ const plugin = {
332
358
  port: config.port,
333
359
  displayName: config.displayName,
334
360
  selfUrl: config.selfUrl,
361
+ tunnel: config.tunnel,
335
362
  gatewayConfig: gatewayConfig ?? undefined,
336
363
  logger: structured.logger,
337
364
  });
@@ -392,7 +419,7 @@ multiclaws 插件刚安装,需要在本次对话中完成以下初始化流程
392
419
  2. 向用户展示生成的档案,并逐一确认以下三项(名字和 bio 需要用户明确回答):
393
420
  - **名字**:展示推断出的名字,询问是否需要修改
394
421
  - **Bio**:展示生成的 bio,询问是否需要修改
395
- - **网络情况**:告知用户「同局域网开箱即用;跨网络需安装 Tailscale 并重启 OpenClaw,下载地址:https://tailscale.com/download」,无需用户回答
422
+ - **网络情况**:告知用户「所有实例通过 FRP 隧道通信,需在插件配置中设置 tunnel 字段(包含 frps 服务器地址、端口、token 和可用端口范围),frpc 会自动下载安装」,无需用户回答
396
423
 
397
424
  3. 根据用户对名字和 bio 的回答更新档案内容(如需修改),然后调用 \`multiclaws_profile_set(ownerName="...", bio="...")\` 保存。
398
425
 
@@ -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;
@@ -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);
@@ -96,7 +101,6 @@ export declare class MulticlawsService extends EventEmitter {
96
101
  */
97
102
  private processTaskResult;
98
103
  private extractArtifactText;
99
- private notifyTailscaleSetup;
100
104
  /** Fetch with up to 2 retries and exponential backoff. */
101
105
  private fetchWithRetry;
102
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();
@@ -653,49 +652,6 @@ class MulticlawsService extends node_events_1.EventEmitter {
653
652
  .map((p) => p.text)
654
653
  .join("\n");
655
654
  }
656
- async notifyTailscaleSetup(tailscale) {
657
- let message;
658
- if (tailscale.status === "needs_auth") {
659
- message = [
660
- "🔗 **MultiClaws: Tailscale 登录**",
661
- "",
662
- "Tailscale 已安装但未登录,跨网络协作需要完成登录。",
663
- "",
664
- `👉 **请在浏览器打开:** ${tailscale.authUrl}`,
665
- "",
666
- "登录完成后重启 OpenClaw 即可。",
667
- "_(局域网内协作无需此步骤,现在即可使用)_",
668
- ].join("\n");
669
- }
670
- else {
671
- // not_installed or unavailable
672
- message = [
673
- "🌐 **MultiClaws: 跨网络协作提示**",
674
- "",
675
- "**局域网内已可直接协作,无需任何配置。**",
676
- "",
677
- "如需跨网络(不同局域网间)协作,请安装 Tailscale:",
678
- "https://tailscale.com/download",
679
- "",
680
- "安装并登录后重启 OpenClaw,将自动配置跨网络连接。",
681
- ].join("\n");
682
- }
683
- // Send to user via gateway (best-effort, don't throw)
684
- if (this.options.gatewayConfig) {
685
- try {
686
- await (0, gateway_client_1.invokeGatewayTool)({
687
- gateway: this.options.gatewayConfig,
688
- tool: "message",
689
- args: { action: "send", message },
690
- timeoutMs: 5_000,
691
- });
692
- }
693
- catch {
694
- // Fallback to log
695
- this.log("warn", message.replace(/\*\*/g, "").replace(/```[^`]*```/gs, ""));
696
- }
697
- }
698
- }
699
655
  /** Fetch with up to 2 retries and exponential backoff. */
700
656
  async fetchWithRetry(url, init, retries = 2) {
701
657
  let lastError = null;
@@ -720,23 +676,3 @@ class MulticlawsService extends node_events_1.EventEmitter {
720
676
  }
721
677
  }
722
678
  exports.MulticlawsService = MulticlawsService;
723
- function getLocalIp() {
724
- // Prefer Tailscale IP if available
725
- const tsIp = (0, tailscale_1.getTailscaleIpFromInterfaces)();
726
- if (tsIp)
727
- return tsIp;
728
- const interfaces = node_os_1.default.networkInterfaces();
729
- let fallback;
730
- for (const addrs of Object.values(interfaces)) {
731
- if (!addrs)
732
- continue;
733
- for (const addr of addrs) {
734
- if (addr.family === "IPv4" && !addr.internal) {
735
- if (addr.address.startsWith("192.168."))
736
- return addr.address;
737
- fallback ??= addr.address;
738
- }
739
- }
740
- }
741
- return fallback ?? node_os_1.default.hostname();
742
- }
@@ -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.9",
3
+ "version": "0.4.11",
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 并重启 OpenClaw,下载地址:https://tailscale.com/download」,无需用户回答
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