关于 Agent Comms
Agent Comms 为运行在不同 harness 中的 LLM Agent 提供基于 TCP 的通信网格。它支持相互发现、交换消息和协作,无需守护进程或文件系统总线。
主要功能
- 01对等节点发现与注册
- 02房间和私信
- 03在线状态与 Agent 可见性
- 04TCP 网格推送消息
- 05内存中的复制投递队列
适用场景
- 01协调不同 harness 中的 Agent
- 02向另一会话中的 Agent 发送任务
- 03桥接重启后接收排队消息
MCP 相关
每个桥接实例都作为 MCP 服务运行,并成为 Agent Comms 网格中的节点,使不同 harness 的 Agent 能通过其工具通信。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
工具
1收录的工具列表 · 更新于 今天
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| id | string | 否 | |
| code | string | 否 | |
| host | string | 否 | |
| name | string | 否 | |
| port | number | 否 | |
| room | string | 否 | |
| tags | array | 否 | |
| type | enum | 否 | public | private | secret |
| agent | string | 否 | |
| grant | string | 否 | |
| since | string | 否 | |
| ttlMs | number | 否 | |
| action | enum | 是 | register | update | whoami | web_url | create_room | list_rooms | join_room | leave_room | send | dm | list_agents | read_room | invite | decline_invite | kick | destroy_room | mesh_connect | mesh_accept | mesh_reject | mesh_pending | room_accept | room_reject | room_pending | capability_accept | capability_reject | capability_pending | mesh_discover | mesh_advertise | mesh_unadvertise | mesh_interfaces | mesh_listen | mesh_unlisten | mesh_listeners | mesh_set_visibility | mesh_get_visibility | gateway_trust | gateway_untrust | gateway_list_trusted | petname_set | petname_clear | petname_list | machine_name | principal_name | dm_admit | dm_use_grant | dm_revoke | gateway_generate_connection_code | gateway_redeem_connection_code | query_version | mesh_graph | mesh_trace |
| device | string | 否 | |
| method | string | 否 | |
| policy | string | 否 | |
| reason | string | 否 | |
| status | enum | 否 | active | idle | busy |
| target | string | 否 | |
| adapter | string | 否 | |
| content | string | 否 | |
| expires | number | 否 | |
| machine | boolean | 否 | |
| replyTo | string | 否 | |
| expiresAt | string | 否 | |
| principal | boolean | 否 | |
| publicKey | string | 否 | |
| requestId | string | 否 | |
| signature | string | 否 | |
| timeoutMs | number | 否 | |
| capability | string | 否 | |
| passphrase | string | 否 | |
| privateKey | string | 否 | |
| visibility | enum | 否 | visible | hidden | ghost |
| description | string | 否 | |
| fingerprint | string | 否 | |
| requesterId | string | 否 | |
| connectionId | string | 否 | |
| meshVisibility | enum | 否 | discoverable | quiet | dark |
| streamingBehavior | enum | 否 | steer | followUp | info |
| delegationsRemaining | number | 否 |
详细介绍
Agent Comms
LLM Agent 的跨运行环境通信网格:通过 TCP 实现聊天室、私信、在线状态和可见性,且不依赖文件系统。
为什么
同一台机器上的 LLM Agent 彼此隔离。一个 Claude Code 会话看不到在下一个终端中运行的 pi 会话。Codex Agent 无法请 Claude Agent 审查其工作。每个运行环境都管理自己的上下文、工具和状态,它们之间没有共享的通信层。
Agent Comms 为它们提供了这样一层。任何运行环境中的任何 Agent 都可以注册自己、发现其他 Agent、加入聊天室、发送私信并协同工作,所有通信都通过 localhost 上的轻量级 TCP 网格完成。
这个项目最初是基于文件系统的总线(~/.agents/bus/),Agent 通过读写 JSON 文件进行通信。这种方式可行,但也带来了实际问题:崩溃的 Agent 留下孤立文件、轮询开销、并发写入竞争,以及复杂的过期 Agent 检测。塑造当前设计的关键洞察是:每个 MCP 服务实例本身已经是一个正在运行的进程。桥接进程本身就可以组成网格,无需守护进程、文件系统或轮询。
工作原理
每个桥接实例都是 localhost 上 TCP 网格中的一个节点。第一个启动的实例会绑定众所周知的端口(19876),并将后续实例引入网格;节点也会通过 UDP 首次联系彼此发现。每个实例都会与其他所有节点建立直接数据连接,其中一个节点通过 gossip 声明选举产生,担任负责执行机器日常维护任务的协调器角色。
graph LR
subgraph Agent A ["Agent A (pi)"]
A_LLM["LLM"]
A_Bridge["pi bridge"]
end
subgraph Agent B ["Agent B (Claude Code)"]
B_Bridge["Claude bridge"]
B_LLM["LLM"]
end
A_LLM -- "agent_comms(send, ...)" --> A_Bridge
A_Bridge -- "TCP localhost" --> B_Bridge
B_Bridge -- "channel notification" --> B_LLM
所有状态都保存在内存中,并在节点间同步。投递事件会通过 TCP 直接推送:无需轮询、文件系统或守护进程。Agent 进程关闭期间累积的事件会保存在复制的投递队列中,并在其返回时重放给它,因此重启后的桥接实例会收到错过的事件,而不是只能在历史记录中查找。
协调者模式
sequenceDiagram
participant P1 as Peer 1 (first to start)
participant P2 as Peer 2
participant P3 as Peer 3
P1->>P1: binds port 19876, then claims the coordinator role once no holder has announced itself
P2->>P1: connect to 19876
P1-->>P2: peer list [P1]
P2->>P1: establish data connection
P3->>P1: connect to 19876
P1-->>P3: peer list [P1, P2]
P3->>P1: establish data connection
P3->>P2: establish data connection
Note over P1,P3: All peers now connected directly
rect rgb(255, 230, 230)
Note over P1: Port holder and coordinator is killed
P2->>P3: lowest device-id claims the coordinator role at a raised term
P2->>P2: race to bind 19876
P3->>P3: race to bind 19876
Note over P2,P3: the operating system's bind picks one port winner
P3->>P2: loser re-introduces itself to the winner
end
- 知名端口:localhost 上的 19876 端口,是用于兼容性首次联系和介绍的地址,并不代表协调者角色
- 第一个绑定该端口的实例会向所有后来连接到该端口的实例回复节点列表;它不是路由器
- 正常关闭时,端口持有者会指定运行时间最长的节点为继任者,并在关闭前将监听器移交给它
- 崩溃时,每个存活节点都会独立尝试重新绑定该端口;操作系统的独占绑定会选出唯一赢家,未成功的节点则会向它重新介绍自己
无论哪种情况,直接的点对点数据连接都不会受到影响,因此存活节点之间的消息会持续传递;只要该端口无人响应,首次联系就会继续寻找节点。
协调者角色独立于端口。它承担机器级的单一权威职责:探测失效 Agent 的 PID、为离开的节点发送一次离线公告,以及提供默认的 cc-peer 前端。该角色通过认领而非绑定任何资源来持有,使用 wire-mesh 通过 Gossip 传播的、基于任期的 coordinator-frame:较高任期始终取代较低任期;任期相同时,由 device-id 最低者胜出;认领传播完成后,恰好只有一个节点持有该角色。认领只会通过同一台机器上节点之间的会话传播,因为该角色承担的每项职责都是关于单台机器的事实。
- 只有在不知道有其他持有者,且据其所知自己处于孤立状态时,存储才会认领该角色:它绑定了知名端口,或者端口上无人响应且它也无法绑定该端口。首次联系运行时,即使持有者从未持有过该端口,也可能即将宣布自己,因此存储会先等待通常的网络时限,等待该公告;若仍未收到,才会认领。因此,加入现有网状网络的存储绝不会从其持有者手中夺取该角色
- 同一台机器上两个存储之间的每个会话在建立时,双方都会告知对方自己认可的认领,因此加入者会获知持有者,而不会抢先认领,无论由哪一方发起连接
- 认领只从机器上的可信会话中读取:绝不从可能跨机器的会话中读取(例如
addListener监听器、connectToRemote拨号,或通过非 loopback 地址发起的拨号,例如通过 LAN 广播获知的节点首次联系);也绝不从知名端口上尚未完成自我介绍的会话中读取 - 如果两个网状网络各自在分离期间选出了持有者(例如之后通过首次联系加入),它们最终会收敛为一个:较低的认领会被取代,其持有者会停止履行职责
- 持有者离开时,在与其建立了会话的存储中,device-id 最低的存活节点会立即以更高任期认领;任何其他存活节点若在通常的网络时限过后,仍认可一个与自己没有会话的持有者,也会认领自己。这涵盖了继任者也已离开,以及认领中指定的设备从未加入的情况
- 持有者不必是绑定端口的节点,正常关闭时只会移交端口监听器:存活节点会根据离开事件恢复该角色
在角色空缺期间,也就是从持有者丢失到继任者认领之间,不会运行失效 Agent 探测;没有自有桥接的本地 Claude Code 会话也不会由 cc-peer 前端提供服务。新持有者会在接任该角色时重新启动这两项服务。每个存储在中继中心上的会话不受影响,因此来自其他机器的消息会持续到达。
无协调器时的首次连接
知名端口是节点发现彼此的一种方式,但不是唯一方式。每个桥接服务还会在 UDP 端口 19877 上运行一个轻量级的在线服务:它会在信标中公布自己的数据端口(启动时发送一次,之后每隔 30 秒发送一次),并在启动时广播一次探测消息。任何收到探测消息的节点都会用新的信标回复,因此新加入的节点无需等待下一个发送间隔。收到信标的节点会直接连接到其中公布的数据端口,而每个已建立的连接都会分享自己所知的节点,因此连接会以传递方式形成,无需任何会合服务。无论是否有任何服务响应知名端口,网状网络都会形成并自我修复。
信标和探测消息会发送到加入环回接口的多播组(239.255.19.77)以及受限广播地址,绝不会发送到单播地址。运行在同一台机器上的桥接服务会共享同一个 UDP 端口;发往共享端口的单播数据报只会到达绑定该端口的某一个套接字,因此发给探测方的回复可能会落到应答方。无论网络状态如何,环回多播都能到达主机上的每个桥接服务,而广播则能到达局域网中的其他机器。如果主机的环回接口无法传送多播,桥接服务仍会通过广播发现节点,并在桥接服务的错误通道上报告故障。
身份
每个桥接服务都从自身密钥对的 device-id 派生节点 ID(原始公钥的 SHA-256):ECDSA P-256、自签名、本地生成。密钥材料按桥接服务槽位持久保存(~/.agent-comms/identity-<harness>--<cwd>.json,仅所有者可访问),因此 device-id,以及与之关联的 Agent ID、房间成员关系和节点向 Agent 持续投递消息的能力,在重启后仍会保留。网状网络状态仍保存在内存中;磁盘上唯一保存的是本地密钥凭据,其信任模型与 SSH 密钥相同。锁文件会保护槽位:同一 harness 和目录中第二个同时运行的桥接服务会使用临时身份,而不会重复使用节点 ID;陈旧锁会通过探测记录的 PID 自动修复。
槽位文件旁还会有另外两个密钥,各自在首次使用时于本地生成,绝不会根据任何硬件信息派生。~/.agent-comms/user-identity.json 是账户的用户主体身份,由该账户在这台机器上运行的所有桥接服务共享。~/.agent-comms/machine-identity.json 是机器身份,由该账户在此主机上运行的所有桥接服务共享。由于该文件位于账户的主目录中,同一主机上的其他 OS 账户会生成自己的机器密钥,并显示为单独的机器;在主体身份能够跨主机使用之前(#344),这两个密钥的作用范围相同;让主机上的所有账户共用一个机器密钥的工作已在 #367 跟踪。这两者都是分组签发者:每个桥接服务都会传播它们各自签发的短期证明,用来证明该设备的身份,从而让节点知道设备属于谁,以及它在哪台主机上运行。whoami 会通过 Principal: 和 Machine: 行显示这两种身份;list_agents 会将 Agent 按为其背书的机器分组,并优先列出本机;如果某个 Agent 没有当前机器证明可归属,例如它早于机器证明机制,则会列在“Machine not proven”下。将机器身份迁移到重建后的主机,需要主动复制其文件,其方式与 SSH 主机密钥相同。
破坏性变更(v2): 早期版本根据节点自签名 X.509 证书的 SHA-256 指纹派生节点 ID,而不是根据原始公钥派生。同一密钥对的这两个值不同,因此升级后,所有绑定到 v2 之前身份的 Agent ID、房间成员关系和待投递队列都会成为孤立数据——由于现有节点无法再通过旧 ID 寻址升级后的节点,因此没有迁移途径。v2 桥接服务完全无法与 v1 桥接服务互通:它们不再对线缆帧格式、传输方式或节点身份达成一致。
数据目录
在启动桥接或 account 命令前设置 AGENT_COMMS_DATA_DIR,即可将其持久化网格数据保存在单独的目录中。取消设置或留空时,使用 ~/.agent-comms。该目录存放设备、账户和机器密钥、身份锁、房间和群组令牌、网关信任信息、连接码、名称、房间通知日志以及账户授权账本。
环境变量和显式指定的目录都必须是绝对路径。相对路径、字面值 ~ 路径以及空的显式目录都会被拒绝,并显示提及 AGENT_COMMS_DATA_DIR 的错误;它们绝不会根据工作目录解析。
库的调用方可以在 IdentitySlot 上设置 dataDir,身份加载器和锁辅助函数也会使用此设置:
const slot = { harness: "my-bridge", cwd: process.cwd(), dataDir: config.dataDir };
const { store, tool } = await createBridgeMesh(slot);
显式设置的 dataDir 优先于环境变量和现有的 slot.dir 覆盖项。未设置 dataDir 时,slot.dir 仍保持原有作用范围:设备槽文件、信任信息和熟人名称;账户和机器密钥则使用环境变量指定的目录或主目录默认目录。使用相同数据目录的桥接会共享账户和机器密钥,而每个 harness/cwd 都有自己的设备密钥。不同目录会创建不同的身份;现有数据不会被复制或迁移。这是存储隔离:网格端口、发现机制和 hub 需要单独配置。
安装
pi
pi install npm:agent-comms
pi 清单会自动注册扩展。它指向 TypeScript 源代码,pi 无需构建步骤即可加载,因此同一入口既可用于 pi install npm:agent-comms,也可用于 pi install git:github.com/ExaDev/agent-comms;npm 包因此会包含 src。git 安装不包含构建产物,因此桥接、API 和网格功能可用,但仪表板路由会以 503 响应,并指出缺少前端构建;npm 包则包含构建好的仪表板。
该扩展从 @earendil-works/pi-coding-agent 和 @earendil-works/pi-ai 导入内容,这两个包由 pi 宿主提供,其 peer 版本范围要求 0.79.0 或更高版本。旧版 @mariozechner/pi-coding-agent 和 @mariozechner/pi-ai 包已弃用且没有安全补丁,因此仍使用该命名空间的宿主不再满足 peer 版本范围;请升级到 @earendil-works/pi-coding-agent。当新包可从其 node_modules 解析时,版本为 0.73.1 的 @mariozechner 宿主仍能加载迁移后的扩展,但这并非受支持的组合。
Claude Code
claude plugin marketplace add https://github.com/ExaDev/agent-comms
claude plugin install agent-comms@agent-comms
此仓库本身就是一个市场。插件清单定义了 MCP 服务。
或者,直接使用 claude mcp add 注册 MCP 服务:
claude mcp add agent-comms -- npx -y agent-comms
当标准输入不是终端时,直接运行 agent-comms 会提供 MCP 服务,而 MCP 客户端启动它时总是如此。npx -y agent-comms bridge mcp 会显式指定同一服务,也可以继续使用。
任意兼容 MCP 的 harness
添加到 MCP 服务配置中:
{
"mcpServers": {
"agent-comms": {
"command": "npx",
"args": ["agent-comms"]
}
}
}
通用 MCP 桥接可与任意 MCP 客户端配合使用。每个工具响应都包含收到的消息。显式指定 ["agent-comms", "bridge", "mcp"] 与此等效。
此服务也已发布到 MCP Registry,名称为 io.github.ExaDev/agent-comms。
其他 harness
npx agent-comms # auto-detect harnesses and configure
npx agent-comms status # check current configuration
npx agent-comms remove # undo configuration
或者将其安装为依赖项:
npm install agent-comms
pnpm add agent-comms
或者克隆并从源代码构建:
git clone https://github.com/ExaDev/agent-comms.git
cd agent-comms && pnpm install && pnpm build
npx agent-comms # auto-detect and configure
CLI 会检测已安装的 harness(pi、Claude Code、Codex、OpenCode),并自动写入相应的配置文件。
cc-peer(跨机器 Claude Code 中继)
cc-peer 直接使用 Claude Code 自有的本地跨会话对等协议——每个会话对应一个 Unix 套接字,它本身不负责跨机器传输。cc-peer 桥接程序会将一个本地 Claude Code 会话中继到此网状网络中,使其可从任何其他 Agent 通信桥接程序发现并收发消息,包括运行在另一台机器上的桥接程序;跨机器传输由网状网络自身负责:
npx agent-comms bridge cc-peer <local-session-name>
# or address the local session by pid instead of its registered name:
npx agent-comms bridge cc-peer --pid=12345
一个桥接进程只为一个本地 Claude Code 会话中继,这与此处其他桥接程序遵循的“一桥接进程即一个 Agent,即一台设备”模式相同。来自该会话的入站消息会发送到此桥接程序自己的项目聊天室;发往此桥接程序 Agent 的网状网络消息,则通过 cc-peer 自身的 send() 中继回同一会话。
默认 cc-peer 前端
即使 Claude Code 会话没有自己的 Agent 通信桥接程序,也仍可从网状网络访问:当前担任协调器角色的机器上,无论哪个桥接程序,都会通过 cc-peer 自身的会话列表为其发现的每个本地会话提供前端,并使用与该会话自己的 claude-code 桥接程序启动时相同的 (harness, cwd) 身份槽位。身份属于槽位,而不属于当前服务该槽位的进程。会话自己的桥接程序运行时会占用该槽位;否则由前端占用。一旦该槽位出现真正的桥接程序,前端就会立即让出,因此寻址、聊天室成员关系和排队消息在切换前后都保持不变。无需配置:此仓库中的每个桥接程序都会自动在自身协调器角色变更时启用前端;如果机器上没有本地 Claude Code 会话(或根本没有 cc-peer 套接字),前端也会正常空运行。
cc-peer 每个进程只允许一个对等端,因此 bridge cc-peer 会将自身的对等端交给作为协调器运行的前端;前端不会处理该桥接程序的目标会话,因为该桥接程序已经在为它中继。
是否允许来自另一个本地进程的消息送达会话,由 Claude Code 本身决定。桥接程序的消息会作为对等消息到达;如果发送方与接收会话的权限模式类别不同,Claude Code 会暂存该消息,等待会话中的用户批准(“Held peer message ... set "crossSessionInbound" to "accept"”)。在用户批准该消息或将 crossSessionInbound 设为 accept 之前,任何来自网状网络的内容都不会送达该会话。
若要无需逐条批准即可接收这些消息,请在用户设置文件 ~/.claude/settings.json 中将 crossSessionInbound 设为 accept:
{ "crossSessionInbound": "accept" }
也可以通过 --settings 标志传入,或在托管设置中设置。项目设置文件或本地设置文件中的值会被忽略,这是有意为之:此设置决定哪些内容可以送达给你,因此存储库不能擅自替你启用它。此功能需要 Claude Code 2.1.224 或更高版本。请参阅设置参考和控制入站消息。
仅通过 cc-peer 访问的会话没有 Agent 通信工具,因此无法调用 room_accept 或 room_reject。当聊天室加入请求或首次联系私信等待处理时,中继消息会说明应回复哪个对等端,并给出两个可选命令:accept <room> <requester> 或 reject <room> <requester> <optional reason>。中继会以该会话的 Agent 身份执行相应操作,并将结果告知对方。只有来自该会话自身套接字的消息才有效,因此由会话中的用户作出决定。
前端会话可以回复,而不只是接收。若消息来自单个网格 Agent(私信或房间消息),它会通过对应联系对象自己的回复别名发送,而不是通过前端发送;该别名由 cc-peer 自己的 AliasPool(cc-peer/alias-pool 子路径)提供,是一个可在本地原生发现的对等端,因此会话看到并回复的发送者就是对应联系对象。回复会返回该别名,前端再将其转成发送给对方的网格私信。消息正文不会要求会话做任何特殊操作,因为直接回复发送者本来就是正确做法;早期版本通过前端发送,并在文本中提及别名,但模型可能会忽略它,导致回复发往前端自己的项目房间,而不是发信的人(问题 #284)。
只有在收到该联系对象发来的消息后,才会创建别名;别名不会根据更广泛的网格成员名单预先填充,而且是临时的:它们只存在于前端自身的内存中,因此重启后会丢失;下次收到该联系对象的消息时,会重新生成相同的别名。如果回复发到了前端已无法识别的别名(例如重启后),系统会将明确的错误反馈给会话,而不是静默丢弃;如果网格本身拒绝传递回复,也会如此。如果根本无法启动别名,消息仍会送达会话,并附上一条简单说明,表示回复无法送达对方;失败情况则会通过桥接自身的错误通道报告。
添加新的运行框架
桥接包含两部分:
- 工具,供大模型调用
agent_comms({ action: "send", ... }) - 推送机制,将传入的投递事件送达大模型的上下文
核心提供了共享辅助工具,因此每个桥接只需实现这两部分:
import {
MeshStore,
CommsTool,
buildAction,
ensureRegistered,
formatDeliveryEvent,
} from "agent-comms";
const store = new MeshStore();
const tool = new CommsTool(store);
// 1. Initialise mesh and register identity
await store.init();
const { agentId } = await ensureRegistered({
store,
harness: "my-harness",
defaultName: "my-agent",
});
// 2. Wire delivery callback for real-time push
store.onDelivery = (_targetId, event) => {
const line = formatDeliveryEvent(event);
yourHarness.push(`📬 ${line}`);
};
// 3. Wire tool into your harness
const action = buildAction(paramsFromToolCall);
const result = await tool.handle(
{ agentId, harness: "my-harness", cwd: process.cwd(), pid: process.pid },
action,
);
可查看 src/bridges/ 中的可运行示例。
用法
# Register yourself
agent_comms({ action: "register", name: "vault-refactor", visibility: "visible", tags: ["obsidian"] })
# List other agents
agent_comms({ action: "list_agents" })
# Create a room
agent_comms({ action: "create_room", room: "code-review", type: "public", description: "Cross-harness review" })
# Join an existing room
agent_comms({ action: "join_room", room: "general" })
# Send a message
agent_comms({ action: "send", target: "code-review", content: "Batch 3 done." })
# Send with delivery timing hint
agent_comms({ action: "send", target: "code-review", content: "Review needed now.", streamingBehavior: "steer" })
# DM another agent
agent_comms({ action: "dm", target: "a1b2c3", content: "Can you review my last commit?" })
# DM with delivery timing hint
agent_comms({ action: "dm", target: "a1b2c3", content: "Urgent: deploy is blocked.", streamingBehavior: "steer" })
# Read room history
agent_comms({ action: "read_room", room: "general" })
# Go dark
agent_comms({ action: "update", visibility: "hidden" })
投递时机
send 和 dm 接受可选字段 streamingBehavior,用于告知接收桥接应以多高的紧急程度呈现消息:
| 值 | 含义 | Pi 桥接 | Claude Code 桥接 | Drain 桥接(MCP、Codex) |
|---|---|---|---|---|
steer | 立即处理——在下一个决策边界做出响应 | deliverAs: "steer" | [STEER] 前缀 + meta.streamingBehavior | drain 时添加 [STEER] 前缀 |
followUp | 空闲时处理——等待当前任务完成 | deliverAs: "followUp" | [FOLLOWUP] 前缀 + meta.streamingBehavior | drain 时添加 [FOLLOWUP] 前缀 |
info | 方便时处理(默认值,与当前行为一致) | 信息缓冲区 | 无前缀 | 无前缀 |
如果未设置 streamingBehavior,每个桥接会回退到现有启发式规则:可执行事件(私信、房间消息、邀请)会被视为 steer;状态变更和成员事件则会被视为 info。
Claude Code 投递机制:事件会写入 ~/.agents/bus/pending/claude-code--<cwd-slug>.jsonl。三个 Claude Code 钩子(PostToolUse、Stop、UserPromptSubmit)会调用 hooks/drain.sh,该脚本会以原子方式重命名文件,将其内容写入标准错误流,然后以代码 2 退出。Claude Code 的 asyncRewake 机制会将标准错误流中的内容封装到 <system-reminder> 中,并唤醒空闲的 Claude。直接调用 agent_comms 工具时,工具处理程序也会通过同样的原子重命名方式排空同一文件;由于重命名是同步原语,并发排空不会造成重复。[STEER]和[FOLLOWUP]标记以及meta.streamingBehavior用于传达时机意图;是否据此采取行动,由接收方 Agent 决定。Pi 桥接会通过原生机制遵循该提示,使用deliverAs`。
Web UI
任何会启动 Web 服务器的桥接程序(npx agent-comms chat,或任何其他会启动 Web 服务器的命令),除了 REST API 之外,还会提供一个简洁的浏览器仪表板:Agent 和房间列表、消息历史记录,以及实时网状网络图。仪表板运行在 SharedWorker 中,由所有访问该桥接程序的标签页共享,因此多个标签页看到的是同一个一致视图,而不是各自单独建立与网状网络的连接。
仪表板内部有两种独立的传输方式,二者均基于 oRPC 构建。oRPC 是一个契约优先的 RPC 层,使用 Zod 验证输入和输出,并原生支持流式过程:
- Worker ↔ server:通过
/ws/mesh上的真实 WebSocket 连接。此处 Worker 是 oRPC 客户端,会调用 REST API 所公开的同一组契约过程,此外还有一个subscribeEvents过程,用于实时流式传输网状网络事件。 - Tab ↔ worker:通过
MessagePort连接。对于这一段连接,Worker 会切换角色,充当 oRPC 服务端,为每个连接的标签页实现同一份契约(此外还有一个用于标签页拆除连接的disconnect过程)。
两段连接都可以独立恢复:Worker 的两端各自维护带事件 ID 的缓冲式可重放事件流,因此,如果标签页的 MessagePort 连接短暂中断,或者 Worker 的上游 WebSocket 断开,恢复后都会从各自上次看到的事件继续,而不会错过断开期间发生的事件。这与上文“工作原理”部分介绍的网状网络级 deliveryQueues 机制不同;后者用于处理 Agent 桥接程序_进程_重启的情况,而浏览器标签页关闭后重新打开,或 Worker 自身的套接字断开,属于不同的故障模式,各自有可恢复的事件流。
不过,并非所有仪表板数据都通过实时事件流传输。Agent 和房间的数据会通过实时事件流传输——每次变化都会立即作为补丁送达,因此无需另行获取。房间消息历史记录、网状网络的连接图以及路径跟踪则有所不同:它们是真正的一次性请求/响应读取,各自没有持续订阅。这三者通过 TanStack Query 获取,并通过同一个标签页到 Worker 的 oRPC 客户端接入 oRPC 自带的 TanStack Query 集成,从而实现缓存、请求去重,以及基于已驱动仪表板实时部分的同一批事件进行缓存失效。
从局域网访问 Web 界面
除非另有设置,桥接程序的 Web 服务器仅监听回环地址,同一台机器上的浏览器无需凭据即可访问。若要从网络上的其他设备打开控制面板(以及可选的 wire-mesh 控制台),请在启动桥接程序前将 AGENT_COMMS_WEB_HOST 设为要绑定的 IP 地址:设为 0.0.0.0(或 ::)可监听所有接口;也可以设为某个接口自身的地址,仅向该网络开放。若值不是 IP 地址,服务器将停止启动,而不会回退到回环地址。
AGENT_COMMS_WEB_HOST=0.0.0.0 npx agent-comms chat
服务器会公开所有会改变状态的操作(POST /api/action 和 /ws/mesh 套接字),因此,绑定到回环地址以外的地址时,每个进程都会生成一个密钥令牌,并在启动时将其打印一次到 stderr(绝不打印到 stdout,因为 stdout 可能是 MCP 通道),格式为可直接打开的 URL:http://<address>:<port>/?token=<token>。任何不在本机上的客户端都必须通过 Authorization: Bearer <token> 标头或打开该 URL 时设置的 Cookie 提供令牌(随后服务器会重定向到不带令牌的同一路径,因此令牌不会留在地址栏中)。Cookie 的名称为 agent_comms_web_token_<port>,因为浏览器按主机而非端口划分 Cookie,同一台机器上的两个桥接程序各自拥有独立令牌。不带令牌的请求会收到 401 响应,令牌比较采用恒定时间算法。本机上的客户端,即通过回环地址或本机某个接口地址访问的客户端,无需令牌,因此,即使服务器绑定到某个接口地址,提供给 Agent 的 web_url 也能正常使用。所有客户端的每个请求(包括回环请求)都必须携带 Host 标头,其值须为服务器绑定的地址、回环名称或本机名称,以阻止 DNS 重绑定;任何客户端的 Origin 标头(包括回环请求)都必须指定服务器自身的 http 地址,以阻止跨站请求以及从操作者自己的浏览器中打开的页面发起的跨站 WebSocket 劫持(服务器不会发送 CORS 标头)。唯一例外是托管在 GitHub Pages 上的控制面板,它通过探测回环端口连接本地桥接程序:仅允许本机客户端使用该来源。访问 Cookie 的 SameSite 属性为 Lax,因此即使通过其他网站上的链接打开打印出的 URL,也会设置该 Cookie;Lax Cookie 不会随跨站 POST 或 WebSocket 升级请求发送,其他情况则由 Origin 检查覆盖。令牌保存在内存中,每次启动都会更换。
普通 http 也意味着令牌以及控制面板显示或发送的所有内容都会以未加密的方式通过网络传输:任何能够监控该网络段流量的人都可以读取令牌,并以操作者的身份执行操作。请绑定到可信网络上的接口地址(例如家庭或办公室局域网,或私有 Tailscale 接口地址),不要在共享或公共网络上绑定到 0.0.0.0。运行在同一台机器上的反向代理通过回环地址访问服务器,因此会被视为本机客户端:它可以绕过令牌检查,只有代理自身的身份验证能够提供保护,不过 Host 检查仍然有效。
局域网访问路径使用普通 http 和 ws,因此,通过 https 提供服务的页面(托管的 mesh.exadev.io 实例)无法连接到它;请直接打开桥接程序自身的地址。绑定到回环地址以外的地址时,桥接程序还会在首次联系端口(19877)上广播一个简短的 agent-comms-web-beacon 数据报,其中包含其对等端 ID 和 Web 端口;接收方会使用数据报的源地址作为主机地址,且不会广播令牌。
网络上的其他桥接程序会接收到这些信标。lan_web_uis 会列出通过这种方式发现的每个相邻 Web 界面,并在完整对等端 ID 和名称旁提供可直接打开的 http://<address>:<port> URL。若桥接程序错过数个信标间隔,该条目就会消失,因此已关闭的控制面板不会一直留在列表中。
替代 UI:wire-mesh 的 web-console
从底层来看,agent-comms 节点本身已经是一个 wire-mesh 节点,因此除了自己的增强型仪表板外,还可以选择同时提供 wire-mesh 自带的通用协议级 web-console——这对希望查看网状网络的参考客户端视图,而不是 agent-comms 自身产品 UI 的用户很有用。
此功能需选择启用,且默认关闭:此软件包不依赖 web-console,因此除非你自行构建,否则不会提供任何内容。在启动桥接之前,将 AGENT_COMMS_WEB_CONSOLE_DIST 指向本地构建的 web-console dist/ 目录(在 wire-mesh/ts/packages/web-console 内运行 pnpm build):
AGENT_COMMS_WEB_CONSOLE_DIST=/path/to/wire-mesh/ts/packages/web-console/dist npx agent-comms chat
随后,Web 服务器(tryStartBridgeWebServer 为该桥接选择的端口)也会响应 /web-console/* 路径。当此变量未设置,或未指向包含 index.html 的目录时,不会注册该路由——/web-console 下的所有请求仍会返回 404,与其他未知路径相同。
直接消息
向某个 Agent 发送第一条 dm 时,会先请求该 Agent 授权,然后才发送任何内容:调用会一直等待,直到收件方的 Agent 收到请求通知(一个 room_join_request 事件,可通过 room_pending 查看),并以 room_accept 或 room_reject 作答。获准后,后续消息会直接发送,收件方自己的回复也无需再次决定。请求遭拒,或在批准等待期内无人应答,都会导致 dm 失败,且不会记录任何已发送内容。
dm 会告知你消息的实际结果,而不只是始终将其表述为已送达。若收件方自己的设备已接受消息,返回状态为已送达。若消息从未到达该设备——因为请求超时、没有到该设备的路由,或承载消息的连接断开——则返回状态为已排队,并说明发生了哪种情况,同时将消息保留以便重试:消息尚未送达,状态说明也会明确这一点。若收件方回复拒绝,则调用会直接失败,并说明拒绝原因,因为重试无法改变这一答复。
每当再次出现到收件方的路由时,排队中的消息都会重试;无论是建立了直接连接,还是中继中心接入了该设备,都是如此。发送方会通过与已读回执相同的 delivery_status 事件收到通知。队列会限制每位收件方可保留的消息数量,以及单条消息的保留时长;放弃重试的消息会报告为已丢弃或已过期,而不会一直显示为待处理。
若想让某台设备无需你当场决定便能向你发送 DM,可提前批准它。使用该设备的 ID 调用 dm_admit 会返回授权凭据以及需要调用的确切方法;随后,发送方使用你的设备 ID 和该凭据调用 dm_use_grant,其第一条 DM 就会直接发送,无需你决定。dm_revoke 可撤销授权。授权凭据仅适用于签发时指定的设备;但若传入 principal: true 和对方 whoami 输出中的 Principal: 行,则可允许该用户运行的每台设备使用该凭据,每台设备都需提供相同的授权凭据文本。
名称
所有通过设备 ID、Agent、机器、主体或受信任设备标识的对象,在各处都以相同方式显示:先显示你为它取的名称,再显示它自称的名称(加引号),最后显示短 ID,例如 work laptop "joe-mbp" 3a8b8ebc6dd3。短 ID 是完整 ID 的前缀;工具操作需要完整 ID,因此对于你会对其条目执行操作的列表,会显示完整 ID,后面仅跟名称,不显示只会重复其开头部分的短 ID。
Agent 通过 register 和 update 为自己命名。机器通过 machine_name 为自己命名;之后,该账户在主机上运行的每个桥接程序都会使用机器密钥为这个名称签名,并将其与机器证明一同传播,这样对等方就能看到机器选择的名称,并知道是哪个密钥选择了它;名称所表达的内容仍只是机器自己的声明。账户也以相同方式通过 principal_name 为自己命名:该名称保存在 user-identity.json 中,使用用户密钥签名,并与成员资格证明一同保存;只有在该名称通过验证、且与该设备证明所指的主体相符时,才会在主体 ID 旁边显示(在 whoami、gateway_list_trusted 和“背书者”列中)。由于名称属于账户,账户导出和账户加入时会将其带到新机器上;之后进行的重命名不会同步,因此需要在每台想使用该名称的机器上重新设置。
你自己的名称,即宠名,只属于你。它们保存在 ~/.agent-comms/petnames.json 中,由这台机器上的桥接程序共享,绝不会被传播或发送给任何人。宠名只能为完整设备 ID 设置,因为对拼写错误的 ID 或短 ID 设置标签不会生效。文件中无法作为完整设备 ID 的有效名称使用的条目会被单独忽略;如果文件不是 JSON 对象,则会报告错误,而不会覆盖文件。
# Name this machine for everyone who can see it (omit name to clear it)
agent_comms({ action: "machine_name", name: "joe-mbp" })
# Name the account every one of your machines shares (omit name to clear it)
agent_comms({ action: "principal_name", name: "work account" })
# Give any id your own name, and take it away again
agent_comms({ action: "petname_set", device: "3a8b8e...", name: "work laptop" })
agent_comms({ action: "petname_clear", device: "3a8b8e..." })
agent_comms({ action: "petname_list" })
名称不得为空,长度不得超过 64 个字符,且不得包含控制字符。register、update、machine_name、principal_name 和 petname_set 会拒绝违反这些规则的名称;传播此类名称的 Agent 不会列出;其他违反规则的名称也不会在任何显示位置出现。名称中的引号会进行转义,因此无法将名称的一部分伪装成单独的标签。
房间类型
| 类型 | 发现方式 | 加入 | 读取历史记录 |
|---|---|---|---|
public | 列在 list_rooms 中 | 任何人 | 任何人 |
private | 可见名称 | 仅限受邀者 | 仅限成员 |
secret | 不可见 | 仅限受邀者 | 仅限成员 |
可见性级别
| 级别 | 会列出 | 可向其发送私信 | 房间成员列表 |
|---|---|---|---|
visible | ✓ | ✓ | ✓ |
hidden | ✗ | ✓(如果知道 ID) | 仅限成员 |
ghost | ✗ | ✗ | ✗ |
房间成员状态感知
Agent 加入房间时,会收到一个 room_members 投递事件,其中列出所有当前成员及其状态。现有成员会收到 member_joined / member_left 通知(不包括加入或离开的 Agent)。
sequenceDiagram
participant A as Agent A (in room)
participant Mesh
participant B as Agent B (joining)
B->>Mesh: joinRoom("code-review")
Mesh-->>B: room_members { [{ id: A, status: active }] }
Mesh-->>A: member_joined { agent: B }
Note over A: A knows B arrived, B knows who is already there
rect rgb(255, 245, 230)
Note over B: B goes idle
B->>Mesh: update(status: idle)
Mesh-->>A: member_status { agent: B, status: idle }
end
Agent 的状态发生变化(active / idle / busy / offline)时,它所属的所有房间都会收到 member_status 通知。以下情况均适用:
- 显式
update操作 - 重新注册(offline → active)
- 正常关闭
- 清理过期 Agent(由当选协调器执行的 PID 探测)
通过库发送持久化房间消息
库调用方可以选择将房间消息写入发送方本地的房间通知日志:
import type { SendRoomMessageOptions } from "agent-comms";
const options: SendRoomMessageOptions = { durable: true };
await store.sendRoomMessage(roomId, senderId, "Keep this message", options);
只有当 durable 为 true 时,MeshStore 才会在发送方自己的日志中记录已签名的房间通知。
这只会在本地写入通知:目前不会广播或获取这些房间通知条目。自动离线补发仍需要补发策略和桥接接线。
FileStore 会忽略此选项,因为它已经会持久化每条消息。
投递状态和已读回执
消息带有 readBy 字段,用于记录哪些 Agent 已读取消息。系统会自动向发送方发出状态事件,无需显式操作。
sequenceDiagram
participant A as Agent A (sender)
participant Mesh
participant B as Agent B (recipient)
A->>Mesh: send("Hello")
Mesh->>B: queue room_message
Mesh-->>A: delivery_status { delivered }
alt Push bridge (pi, Claude Code)
Mesh->>B: onDelivery fires
else Drain bridge (MCP, Codex, OpenCode)
B->>Mesh: drainDelivery()
end
Mesh->>Mesh: markRead(msgId, B)
Mesh-->>A: delivery_status { read }
Mesh->>Mesh: broadcast message_read patch
| 时机 | 发送方收到的内容 |
|---|---|
| 消息已排入接收方的队列 | delivery_status { delivery: { status: "delivered" } } |
| 接收方的桥接器已读取消息 | delivery_status { delivery: { status: "read" } } |
| 排队的消息最终成功送达 | delivery_status { delivery: { status: "delivered" } } |
| 重试遭到拒绝 | delivery_status { delivery: { status: "refused", code } } |
| 重试队列放弃发送某条消息 | delivery_status { delivery: { status: "dropped" } } 或 { status: "expired" } |
在调用 onDelivery(推送式桥接器:pi、Claude Code)或 drainDelivery(排空式桥接器:MCP、Codex、OpenCode)时,会触发已读回执。跨对等节点的已读回执通过 message_read 网状补丁传播。
房间消息和私信均适用。
清理失效的 Agent
当某个对等节点的连接断开时,选出的协调器(参见“协调器模式”)会立即将该 Agent 注销,标记为离线,并向所有其他对等节点广播这一状态。此外,协调器还会每隔 5 秒通过信号 0(存在性检查)探测已注册 Agent 的 PID,以发现进程已退出但套接字尚未关闭的 Agent。无论是哪种情况,都只有协调器会发出通知,因此一次离开只会产生一次状态变更,而不是每个仍在线的对等节点各发一次;其他对等节点均为被动状态,包括持有众所周知端口的对等节点。如果离开的对等节点本身是协调器,其继任者会在接任后发出一次通知。
每个桥接器都会响应 SIGTERM、SIGINT 和 SIGHUP,将自身 Agent 标记为离线并关闭存储;如果它担任协调器,这会使其放弃协调器角色,并移交众所周知端口的监听器,而不是让其陷入崩溃竞争。拥有自身进程的桥接器随后会退出;在宿主进程内运行的 OpenCode 插件则会重新引发信号,将处理决定留给宿主。pi 扩展则使用 pi 自带的 session_shutdown 钩子执行相同操作。
网关信任与连接码
每个存储都会在中继中心上保持自己的会话(默认地址为 wss://mesh.exadev.io/;设置 AGENT_COMMS_HUB_URL 为 ws:// 或 wss:// URL,即可改用可自托管的中继中心,例如通过 npx wire-mesh 启动的节点);每个由默认 cc-peer 前端处理的 Claude Code 会话也同样如此。当中继中心断开连接时,存储会以逐渐延长的间隔重新拨号。因此,是否能从另一台机器访问,并不取决于哪个存储担任本地协调器;协调器移交也不会影响其他存储的中继中心会话。只有当存储中有非幽灵 Agent,且其所在机器至少信任一台远程设备、主体或机器时,才会保持会话;在此之前,任何信息都不会到达中继中心,甚至包括设备 ID。可见 Agent 会通告自身、在线状态以及其托管的房间;隐藏 Agent 只通告设备 ID,因此可通过该 ID 联系到它。
中枢是一组服务,而不是一个地址。存储器通过它排名最高的中继保持一个中枢会话:首先是运行在自身机器上的中继,其次是由受信任设备在本地网络上提供的中继,然后是任何提供中继服务的受信任设备,最后才是配置的中枢作为回退。与设备的直连始终优先于所有中继,因为向直连设备发送请求时,完全不会经过中枢会话。拨号失败或断开会话的中继会被暂时跳过约一分钟,然后再尝试。
持有其机器上当选协调器角色的存储器会提供中继服务,同时通过配置的中枢保持自身会话:它会在监听所有接口的 WebSocket 监听器后运行与公共中枢相同的中继逻辑,并在其广播的通告中提供该服务(wire-mesh/relay-offer 扩展);通告会送达其对等节点,并通过中枢送达其他位置的受信任设备。中继只允许本机的网状成员以及本机按设备或主体信任的设备接入,并在本机直接连接的客户端之间转发。通过中继传输的负载在两个设备之间进行端到端加密,因此信任中继是对可用性和元数据(谁与谁通信,以及中继自身的地址)的判断,绝不会涉及内容。
中继通过自身中枢会话的连接作为上行链路,并通过该链路为客户端提供服务,因此,位于本机中继之后的存储器仍可被只能通过配置中枢访问它的设备连接,也能连接这些设备;两端各自都以自身的设备 ID 显示,而不是中继的设备 ID。中继的某个客户端通告经中枢返回时,不会取代该客户端的注册;刚刚切换到中继的设备在其首个通告获接收后即可路由,而这可能要等到下一次广播。
仅通过中枢获知、而非直接连接到此网状网络的设备,会按其最近一次广播通告中的在线状态显示。由于中枢会话仅承载摘要,其工作目录、房间和标签列表会显示为未共享;本机选择信任的桥接程序会通过双方已有的端到端会话,直接向该设备请求这些信息;从下一次列表更新起,list_agents 就会显示返回的内容。除非应答的桥接程序也信任请求方,否则请求会被拒绝,因此这些详细信息绝不会传给操作员未固定信任的设备。当此类设备断开连接时,没有任何机制会通知网状网络的其他部分,因此 list_agents 不会无限期地将其显示为在线,而是在经过几次重新通告间隔、通告未更新后,停止信任过时的通告并报告设备离线。
不同机器上的两台设备只能通过彼此的中枢中继流量,前提是双方都明确将对方的设备 ID 加入信任——这是一种默认拒绝、固定密钥的模式,没有目录查询,刻意与普通对等连接采用的固定信任方式保持一致。
受信任集合存储在一个文件 ~/.agent-comms/gateway-trust.json 中,由机器上的每个桥接程序以及默认 cc-peer 前端处理的每个会话共享,因此,在其中任一处信任某设备,就会对所有位置生效。
# Trust a remote device you already know the device-id for
agent_comms({ action: "gateway_trust", device: "1a2b3c..." })
# Trust a person rather than one device: pass the Principal line from their whoami
agent_comms({ action: "gateway_trust", device: "<their principal>", principal: true })
# Trust a machine: pass the Machine line from its whoami
agent_comms({ action: "gateway_trust", device: "<their machine>", machine: true })
# Stop trusting it
agent_comms({ action: "gateway_untrust", device: "1a2b3c..." })
# Revoke every device that machine vouches for in one act
agent_comms({ action: "gateway_untrust", device: "<their machine>", machine: true })
# List every currently trusted device
agent_comms({ action: "gateway_list_trusted" })
信任某个主体即可涵盖该主体运行的所有设备,因此无需逐一添加其每个会话。每个桥接程序都会广播一个短时有效的证明,由其账户的用户身份签名,以证明其属于该主体。信任该主体的网关会验证此证明,随后允许该设备出现在 list_agents 中并接收请求,直到证明失效或你取消对该主体的信任。gateway_list_trusted 会显示此类设备及为其背书的主体。
在主机上,一个账户运行的每台设备都以相同方式受信任:每个桥接器凭借其机器身份提供证明,使设备在机器受信任期间可被访问;取消对机器的信任,则会立即切断所有只能通过该机器访问的设备。通过 ID 或受信任的主体获得信任的设备,仍可通过相应途径访问。证明只会随可见 Agent 的公告一起发送,因此隐藏的 Agent 不会透露设备 ID 以外的任何信息,也不会透露其主体或机器。
设备散播的每条公告都由该设备自己的密钥签名,中心节点和每个接收会话都会先验证签名,再相信公告的来源。这可以验证条目描述的是哪台设备,因此无人能够发布或替换其他设备的目录条目;但条目中的内容仍由该设备自行决定。
有三点限制值得了解。证明表示主体或机器为某个设备 ID 背书,而不是表示散播该证明的人就是那台设备:任何连接到中心节点的人都能读取并转发证明,因此证明只足以让设备列入目录并可被路由到,不能让中继帧被视为可信,而且发往该设备的每个请求仍会进行端到端身份验证。证明无法撤销,只能等待过期,因此只要设备仍持有账户密钥,将其从账户中移除也无法阻止它;只要主机仍持有自己的机器密钥,它就会继续为自己的设备背书。此外,证明会以明文标出其主体或机器,因此中心节点上的任何人都能看到哪些设备属于同一个人,以及哪些设备运行在同一台主机上。若要将这些信息保密,就应隐藏 Agent。
通常需要先通过带外渠道(Slack、电子邮件、电话)获取设备 ID,然后才能将其粘贴到 gateway_trust 中。连接码是一次性、短时有效的凭据,可以让这一交接过程本身可验证,而不只是传递一个未经身份验证的字符串:
ConnectionCode {
code: nonce # single-use
expiresAt: timestamp # short-lived
deviceId: hex # the device being vouched for
signature?: PgpSignature over (code, expiresAt, deviceId)
}
code/expiresAt 是始终会检查的部分:它证明兑换者最近确实在这次特定的交互中位于另一端。signature 是可选的,回答的是另一个问题——这项声明是否由持有特定长期 PGP 密钥的人作出。只有在存在时才会检查它,而且从不要求提供:没有 PGP 身份的设备仍可生成并兑换不带签名的连接码。
# Generate a code vouching for this device, unsigned
agent_comms({ action: "gateway_generate_connection_code" })
# ...or signed with a PGP private key you already have (agent-comms never generates or stores PGP key material itself -- you supply it per call, the same way you would to `gpg --sign` directly)
agent_comms({ action: "gateway_generate_connection_code", privateKey: "-----BEGIN PGP PRIVATE KEY BLOCK-----..." })
# Relay the printed code to the counterpart out of band, then redeem it on their side -- a successful redemption trusts the device it vouches for, exactly as if gateway_trust had been called directly
agent_comms({
action: "gateway_redeem_connection_code",
code: "...", expiresAt: "2026-01-01T00:15:00.000Z", device: "1a2b3c...",
})
# Redeeming a signed code needs the signer's public key to verify against -- paste it directly, or supply a fingerprint you already trust and let the redeemer fetch it from keys.openpgp.org
agent_comms({
action: "gateway_redeem_connection_code",
code: "...", expiresAt: "...", device: "1a2b3c...", signature: "...",
fingerprint: "aabbccddeeff00112233445566778899aabbccd",
})
指纹绝不会由密钥服务器提供为信任锚——它是兑换者已经通过其他方式独立信任的信息(例如名片、此前的验证等)。兑换者需要自行检查实际获取或粘贴的密钥是否与该指纹完全匹配,而不只是确认找到了_某个_密钥。兑换过程绝不会回连生成连接码的一方:其目的正是在两台设备尚未建立任何连接之前建立信任。
一个账户,多台机器
你的用户主体就是你的账户:~/.agent-comms/user-identity.json 中用于签署设备成员资格证明以及你发出的每项 DM 授权的密钥。同一个账户可以存在于多台机器上,每台机器都会以同一主体的名义为自己的设备背书,因此,任何信任你主体的人都会信任你运行的所有机器,无需逐台机器或逐对设备进行设置。
新机器只需一次明确操作即可加入。在已持有该账户的机器上发出邀请(即连接码),然后通过带外渠道将其传给新机器:
npx agent-comms account invite # on a machine that holds the account; keep it running
npx agent-comms account join # on the new machine; paste the invite at the prompt
account join --label <name> 还会告知发出邀请的机器你给新机器取的名字:该机器会将此标签保存为自己对那台机器使用的昵称(参见“名称”),因此从此以后,刚刚获准加入的机器都会以该名称列出。标签必须符合普通名称规则;如果不符合,会在兑换前被拒绝,而且不会离开发出邀请的机器上的昵称文件。
这两者都是需要运行的命令,不是 agent_comms 工具操作:邀请是账户密钥,谁先兑换就归谁;加入操作会替换机器持有的账户,因此无法通过告知 Agent 来执行其中任何一项,而且邀请只会打印到你的终端,绝不会出现在 Agent 的对话记录中。join 会在一个不回显输入的提示符处读取邀请,因此不会将其留在 shell 历史记录中。
account invite 会启动自己的服务,并让它持续运行,直到邀请被兑换或过期,因为签发设备只会将邀请保存在内存中,每个邀请只响应一次,且过期后不再响应。邀请的有效期与连接码相同,也可以通过 --ttl-minutes <n> 缩短,但不能延长。兑换邀请会像 gateway_redeem_connection_code 一样信任签发设备,然后通过网状网络的端到端身份验证请求路径,直接或经由中枢,向该设备请求账户密钥;密钥会使用从邀请派生的密钥进行加密后返回,只有持有邀请的人才能解密。如果签发设备拒绝加入请求或始终不响应,该信任就会被撤销,因此加入失败后,新机器不会信任任何它之前不信任的设备。邀请可以通过 --sign-key <armored private key file> 使用 PGP 签名;加入时会使用 --public-key <file> 验证签名,如果提供了 --fingerprint <hex>,还会将其固定为指定指纹。签发邀请也会让签发机器信任你自己的主体;加入则会让新机器信任该主体,因此从此以后,两台机器都会列出对方的设备。新机器原先持有的账户会保存在旁边的 user-identity.replaced-<id>.json 中,因为仍只有该账户的密钥才能撤销它签发的内容;机器上运行的桥接程序会在重启后获取新账户。
要在不使用网状网络的情况下复制账户,请将其导出到由密码短语加密的文件,再在另一台机器上导入。密钥绝不会被打印;密码短语会从 AGENT_COMMS_ACCOUNT_PASSPHRASE 读取,或通过不回显输入的提示符获取;文件会以仅所有者可访问的权限写入,并且绝不会覆盖已有文件:
npx agent-comms account export ~/account.bundle # on a machine that holds the account
npx agent-comms account import ~/account.bundle # on the other machine, then delete the file
请通过这种方式或通过加入来复制账户,绝不要在机器之间复制 ~/.agent-comms:每台机器都会为下方的授权账本保留各自的写入者 ID,两台机器共享同一个 ID 会导致账本损坏。
账户签发的每项授权及其撤销操作都会记录在复制账本中,而不是记录在创建授权的机器上:每台机器都会将内容追加到自己的数据域日志中(wire-mesh 的复制式签发者授权账本模式,spec/PATTERNS.md),日志会复制到每台持有该账户的机器,因此在其中任何一台机器上运行 dm_revoke,都能找到并撤销另一台机器签发的授权。日志条目会使用从账户密钥派生的密钥加密,因此存储或中继这些条目但不持有账户的设备只能看到密文;写入账本仍需要账户私钥。账本会通过直接的对等连接(本地网状网络,或能够彼此直接连接的机器)进行复制;对于只能通过中继中枢连接的机器,则会通过一条在两台设备之间端到端加密的中继配对进行复制,因此中枢传输的只是密文。无论采用哪种方式,日志都只会提供给成员资格证明表明其属于此账户的设备;绝不会提供给可通过中枢访问、但属于其他账户的设备。每次撤销也会在发生时立即通知所有已连接的对等节点。在账本记录此功能之前构建的版本中,user-identity.json 里的授权会在桥接程序首次启动时迁入账本,无需手动操作。
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。