自托管 · Claude Code + Codex · 手机优先 PWA

AI 在替你写代码。
但它随时会停下来问你一句话

你在 5 台服务器上跑着 AI 编码会话。它们会停下来问「这条 rm -rf 能跑吗」—— 而你在地铁上,SSH 不在手边。

WebAgents 把这些会话收进一个统一的管控台:一个收件箱、一个「批准 / 拒绝」、一条随时能接管的对话流。

Demo 跑的就是生产代码本身,只是数据源换成了编造的演示数据——它连一个 API 请求都不会发出去。

WebAgents 看板:5 台服务器的会话一屏尽览

为什么需要它

这不是一个"AI 编程工具"。它是你和一群 AI 之间的控制面

01 · 它会卡住等你

Claude Code 遇到危险命令会停下来等授权。你不在,它就一直等着。一个会话卡住,后面排的活全停。

02 · 你不知道它在哪儿卡住

5 台机、十几个项目。哪台在跑?哪个在等你?你得挨个 SSH 上去看——如果你手边有电脑的话。

03 · 终端和 Web 是两个世界

终端里起的会话,网页上看不到;网页上起的,回到终端接不上。你被绑在某一端。

它做什么

六个能力。每一个都能在上面的 Demo 里点开看。

多机统一看板

所有服务器的会话一屏尽览。六态模型:运行中 / 等待审批 / 等待输入 / 空闲 / 已完成 / 出错——一眼看出谁在等你。

待办收件箱

四类待办统一收口:审批(危险命令)、计划(改动前确认)、提问(AI 在征询)、验收(回合产生了文件变更)。手机推送直达。

双引擎,同一套 UI

Claude Code 与 Codex 并列。两套完全不同的协议(SDK / app-server RPC),在上层收敛成同一个会话模型、同一个审批流。

终端 ⇄ Web 双向接管

终端里 claude 起的会话,Web 上只读追踪 + 一键接管;Web 上起的会话,也能释放到终端继续用 --resume 接。同一份会话,两端都能拿。

交互式终端

浏览器里的真 PTY(tmux 支撑 + 手写 WebSocket + xterm.js)。跨 agent 重启持久——部署完回来,你的 vim 还开着。

出了事查得清

跨服务器执行历史(按回合组织,不是一行行日志)、agent 重启事件簿(带「当时被杀掉了哪些会话」)、数据层健康看板。

长这样

下面五张全部截自 Demo 站点,你可以自己点进去翻。

待办收件箱
收件箱 · 四类待办统一收口
审批:危险命令被拦下
审批 · 危险命令被拦下,附完整上下文
对话流:markdown、工具调用、diff、流式
对话流 · markdown / 工具卡 / diff / 逐字流式
消耗:token 与成本
消耗 · token 与成本,按项目/服务器拆

怎么搭的

一个 hub,N 个 agent。远端不开任何入站端口

浏览器 PWA 手机 / 桌面 nginx TLS + 登录网关 hub 只监听 127.0.0.1 agent · 东京 claude / codex / pty agent · 新加坡 claude / codex / pty agent · 法兰克福 claude / codex / pty …N 台 SSH 隧道 (零入站端口)
结构化事件总线为主agent 扫描 CLI 自己的会话日志(JSONL / rollout),产出结构化事件;终端镜像只做兜底。不是"把终端搬到网页"。
远端零入站端口agent 只监听 127.0.0.1,靠 hub 主动建立的 SSH 反向隧道接入。远端防火墙一个洞都不用开。
agent 零依赖纯 Node 原生模块,远端只需放几个 .js 文件 + 一个 systemd unit。没有容器,没有 npm install。

踩过的坑,和留下的防线

这一节是我认为这个项目最值得看的部分。
不是"我有多少功能",而是"它在真实的多机生产环境里坏过哪些次,以及每一次留下了什么"。 下面每一条都是线上真实发生过、并且付出过代价的。

