让本机 Agent 指挥浏览器里的多个网页 AI:一套 127.0.0.1 控制协议的设计取舍

0 阅读8分钟

上一篇写的是数据面:怎么把问题塞进网页输入框、怎么在没有 finish_reason 的世界里判断"回答完了"(把五个网页版 AI 拉进同一个群聊:不用 API key 的多 Agent 编排)。

这一篇写另一个方向,也是多模型群聊里更少人讨论的一半:控制面。

数据面解决"话怎么送进去、怎么取出来";控制面解决"谁有权让谁干活、失败了怎么自己爬起来"。当你希望调用方不是人、而是一个本机 agent(Codex、Claude Code 之类)的时候,控制面的设计决定了这件事能不能用。


一、先把拓扑摆出来

Agent(Codex / Claude Code)
   │  读 SKILL.md,执行 shell 命令
   ▼
openteamcli(进程,跑完就退出)
   │  HTTP POST /command  +  Authorization: Bearer <token>
   ▼
本地 daemon  ──  127.0.0.1:19305,只能本机访问
   │  WebSocket /ext
   ▼
Chrome 扩展 service worker
   │  runtime message
   ▼
各站点 iframe(ChatGPT / Claude / Gemini / DeepSeek / Grok)

四跳,比"扩展直接开个 HTTP 端口"多了两层。每一层都是被约束逼出来的,不是设计癖。


二、约束一:Manifest V3 不能监听入站连接

如果扩展能自己开一个 http://127.0.0.1:port,上面这套可以砍掉 daemon,CLI 直接连扩展。做不到。

MV3 的 service worker 没有监听端口的能力。它只能作为客户端发起连接(fetch、WebSocket),不能作为服务端接受连接。这是运行时环境的限制,不是权限问题——你申请了所有权限也开不了一个 socket server。

于是方向必须反过来:daemon 当服务端,扩展当客户端。

而一旦"连出方"是扩展,"连入方"是 CLI 的进程,就会出现一个新问题:CLI 怎么知道 daemon 在不在?

答案是固定端口 + 探活。协议版本和端口写在共享常量里:

export const OPENTEAM_CONTROL_PROTOCOL_VERSION = 1
export const OPENTEAM_CONTROL_DEFAULT_PORT = 19305
export const OPENTEAM_CONTROL_LEGACY_DEFAULT_PORT = 19826

扩展在连接之前先 ping 一次,1 秒超时:

const DAEMON_PING_TIMEOUT_MS = 1_000

端口不是硬编码的,它是设置项(agentControlPort),用户能改。所以迁移逻辑里留了一个 LEGACY_DEFAULT_PORT:老版本 store(version < 7)里存着旧默认端口 19826 的,读出来时自动换成 19305。

if (storedVersion < 7 && raw === OPENTEAM_CONTROL_LEGACY_DEFAULT_PORT) return OPENTEAM_CONTROL_DEFAULT_PORT

这一行看着无聊,但不写它,升级后的老用户会得到"daemon 起来了、扩展连不上"这种最难查的状态:两边都在跑,各说各的端口。

还有一个容易被忽略的坑:扩展页的 CSP 必须显式放行 WebSocket。MV3 的扩展页有独立 CSP,默认不允许随便连:

"content_security_policy": {
  "extension_pages": "script-src 'self'; ... connect-src 'self' https://* http://127.0.0.1:* ws://127.0.0.1:*; ..."
}

没写 ws://127.0.0.1:* 的时候,new WebSocket() 直接被拦在构造阶段,控制台一行红字,看起来像是 daemon 挂了。这个坑我调了半天。


三、约束二:service worker 30 秒后会被回收

第二层约束更麻烦:MV3 的 service worker 空闲几十秒就会被杀掉,下次有事件再重新启动。也就是说,扩展和 daemon 之间不存在一条"一直活着"的长连接——它是断断续续的。

setInterval / setTimeout 在这里没用:worker 一被回收,定时器就没了。能跨 worker 生命周期存活的只有事件和 chrome.alarms。

