我的 AI Agent Coding 基础工具链
我现在写代码基本是多个项目进行,每个项目好几个 agent 一起开着:一个 pane 里 Claude Code 在改代码,旁边 Codex 在跑 test 和 review,还有几个处理杂活的。
同时进行的工作区一般 2-3 个,同时期维护的有 10+ 个,我不想每次都重新打开,所以怎么把它们安排好,能够自驱,且能支持随时随地介入就变得尤为重要。中间折腾了不少次,最终我建立了一套可插拔、高兼容性、高稳定性、错误易恢复的基础工具链。
我时不时遇到过机器卡死需要重启,Terminal app 闪退,或者比如手滑关掉一个终端窗口,以至于里面跑了半小时的 AI agent 被中断,还需要手动一个一个恢复。另外,现在优秀的 Terminal app 很多,Ghostty / cmux / Kaku / Otty 等等,我不想放弃尝鲜,但希望尝试的成本足够低,而且不能影响正在跑着的 agents。这个时候我想到了 tmux,决定用它来当底层的中枢,当 AI agents 运行的「容器」。
最终,这套基础工具链解决的问题:解耦具体的 AI agent 厂商,不绑定 Terminal app,不局限设备,有网就能用,出现错误易恢复。
整体架构
说白了就是一个 tmux 中枢,两条路进去。远程那条是 browser → tunnel → ttyd → tmux,本地那条是 任意终端 → tmux。
这两条路进的是同一个 tmux session,不是两份互相同步的副本。我在手机上敲的命令,笔记本的 Ghostty 里立刻就能看到,因为那本来就是同一个现场。
四层各干各的:
| 层 | 职责 | 可选? |
|---|---|---|
| tmux | session 持久化 + 工作区组织 | 不能省,但可以换成别的多路复用实现 |
| ttyd | 把终端变成一个网址 | 可选,只在本机用就不必装 |
| 外部 Web 访问 | 把这个网址安全送到外网 | 可选,三条路选一条,或者不要 |
| 本地终端 | 原生入口 | 系统自带,一直都在 |
层与层之间的接口简单到只有一个 localhost:6800,所以任何一层拿掉或者换掉,其余几层一行配置都不用改。后面所有的灵活性都是从这儿来的。
tmux:地基
tmux 给我的其实就两件事,但都属于用过就回不去的那种:一是不管发生什么,我都能回到原来那个位置;二是 Terminal app 从此随便换。
先说第一件。开头提到的那几种情况,手滑关掉窗口、Terminal app 闪退、机器卡死要重启,再加一个同样常见的「Ghostty 出了新版本,得退出重开才能升级」,在没有 tmux 的时候代价都是同一个:正在跑的东西全没了,几个 agent 得一个一个重新拉起来,工作区得重新摆一遍。有了 tmux,这几种情况我都能接着干。
但它们背后的机制并不一样,恢复的代价也差很远,得分成两类讲。
关掉窗口和重启电脑,恢复方式不一样
关掉终端窗口、终端自己崩了,这几种情况 tmux server 都还在后台好好跑着,agent 一点没受影响。重新 tmux attach 回去,所有 window、pane、工作目录、滚动历史、甚至运行的状态都丝毫不受影响。这是 tmux 自带的能力,不需要任何插件。
电脑重启:tmux server 跟着一起退出,光靠 tmux 自己恢复不了。需要加几行配置安装 tmux-resurrect 和 tmux-continuum 这两个 tmux 插件:前者负责把整个工作区结构存成快照,后者每隔一段时间自动存一次,并且在 tmux 启动时自动恢复。
我把间隔设成 15 分钟:
1 | set -g @plugin 'tmux-plugins/tmux-resurrect' |
@resurrect-processes ':all:' 这行值得单说。resurrect 默认只恢复一个白名单里的常见程序,claude 这种它根本不认识,所以不加这行,重启之后 pane 是回来了,里面却是空的 shell。
要说清楚的边界是:回来的是工作区结构和当时屏幕上的内容,不是进程的内部状态。agent 跑到一半的对话上下文不会自己接上,:all: 做的是把当初那条命令重新拉起来。但每个 pane 都停在正确的项目、正确的目录、正确的布局里,屏幕上还留着关机前那一屏,接着干就行。
Terminal app 只是一层外壳
第二件事说起来简单得多。因为真正跑东西的是 tmux,Terminal app 就退化成了一块显示器,Ghostty、cmux、Kaku、Otty、iTerm2、kitty、WezTerm、系统自带的 Terminal,我想换就换,成本是零,attach 上去看到的都是同一个现场。开头说的「想尝鲜,但不想为此付代价」,兑现的就是这一条。
顺带说一句 cmux。它本身就是冲着并行跑 agent 去的,分屏、标签、通知都有,乍一看像是能把 tmux 替掉。但它和 tmux 不在同一层:它解决的是「怎么在一个 app 里同时摆下好几个 agent」,tmux 解决的是「这个 app 没了之后东西还在不在」。而任何一个 Terminal app 都给不了后者,因为它自己就是那个会被关掉的东西。所以我是把它们叠着用,不是二选一。
这一点到下一层会变得更值钱:连浏览器也不过是又一个壳而已。
我平常怎么布局工作区
地基稳了,接下来是怎么在上面安排具体的项目和 agent。
一个 window = 一个项目。 切 window 就是切项目上下文,不用 cd 来 cd 去,也不用记哪个终端标签页对应哪个项目。
同一个 window 里 split pane,并行跑多个 agent。 这是我用得最多的模式:左边 pane 跑 Claude Code,右边跑 Codex,左边写完一段实现,直接让 Claude Code 去调右边的 Codex 做 code review。两个 agent 在同一屏里,上下文和产出一眼全看得到,不用在窗口之间来回切。
为了少按几下,我给这个动作专门留了几个键:
1 | bind A new-window -n "agent" -c "#{pane_current_path}" # 新开一个 agent 窗口 |
-c "#{pane_current_path}" 是这几行的重点,新开的 pane 直接落在当前项目的目录里,不用再 cd 一次。
tmux 浮层我放了两个,用途不一样。
第一个是通用浮层,Ctrl+b 然后 Ctrl+f。这里有个坑:display-popup 本身是临时的,关掉就没了,里面的东西不会留下。想要一个「关掉再开还在原来位置」的浮动终端,得让 popup 去挂一个独立的 scratch session:
1 | bind C-f if-shell -F '#{==:#{session_name},float_pane}' { |
new-session -A 的意思是有就 attach、没有就新建,所以这个浮层的状态一直在。前面那个 if-shell 判断是为了让同一个键既能开也能关:已经在浮层里了就 detach 收起来,不在就弹出来。
第二个是 lazygit 浮层,Ctrl+b 然后 Ctrl+g。它跟当前 window 绑定,我在哪个项目按,弹出来的就是哪个项目的 lazygit,看完 diff、提交完,一个键收起来,原来的布局纹丝不动。
嫌 Ctrl+b 再 Ctrl+g 这种两段式快捷键麻烦的话,可以配合我写的 HyperCapslock,把 Hyper + g 映射过去,一个键就弹出来。
这个东西我写得比想象中费劲,有两个坑:
一是 display-popup 的 -e 和后面的 shell command 不会被 tmux 的 format 展开,只有 -d 会。所以直接在命令里塞 #{window_id} 是拿不到值的,而且 popup 内部去读 #{window_id} 会解析到错误的 client。最后的做法是用 run-shell 把 window 和 client 在按键那一刻抓出来,作为参数传给一个外部脚本。
二是所有 lazygit 实例我都塞进同一个叫 _lazygit 的 scratch session 里,每个项目对应它下面的一个 window。不这么做的话,用过 lazygit 的项目越多,session 列表就越长。
手机上怎么切。 prefix + s 列出所有 session 和 window,方向键选;prefix + w 是跨 session 的 window 列表。set -g mouse on 打开之后很多切换直接点就行,没有物理键盘的时候这是刚需。
ttyd:把终端变成一个网址
一个很自然的疑问是,Claude 有官方的远程方案,Codex 也有手机 app,为什么还要自己搭一层 ttyd。
因为它们只服务自己那一家。ttyd 转发的不是某个 agent,而是终端本身。所以任何跑在终端里的 agent 都直接可用,一行配置都不用改;明天换一家,基础设施一动不动。而且不止是 agent,build、tail -f 日志、htop、数据库 CLI,本来就活在终端里的东西全都顺带有了。
这行业半年换一茬工具。把基础设施绑在某一家的官方客户端上,等于在赌它永远是最好的那个,我不太想赌,这样才能一直用当下最好的那个。
顺带还有一个收益是对设备和系统的不挑:有浏览器就能用,不装任何客户端。iPad、别人的电脑、安卓平板,打开网址就是我的工作区。
tmux 和 ttyd 这两层讲完了,正好可以回过头看看不搭它们是什么样:agent 直接跑在 Ghostty 的几个 tab 里。这也是我自己最开始的状态。
启动命令是这样:
1 | ttyd -W -p 6800 -i 127.0.0.1 -t fontSize=16 tmux new -A -s web |
三个要点:
-W必须加。 ttyd 默认是只读的(--writable,readonly by default),不加它你在浏览器里敲键盘完全没反应。这是最容易卡住人的一个坑,我自己也踩了。-i 127.0.0.1只绑回环地址,不直接暴露到网络。下面讲安全的时候还会再说这一条。tmux new -A -s web的意思是有web这个 session 就 attach,没有就新建。浏览器永远从一个固定入口进去,进去之后再用 tmux 命令跳到任何已有的 session。
ttyd 还是 wetty? 两者本质是同一类东西,都是
xterm.js + WebSocket的网页终端,也都不能被ssh连入(wetty 是「网页版 SSH client」,不是 SSH server)。区别只在后端:ttyd 直接 fork 进程,wetty 走 SSH。单人自用我选 ttyd,一个 C 写的静态二进制,brew install ttyd装完就能跑,资源占用极低。这个决定一次定了就不用再纠结。
对外通道:三种方案,随时可换
这一层负责把本机的 ttyd 安全地送出去,有三个选择:
- Tailscale:私有网络,手机装个 App 登录同一个账号就能连上,不开端口、不用域名、不用证书。单人自用投入产出比最高。但这里有个很容易踩的地方:ttyd 绑在
127.0.0.1之后,手机拿着 Tailscale 分配的那个100.x地址是连不上的,因为那条路根本不经过回环网卡,你会直接吃一个 connection refused。正确的做法是用tailscale serve把 tailnet 上的请求代理到127.0.0.1:6800,跟 cloudflared 在本机做转发是同一个套路。千万别图省事把 ttyd 改绑0.0.0.0,那等于把下面要讲的那层网络隔离直接拆掉。 - Cloudflare Tunnel:给你一个公网 URL,TLS 在边缘终结,还能叠一层 Access 做身份门。适合想要一个好记网址、或者要分享给同事的场景。
- 干脆不加:纯本地用,或者局域网内用 SSH tunnel。
这一层真正的收益其实不是「能远程」,而是它本身可以随时换掉。今天用 Tailscale,明天要分享给别人就换 Cloudflare Tunnel,tmux 和 ttyd 一行配置都不用动,因为它们之间只隔着一个 localhost:6800。分层的价值就在这儿。
至于本地终端那一层,浏览器终端在手机上是有摩擦的,输入法、快捷键、字体都比不上原生。所以坐在电脑前的时候我照样用 Ghostty 直接 attach。这两条路是并存的,不是二选一,远程那套加上之后本地体验一点没变。
几处关键配置,和踩过的坑
有两个坑我是踩过之后才写进配置的,都属于「不报错,但就是不生效」那一类,单独拎出来说。
第一个:tmux-continuum 必须放在所有会改 status-right 的插件后面。 continuum 是把自动保存的钩子挂在状态栏字符串里的,我用了 tmux-power 主题,它会重写 status-right。插件顺序一反,钩子直接被覆盖,自动保存就静默失效了,一点提示都没有,等到重启才发现快照停在几天前。
第二个:别自己给 resurrect 挂 save 钩子。 我一开始图快,给 window-linked、window-unlinked、session-created、session-closed 全挂上了保存脚本,想着这样存得更勤。结果是快照反而更容易丢:
unlinked/closed在 tmux 关闭时会逐个窗口触发,把最新快照覆盖成空的;session-created会在新 tmux server 起空会话时抢在 continuum 恢复之前触发;window-linked在 continuum 重建窗口的过程中也会触发,把半恢复的状态存进去覆盖掉好的。
最后我全删了,只留 continuum 那个 15 分钟的定时保存,需要立刻存就手动按 prefix + Ctrl-s。写这篇的时候顺手看了眼快照目录,21:43、21:58、22:13,刚好 15 分钟一个,确实在正常工作。
另外还有一行跟 agent 直接相关的:
1 | set -g detach-on-destroy off |
默认情况下,你所在的 session 被销毁时 tmux 会把 client 一起踢掉。挂着长时间跑的 agent 时这个行为挺烦人,关掉之后它会切到另一个还活着的 session,而不是把你扔回 shell。
最后顺手纠正一个到处都在传的说法。很多配置会让你加上这行:
1 | set -g window-size latest |
给的理由是:多端同时连的时候,tmux 会把 window 尺寸缩到最小的那个 client,笔记本和手机一起连着,屏幕就被手机那端挤成一小块。
这个理由在早期版本成立,但现在已经过期了。我起了一个不加载任何配置文件的干净 tmux server 实测(3.6a),window-size 的默认值本来就是 latest,也就是跟随最近操作的那一端。所以这行今天是多余的,我配置里也没有。只有当你用的版本比较老,或者某个插件把它改成了 smallest,才需要显式设回来。
安全:一层身份门,加一层网络隔离
把一个能输入的终端挂到公网,等于把整台机器的控制权放出去。但这不意味着要叠一堆密码。
我只留一层身份认证(Cloudflare Access,或者干脆用 Tailscale 的私有网)。ttyd 自己的 -c user:pass 反而是冗余的,登录两次纯属折磨。
但有一样绝对不能省,就是 -i 127.0.0.1。
它不是一道让你再输一次密码的门,而是一层网络结构上的保险。只要 ttyd 严格绑在回环地址,能到达它的唯一路径就是那条被身份门把守的隧道;否则局域网里任何人都能 http://你的IP:6800 直接绕过所有认证进来。这一层零摩擦、零成本,挡住的却是最危险的那条旁路。
所以「两层防护」应该理解成身份门加网络隔离,而不是两次密码。
按需叠加:给三类人的建议
整套东西的顺序是:tmux 是地基,要远程再加 ttyd,要外网或者手机再从 Tailscale 和 Cloudflare 里挑一个,都不需要就纯本地跑。
- 只在本机跑 agent:装个 tmux,把 window、pane、浮层这套工作区组织用起来,就已经赚了;
- 想在手机上看一眼进度:tmux + ttyd + Tailscale,一个下午能配好;
- 要分享给同事或者想要个公网网址:tmux + ttyd + Cloudflare Tunnel + Access。
回头看,这套东西真正的价值不在任何一个具体的工具,而在于开头列的那几条最后都落到了实处:agent 换谁都行,Terminal app 想换就换,设备只要有个浏览器就能用,出了岔子,不管是关错窗口还是重启电脑,都回得来。
AI agent 一年换三家是常态,Terminal app 更是一年一茬。但只要它们还跑在终端里,我这套就一行都不用动。
- Title: 我的 AI Agent Coding 基础工具链
- Author: Xueshi
- Created at : 2026-07-27 22:30:00
- Updated at : 2026-07-28 04:26:19
- Link: http://xueshi.me/2026/07/27/ai-agent-coding-toolchain-tmux-ttyd/
- License: This work is licensed under CC BY-NC-SA 4.0.