对话流永久空白,一直转圈 event loop 饥饿
根因解析缓存按 mtime 失效,而正在运行的会话每写一行 mtime 就变 → 每次都全量重解析。一个 16MB 的长会话扫一遍要 1 秒,而 SSE 每 300ms tick 一次、且不等上一轮跑完 → 同一个文件被 3~4 个全量扫同时碾 → event loop 被埋 → 首帧发不出去。会话一停写就自愈,所以静态环境根本复现不出来。
防线增量续扫(append-only ⇒ 只解析新增字节,1000ms → 3ms)+ 同文件并发解析合并 + SSE tick 不叠加。三道缺一不可。回归测试验「增量 ≡ 冷启全量」,含"正在写入的半行"这个最阴的用例。
界面冻在 15 分钟前,等多久都不刷新 SSE 半死连接
根因手机休眠 / 切后台 / NAT 超时后,TCP 还开着,但一个字节都不会再来。此时 onerror 永不触发readyState 恒为 OPEN —— 而兜底轮询的判据是「CLOSED 才回退」,那个条件永远等不到。服务端本来有心跳,但发的是 SSE 注释行 : ping ——浏览器根本不把它暴露给 JS。等于装了心跳,线没接。
防线心跳改成具名事件 event: ping + 前端心跳看门狗(会话本该活跃却 45s 无字节 → 主动重建)+ visibilitychange 切回前台立刻补拉。看门狗不要求对端配合——"活跃却静默"本身就够,所以对着还没升级的旧 agent 也能自愈。
部署一次,把用户正在跑的会话拦腰杀了 重启 = SIGKILL
根因平台会话是 agent 的子进程,跑在同一个 cgroup 里。systemctl restart 一下,正在执行的那一回合被拦腰截断——tool_result 已落盘、assistant 的下一回合永远不会来,会话冻在半途,前端把这条悬空的 tool_use 永久渲染成「正在执行 305 秒」。
防线部署脚本先查目标机的活跃平台会话,有就直接拒绝(除非显式 force)+ 原子落位 + 失败自动回滚。另外 agent 上报 bootId,hub 据此识别重启并记下「当时被杀掉了哪些会话」——那份名单只有重启前那一拍轮询里有,事后再也拿不到。
点了「允许」,审批卡消失后又冒出来,然后再消失 陈旧快照覆盖乐观更新
根因前端每 3 秒轮询,拿服务端列表整份覆盖本地。只要有一拍快照早于你的点击(请求在途 / hub 还没轮到那台 agent),它带回来的这条仍是 pending —— 于是刚隐藏的卡片被复活。可见时长恰好一个轮询周期。
防线给刚提交的操作立墓碑(20s TTL):墓碑期内服务端再报 pending 一律按本地已解决压住。超时后不再压制——万一服务端真的处理失败,卡片会重新出现让你重试,而不是被永久藏起来。回归测试:关掉墓碑即失败
会话明明空闲,却死活接管不了 文件新 ≠ 有人在写
根因接管守卫的判据是「JSONL 的 mtime 在 90 秒内 ⇒ 有人在写 ⇒ 拒绝接管」。可 mtime 是任何东西都能碰的:一次 touch、一次 rsync -a、一个备份工具扫过去——会话就被锁死,而用户面前一条出路都没有
防线改成必须 /proc 里真有写者:① 有进程显式攥着这个会话(铁证);② 该项目下有 CLI 进程文件近期确在动。mtime 降级为佐证,自己不再构成拦截理由。另外把 4xx 的可操作字段原样透传——此前代理层把 409 压成 500 并吃掉了 canForce,导致「强制接管」按钮永不出现。

怎么跑起来

一台机做 hub,其余机各放一个 agent。

# 1. 主控机:构建前端 + 起 hub
cd web && npm ci && npm run build
systemctl enable --now web-agents-hub

# 2. 每台被管服务器:放一个 agent(零依赖,只是几个 .js)
bash server/deploy-agent.sh root@your-node
#    ↑ 部署前会先查该机有没有正在跑的会话,有就拒绝——不会把你的活干断

# 3. hub 侧建 SSH 隧道接入(远端不用开任何入站端口)
systemctl enable --now web-agents-tunnel@your-node