所以代码里挂了一个 24 秒周期的保活闹钟:

const CONTROL_KEEPALIVE_ALARM = 'openteam-control-keepalive'
const CONTROL_KEEPALIVE_PERIOD_MINUTES = 0.4

chrome.alarms.create(CONTROL_KEEPALIVE_ALARM, { periodInMinutes: CONTROL_KEEPALIVE_PERIOD_MINUTES })

这里是第二个坑:0.4 分钟(24 秒)并不会真的每 24 秒触发。Chrome 对已发布扩展的 alarm 有下限——Chrome 120 起是 30 秒,之前是 1 分钟,低于下限会打一条 warning 然后按 30 秒执行。所以这行的真实语义是"尽可能快地重新连一次",实际节奏约 30 秒。

闹钟触发后做两件事:唤醒 worker,调 sync() 重建连接。

重连本身是指数退避的,从 2 秒翻倍到 60 秒封顶:

const RECONNECT_BASE_DELAY_MS = 2_000
const RECONNECT_MAX_DELAY_MS = 60_000

const delayMs = Math.min(RECONNECT_BASE_DELAY_MS * 2 ** reconnectAttempts, RECONNECT_MAX_DELAY_MS)

这个约束直接决定了协议长什么样。 既然连接会随时断,协议就不能假设"我发出去的东西一定有人收",必须做到三点:

  1. 每个请求带 id。命令是一个信封:
export interface ControlHttpCommand {
  id: string
  action: string
  payload?: unknown
  timeoutMs?: number
  profileId?: string
}

daemon 端拿一个 pending Map 按 id 配对结果,谁超时就地失败,绝不会把结果串到另一个请求上。

  1. 新请求必须先问"现在有没有人接"。forwardCommand 第一步就检查连接:
if (!extension?.open) {
  return failure(command.id, 'extension_not_connected', 'OpenTeam 扩展尚未连接到本地守护进程。', '请打开已启用 OpenTeam 扩展的 Chrome,并在 OpenTeam 设置里开启本机智能体控制。')
}

注意这个失败自带 hint,agent 拿到就能直接照做,不用再问用户"怎么回事"。

  1. 在途请求靠 id + 超时兜底。请求发出去之后放进 pending,每条挂一个自己的定时器,超时了就删掉并返回 task_timeout。

这里我要诚实说一个还没做好的地方:扩展断开的那一刻,在途请求不会被立刻失败,它们会一直挂到自己的超时,然后得到一个 task_timeout。也就是说断线和"模型真的在思考"在这段时间里长得一样。

正确的做法应该是在 socket close 事件里把 pending 全部 flush 成 extension_not_connected。现在只能靠 skill 里的排查表兜住:"遇到 task_timeout 先跑 task read 看拿到多少,再决定要不要重发"。写出来是因为这类"看起来能用、其实语义模糊"的地方最容易被自己忽略。


四、安全模型:这个端口为什么要认真对待

一个监听在本机、能操作你五个 AI 账号会话的端口,本质上是一个本地提权面。控制面的设计里,安全性优先级高于便利性。

现在的做法四条:

1. 只绑 127.0.0.1,不绑 0.0.0.0。

const host = options.host ?? '127.0.0.1'

绑 0.0.0.0 意味着同一个咖啡厅 Wi-Fi 下任何人都能连。这一行代码的差别是同屋和全球的差别。

2. 每个请求要 Bearer token,token 落在用户目录。

export const DEFAULT_TOKEN_PATH = resolve(homedir(), '.openteam/control-token')

CLI 和 daemon 读同一个文件,不用额外配对流程。

3. 401 明确标注"不可恢复"。

function permissionDenied() {
  return { ok: false, error: { code: 'permission_denied', message: '没有权限访问 OpenTeam 本地控制接口。', recoverable: false } }
}

