博客
为什么我们基于 tmux 构建 Tactic Remote:会话持久化与终端架构
Tactic Remote 使用 tmux 管理 Claude Code 会话,在网络断开、Mac 休眠周期和应用重启期间提供持久化支持,同时实现终端输出实时流式传输到你的 iPhone。
继续查看该栏目下的产品与工程更新。
浏览此分类当我们开始构建 Tactic Remote 时,面临的第一个架构决策是根本性的:当控制设备(你的 iPhone)连接不稳定时,如何保持 Claude Code 会话存活并可观察?答案是 tmux,这个决策几乎塑造了产品的一切。
问题
Claude Code 作为长时间运行的交互式进程运行。一个典型的会话持续 10 到 60 分钟。在此期间,Claude 读取文件、提出修改、执行命令、等待审批并根据反馈进行迭代。如果进程在会话中途终止,所有上下文都会丢失。Claude 必须重新开始,重新读取文件,并重建对任务的理解。
在本地终端上,这不是问题——你的 Mac 保持运行,进程运行直到完成。但 Tactic Remote 引入了移动客户端。iPhone 会在 Wi-Fi 和蜂窝网络之间切换。它们会锁定屏幕并挂起后台应用。用户会走过没有信号的区域。任何将 Claude Code 进程生命周期绑定到 iPhone 网络连接的架构从根本上就是有缺陷的。
我们需要 Claude Code 独立于任何客户端连接运行,并且客户端可以随时连接和断开而不影响正在运行的会话。
为什么选择 tmux
tmux 是一个自 2007 年以来一直在生产环境中使用的终端复用器。它将终端会话的概念与显示它的终端模拟器分离。tmux 会话作为主机上的服务器进程运行。客户端连接以查看会话并与之交互,断开连接不会影响正在运行的进程。
在选定 tmux 之前,我们评估了几种替代方案:
screen — 最早的终端复用器。功能可用但维护不够活跃,脚本 API 也更有限。tmux 的结构化命令接口使程序化控制显著更容易。
自定义 PTY 管理 — 我们使用 node-pty 原型化了一个基于原始伪终端的解决方案。它适用于基本场景,但需要我们重新实现会话持久化、窗口管理和输出缓冲。我们在从零构建一个更差的 tmux。
基于容器的隔离 — 在 Docker 容器中运行 Claude Code 并附加 PTY。这增加了开销,使文件系统访问复杂化(Claude Code 需要读写你的实际项目文件),并引入了一个制造更多问题而非解决问题的抽象层。
nohup / disown — 简单的后台进程管理。没有输出捕获、没有重新连接、没有程序化控制。不适合交互式会话。
tmux 开箱即用地提供了我们需要的一切:会话持久化、程序化窗格控制、输出捕获和稳定且文档完善的 API。权衡是 tmux 必须安装在开发者的 Mac 上,但 brew install tmux 是一次性的安装成本,我们认为这是可以接受的。
架构
Tactic Remote 的 tmux 集成跨三个层次工作:
第一层:会话管理
当你通过 Tactic Remote 启动一个 Claude Code 会话时,Mac 伴侣应用会创建一个带有唯一标识符的新 tmux 会话。Claude Code 在该会话的主窗格中启动。
-d 标志至关重要——它以分离模式创建会话。无需连接终端模拟器,会话即可存在并运行。Claude Code 立即启动,Mac 伴侣应用在后台监控它。
每个会话获得一个专用的 tmux 会话,而不是共享会话中的一个窗格。这提供了干净的隔离:特定于会话的环境变量、独立的滚动缓冲区,以及不存在跨会话干扰的风险。
第二层:输出捕获与流式传输
Mac 伴侣应用持续从 tmux 窗格捕获终端输出,并通过 WebSocket 流式传输到已连接的 iPhone 客户端。这就是让你在手机上实时查看 Claude Code 输出的机制。
输出捕获使用 tmux capture-pane,坐标覆盖可见窗格内容加上滚动缓冲区。伴侣应用运行一个捕获循环,该循环:
- 以可配置的间隔(默认:200ms)捕获当前窗格内容。
- 将捕获的内容与上次已知状态进行比对。
- 仅向已连接的客户端发送差异部分。
- 维护一个最近输出的环形缓冲区,供重新连接并需要补齐进度的客户端使用。
200ms 的捕获间隔是响应性和 CPU 使用之间的平衡点。在此速率下,终端更新在 iPhone 上感觉接近实时,同时在现代 Mac 上仅消耗不到 2% 的 CPU。偏好更快更新的用户可以将间隔降低到 100ms;使用电池供电的用户可以增加到 500ms。
第三层:输入路由
当你从 iPhone 发送输入——无论是审批响应、输入的消息还是会话控制命令——Mac 伴侣应用使用 tmux send-keys 将其路由到适当的 tmux 窗格。这维持了直接终端连接的错觉,而实际输入是通过 WebSocket、跨网络进入 tmux 会话的。
审批响应是一个特殊情况。它们不是作为原始按键发送的,而是由 hook 系统处理并作为 Claude Code 权限系统期望的结构化响应注入。这确保了审批是原子性的,不会因部分网络传输而损坏。
实际中的持久化
tmux 架构在多个层面提供持久化:
iPhone 应用关闭。 tmux 会话继续运行。Claude Code 继续工作,审批在 Mac 伴侣应用上排队。当你重新打开 iPhone 应用时,它重新连接到 WebSocket,接收缓冲的输出,你回到之前的位置。
iPhone 断网。 从会话的角度看,与应用关闭相同。WebSocket 断开,输出在 Mac 上缓冲,重新连接后恢复状态。
Mac 进入休眠。 tmux 会话在休眠和唤醒周期中存活。当 Mac 唤醒时,tmux 服务器进程恢复,任何正在执行中的 Claude Code 进程继续运行。Mac 伴侣应用重新建立 WebSocket 监听器并重新连接到 iPhone。
Mac 伴侣应用崩溃。 tmux 会话独立存活。重启 Mac 伴侣应用会检测到带有 claude-remote- 前缀的现有 tmux 会话,重新连接到它们并恢复流式传输。不会丢失任何 Claude Code 上下文。
Mac 重启。 这是 tmux 会话无法存活的唯一场景,因为 tmux 会话仅存在于内存中。Claude Code 必须重新启动。我们清楚地记录了这一点,并正在探索 tmux-resurrect 集成以用于未来版本,尽管恢复交互式 AI 会话(而非仅仅 shell 状态)的复杂性使这并非易事。
会话生命周期
Tactic Remote 会话经历以下状态:
| 状态 | tmux 状态 | iPhone 状态 |
|---|---|---|
| 启动中 | 会话已创建,Claude Code 正在启动 | 加载指示器 |
| 活跃 | 进程运行中,输出流动中 | 实时终端视图 |
| 等待审批 | 进程在 hook 上阻塞 | 审批通知 |
| 暂停(客户端已断开) | 运行中,输出缓冲中 | 未连接 |
| 已完成 | 进程已退出,会话保留中 | 会话摘要 |
| 已过期 | 会话在保留期后销毁 | 已归档 |
已完成的会话在 tmux 中保留可配置的时间(默认:30 分钟),以便你在 Claude Code 退出后仍能查看最终输出。保留期结束后,tmux 会话被销毁,输出日志被归档。
可观测性
tmux 为我们提供了用自定义 PTY 方案难以实现的内省能力:
- 会话列表 —
tmux list-sessions提供所有活跃 Tactic Remote 会话的即时清单。 - 窗格尺寸 — 我们可以查询和设置窗格尺寸以匹配 iPhone 的显示特性,确保输出换行正确。
- 进程监控 —
tmux list-panes配合格式标志让我们无需维护单独的 PID 跟踪系统即可监控 Claude Code 进程。 - 滚动缓冲区访问 — 完整的滚动缓冲区可供搜索和检索,这为 iPhone 上的会话历史视图提供支持。
性能特征
通过 tmux 运行与直接终端相比增加的开销极小:
| 指标 | 直接终端 | tmux 会话 |
|---|---|---|
| 进程启动 | ~800ms | ~850ms |
| 按键延迟 | <1ms | ~2ms |
| 每会话内存开销 | — | ~3MB |
| CPU 开销(空闲会话) | — | <0.1% |
开销在实践中无法察觉。tmux 经过近二十年的生产使用优化,它引入的额外间接层对用户来说是不可见的。
经验教训
基于 tmux 构建是正确的选择,但并非没有挑战:
终端编码边界情况。 Claude Code 的输出偶尔包含与 tmux 的终端模拟意外交互的 ANSI 转义序列。我们维护了一个标准化层,在流式传输到 iPhone 之前清理输出。
macOS 上的 tmux 安装。 tmux 未预装在 macOS 上。我们考虑过将其捆绑,但最终决定不这样做——开发者通常更喜欢自行管理 tmux 的安装,而捆绑会给已经安装了 tmux 的用户造成版本冲突。
大规模下的 capture-pane 性能。 在大量并发会话下,捕获循环的 CPU 使用量线性增长。我们实现了自适应捕获频率,降低无近期输出变化的会话的捕获频率。
tmux 是一种在运行良好时会"消失"的基础设施。大多数 Tactic Remote 用户不知道也不关心 tmux 的参与——他们看到的是一个无论手机或网络发生什么都能可靠运行的会话。这正是我们追求的结果。