多窗格编程智能体终端的架构与难点
MIT 许可的自托管工作区 SpaceApp 在浏览器中并行运行编程 CLI,文章拆解 PTY、WebSocket、终端回放与重连实现。它将终端进程与核心服务分置容器,使核心服务重启时 CLI 继续运行,并披露尚未解决的背压问题。
AI 深度解读
把多个 AI 编程 CLI 放进浏览器,难点远不止给每个窗格装上终端。进程能否活过服务重启,断线后输出能否接上,Ctrl-C 会不会误杀任务,以及慢浏览器会不会拖垮整个链路,都得逐一处理。
SpaceApp 作者拆解了公开版本 v1.1.9 的实现。它已经补上不少真实 CLI 暴露的故障,但还没有解决端到端背压。对准备做 Web Terminal、远程开发工具或 Agent 工作台的程序员,这篇更有用的部分,是那些看起来合理、运行后才发现不够的设计。
终端进程为什么要和 Web 服务分开
SpaceApp 是 MIT 许可的自托管工作台。它把 Web 应用、API、WebSocket 放在 spaceapp-core,默认监听 127.0.0.1:4911;所有 PTY 则交给 spaceapp-cli,以非特权用户 uid 10001 运行。PTY,即伪终端,让 CLI 获得接近本地终端的交互环境。
两个容器通过共享卷上的 /run/spaceapp-cli/host.sock 通信,权限是 0660。这样重启 core 时,CLI 进程仍留在另一个容器里。
每个窗格对应一个 node-pty 进程。下面这段启动代码解决了 Ctrl-C 杀掉整个 CLI 进程树的问题:
export const spawnNodePty: CliHostSpawn = (spec) =>
spawn("/bin/sh", ["-c", '/usr/bin/stty -isig; exec "$@"', "space-cli-host", spec.command, ...spec.args], {
name: "xterm-256color", cols: spec.cols, rows: spec.rows, cwd: spec.cwd, env: spec.env });
关键是 stty -isig:关闭终端的信号转换后,Ctrl-C 以字节 0x03 传给 CLI,由 CLI 自己处理取消操作。
退出也有坑。作者发现 node-pty 的 kill() 只发送 SIGHUP,而且会吞掉错误,部分子进程因此残留。他们改成依次发送 SIGHUP、SIGTERM、SIGKILL,最后通过 kill(-pid, SIGKILL) 清理整个进程组;退出事件没来时,再用 process.kill(pid, 0) 巡检核对。
不过,关掉浏览器不等于任务永久后台运行。没有连接的会话默认在 30 分钟后回收,每 60 秒扫描一次。回来时宿主会重建并恢复会话,原文没有交代各类 CLI 的恢复细节,不能把它理解为进程从未中断。
断线恢复靠两份输出记录,但它们用途不同
宿主给每次启动分配 generation id,给输出分配单调递增的序号。重连时,调用方提交「序号 N 之后的内容」,宿主就能判断哪些输出需要补发。
第一份记录是每个会话的 8 MiB 环形缓冲区,输出按最多 8,000 个字符分块。旧内容被覆盖后,宿主会明确抛出 CLI_HOST_REPLAY_GAP,而不是假装回放完整。
第二份是 PostgreSQL transcript。API 收到输出后,一路立即发给浏览器,另一路按 20 ms 或 32 KiB 批量入库。存储前用模式匹配脱敏,覆盖常见 API key、Bearer、Basic 请求头和会话 Cookie。脱敏作用于存储记录,浏览器实时收到的仍是原始输出。
宿主重启会丢失环形缓冲,数据库记录则能保留。重连回放持久化记录时,每处理 16 帧就让出事件循环,避免一段长历史持续占用 API。两份记录分别承担快速补发和持久保存,但原文没有展开它们切换时如何消除重复、保证衔接。
宿主协议采用长度前缀 JSON:4-byte 大端长度加 UTF-8 JSON,单帧最大 8 MiB。同一连接里的请求通过 Promise 队列严格串行。为降低跨会话阻塞,API 建立 32 条连接,用会话 id 的 FNV-1a 哈希固定分配通道。
这里作者的表述需要收紧:固定 32 条连接只能分散排队。不同会话仍可能落到同一条连接上,不能据此认定会话之间完全不会互相等待。
浏览器端则每个窗格建立一条 /api/panes/:id/cli/terminal WebSocket,用带 TTL 的一次性票据连接,票据由 32 个随机字节经 base64url 编码生成。消息是 JSON 文本帧,两端用 zod 校验。
多个标签页可以观看同一窗格,但只有控制租约持有人能输入。租约为 30 秒,作者最初也在 30 秒时续租,结果与到期时间竞争,发生丢键;现在心跳上限改为 15 秒。这类错误和模型能力无关,却直接决定用户是否信任工具。
浏览器写得慢一点,不等于整条链路有背压
每个窗格使用 @xterm/xterm 6,保留 2,000 行滚动历史。初始化先采用 PTY 保存的尺寸,避免回放先经过错误的宽高重排;convertEol 保持关闭,因为 PTY 的 termios 已处理换行,再转换会破坏全屏 TUI 的光标定位。
浏览器一次只执行一个 terminal.write(chunk, callback),等回调后再写下一块,单块最多 1 MiB,块间让出执行机会。隐藏或最小化的窗格停止解析,把输出排进 JavaScript 队列。
问题在于,单窗格积压超过 128 KiB、总积压超过 1 MiB 时,系统只是记录压力诊断,并不截断队列。WebSocket 输出帧最多 8 KiB,按码点边界切分;超过 1 KiB 启用 permessage-deflate,maxPayload 为 16 MiB。这些措施改善传输和调度,却没有限制持续积压。
分块、压缩和主动让出执行时间,都不能替代端到端背压。
作者承认,服务端没有检查 bufferedAmount,浏览器慢时不会暂停 PTY;宿主调用 socket.write 也没有检查返回值。普通 Agent 输出尚能应付,但 CLI 突然倾倒大量日志、浏览器又走慢链路时,问题仍在。
真正难的是多观察者策略:一个标签页慢、另一个快,是否应该暂停共同的 PTY?暂停会影响快用户;持续发送则要决定慢用户积压到哪里、是否断开并转入回放。原文提出了问题,没有给出完整实现。
另一个取舍是保留热房间。最多 6 个最近使用的房间保持挂载和连接,上限为 96 个窗格,移动端为 24 个。此前隐藏连接在 30 秒后停用,每次返回都要重新取票据、重建 xterm、完整回放;现在取消停用,切换更顺,但资源成本也留了下来。
我的判断
我认可作者把「进程存活、输出恢复、终端渲染」分开处理。Ctrl-C、进程组清理、租约续期这些故障也提供了具体排查入口。OpenCode 2 的两个 --standalone 服务共用存储时启动失败,最终改为每个 CLI 容器共享一个 opencode serve,加锁和健康检查,也说明真实 TUI 的行为需要实际运行才能摸清。
但这些属于作者报告的故障与修复。普通输出能撑住、保留热房间更合适,都没有吞吐、延迟或内存对照数据,应当视为项目经验。
安全边界也要单独看。所有窗格在同一个 CLI 容器内,以同一用户运行。独立的 HOME、TMPDIR、配置目录和 umask 077 只能分开配置,一个 CLI 仍可能读取另一个 CLI 的文件。房间不能直接拿来隔离互不信任的用户。
资源预算同样不轻:小配置共享 2 GB / 1.5 CPUs,大配置为 4 GB / 3 CPUs,作者建议宿主至少 4 CPUs、约 8 GB 内存。若目标是多租户服务,仅有当前容器划分还不够;若是个人自托管工作台,这种集中管理方式则更容易落地。
能拿来做什么
- 做 Node 终端服务时,可以借鉴独立 PTY 宿主、输出序号和明确的回放缺口错误,并实际测试重启、断线、Ctrl-C 和残留子进程。
- 做前端终端组件时,可以参考 xterm 回调串行写入和 PTY 尺寸恢复,同时为隐藏队列设置真正可执行的积压策略,别把告警阈值当成内存上限。
- 做多人观看终端时,应先决定慢观察者是否允许拖慢任务,再选择暂停源头、断开慢连接或依赖持久化回放;这个产品决策会影响整条数据链路。
- 想试用 SpaceApp,可以执行
npx --yes run-spaceapp@latest install。它要求 Node.js 20.11 或更新版本,支持 Linux、macOS、Windows 11,适合先用自己的 CLI 验证交互和资源占用。