recoverable: false 这三个字是给 agent 看的。大多数 agent 的默认行为是"失败了就重试",如果所有错误长得一样,它会把 token 错误重试到超时。区分开之后,skill 里就能写一句"遇到 permission_denied 不要重试,先重启 daemon"。

我实际测了一下,不带 token 直接打这个端口:

$ curl -s -X POST http://127.0.0.1:19305/command -d '{}' -w '%{http_code}'
401
{"ok":false,"error":{"code":"permission_denied","message":"没有权限访问 OpenTeam 本地控制接口。","recoverable":false}}

4. 同一时刻只接受一个扩展连接。

extension?.socket.destroy()
extension = createExtensionConnection(socket, ...)

新的连进来就把旧的踢掉。理由不难猜:两个扩展实例同时写同一份 store,角色归属会乱——同一个角色在两个页面里各维护一个 iframe,回复算谁的都不对。比起处理这种冲突,只保留一个更省事,用户也更容易理解。

还有两条容易被忽略但同样必要的:请求体 1 MB 上限(MAX_BODY_BYTES),以及给本地 agent 执行任务时的工作目录白名单——越界的路径直接 403 workspace_not_allowed,不给"这次特殊处理"。


五、接口的验收标准:错误码能不能让 agent 自己爬起来

给人用的 API 和给 agent 用的 API,评价标准不一样。

人看到 "Error: request failed" 会去看日志。agent 看到同样的东西,只会重试、或者放弃并编一个理由。所以对 agent 来说,错误分类的质量直接等于可用性。

现在的错误约定是 code + hint + recoverable,配合一张状态码映射表:

function statusForError(code) {
  if (code === 'permission_denied') return 401
  if (code === 'workspace_not_allowed') return 403
  if (code === 'agent_not_found' || code === 'agent_run_not_found') return 404
  if (code === 'extension_not_connected') return 503
  if (code === 'task_timeout' || code === 'agent_failed') return 504
  return 400
}

而 skill 文档里,等价的东西是一张"出错先做什么"的表:

错误码处理方式
extension_not_connected让用户打开扩展页面,确认已开启本机智能体控制,再跑 doctor
control_disabled请在设置里开启本机智能体控制
role_not_ready先 chat initialize --chat <id>,或等 iframe 初始化
task_timeout跑 task read,汇报已收到的部分和仍在等的人
permission_denied不要盲目重试,先 daemon restart
fetch failed先 doctor 和 daemon status,再看是不是 daemon 退了

这张表的每一行都是踩出来的。写它的过程基本等于把"我会怎么排查"翻译成机器指令。


六、task.wait 的语义:永远返回,永远给部分结果

这是我改过最多的一处接口设计。

最直觉的写法是:等所有角色回答完再返回,超时就抛异常。问题在于"抛异常"对 agent 是个危险的信号——它会理解成"这次没成",然后重新发一遍任务。而对网页版 AI 来说,重发意味着五个模型重新答一遍,一轮几十秒,而且新回答和你已经拿到的部分回答对不上。

现在的实现:500 毫秒轮询一次,默认 5 分钟上限,超时不抛异常,返回结构化结果:

const DEFAULT_WAIT_TIMEOUT_MS = 300_000
const WAIT_POLL_INTERVAL_MS = 500

返回的东西长这样(TaskReadResult):

  • receivedRoleIds:已经答完的
  • pendingRoleIds:还在等的
  • errorRoleIds:明确失败的
  • replies:每条回复的 roleId、角色名、内容、状态

也就是说,调用方拿到的永远是"现在的局面",而不是一个二元的是非。agent 可以据此决定:三个人答了就够用了;或者再等 60 秒;或者把超时的那个人踢掉重来。

一个接口在失败时仍然返回有用信息,才有可能被 agent 正确使用。 这条在别的场景里也成立。


七、给 agent 的 SDK 是 Markdown,不是 TypeScript

这个决定一开始我自己也觉得别扭:产品有 TypeScript 类型定义,为什么不直接发布一个 npm SDK 让 agent 调用?

因为 agent 消费的不是类型,是上下文。

给它一个 SDK,它要做的是:读 API 文档 → 猜调用顺序 → 拼参数 → 处理返回。每一步都是概率。而给它一份 Markdown,里面写清"先 daemon start,再 doctor,如果 connected 是 false 让用户开开关,然后写一个 task.json,最后 run create-and-post --file task.json --wait"——它照做的成功率是另一回事。

所以这个能力是以 skill 的形式发布的:

npx skills add afumu/openteam --skill openteam-control

而 skill 里那些类型系统表达不了的东西,才是它真正的价值:

  • 顺序:先探活再干活,探活失败不要瞎试命令
  • 红线:不要把 token、cookie、用户隐私写进任务提示词
  • 边界:daemon 只许监听 127.0.0.1,不要暴露到局域网
  • 汇报口径:回答时说明哪些角色已回、哪些在等、是否超时

这些东西写成 TS 类型只会变成没人看的注释。

顺带说,同一份 skill 同时适用 Codex 和 Claude Code,不用为每个 agent 写一套封装——这也是我选 Markdown 的实用理由。


八、跑起来是什么样

$ npm install -g @afumu/openteamcli
$ openteamcli daemon start
$ openteamcli doctor
{
  "ok": false,
  "daemon": { "reachable": true, "port": 19305 },
  "extension": { "connected": false, "profiles": [] },
  "hint": "请打开 OpenTeam 页面,在设置里开启“本机智能体控制”。"
}

ok: false 但不报错——这是我觉得最该有的默认行为:体检命令不该在发现问题时崩掉,它该告诉你怎么修。 hint 字段就是干这个的。

连接正常之后,一次任务用 JSON 文件承载:

{
  "chat": { "name": "方案评审", "mode": "independent" },
  "roles": [
    { "source": "temporary", "name": "工程师", "systemPrompt": "从工程实现、复杂度、风险和测试角度评估。" },
    { "source": "temporary", "name": "产品经理", "systemPrompt": "从用户价值、范围控制和交付节奏角度评估。" }
  ],
  "task": { "target": "all", "content": "请评估这个方案,给出风险、建议和最终判断。" }
}
openteamcli run create-and-post --file task.json --wait

一条命令完成:建群 → 加角色 → 初始化 → 发任务 → 等回复。

顺便,daemon 还能接本地 ACP agent(通过 WebSocket 桥接到 session/prompt),也就是说"网页 AI 角色"和"本机代码 agent"可以进同一个群。这部分还是 MVP:配置文件在 ~/.openteam/acp-agents.json,默认关闭,按 agent 配工作目录白名单;仓库的 docs/ACP.md 里也明确写了 UI 侧还没做完。


九、这套设计的代价,说清楚

  • 只支持 Chromium 系浏览器。daemon 和 skill 是跨平台的,底座不是。
  • 单扩展连接。你想同时用两个浏览器配置?不行。
  • 只能本机。这是安全选择,不是能力缺失,但也确实意味着"远程让家里那台机器跑一下评审"做不到。
  • 它继承了下层的全部脆弱性。控制面做得再稳,底下仍然是 DOM 自动化——站点改版一次,最上层跟着一起哑。这一层能保证的是"失败得清楚",不是"不会失败"。
  • 许可证是 PolyForm Noncommercial 1.0.0,个人非商业使用。

最后

控制面这类代码没有 demo 可看,也没有 UI 截图,写出来一点也不性感。但如果你打算让 agent 去操作任何"有状态、会掉线、还有权限"的外部系统,你会发现最后卡住你的都是这几件事:连接方向、重连语义、错误分类、部分结果。

数据面决定它能不能用,控制面决定它敢不敢用。

源码和 skill 都在 github.com/afumu/opent…,CLI 在 npm 上是 @afumu/openteamcli。

一个想听听的问题:你更愿意让 agent 拿一个 CLI(可控但笨),还是拿一个 SDK(灵活但容易乱调)?