tmux 自动化技能
本技能教 Claude 如何用 tmux 在自动化场景里驱动交互式程序。重点不是手按键绑定,而是用 tmux 子命令以脚本方式创建会话、发命令、读输出。
心智模型
程序 ⊂ 面板 (pane, %X) ⊂ 窗口 (window, @X) ⊂ 会话 (session, $X) ⊂ 客户端
- 一个面板里跑一个程序,提供完整 PTY。
- 窗口是一组面板的容器,占满终端。
- 会话是一组窗口,可被多个客户端 attach 共享。
- 服务器进程在后台维护所有状态;只要服务器没被 kill,会话就一直在。
每种对象都有稳定的唯一 ID:会话 $N、窗口 @N、面板 %N。永远用 ID 定位——索引 (0、1.2) 在窗口被创建/关闭时会变,ID 不会。
何时该用本技能
满足下列任一条即可:
- 要向已经启动的程序发命令(python REPL、psql、gdb、ssh 远端 shell、claude/codex CLI 等)。普通 Bash 启动后无法再向 stdin 投递输入,tmux 可以。
- 程序拒绝在非 TTY 下工作,或在管道里行为变差(丢色、不刷新、缓冲变怪、curses 错乱)。tmux 提供真 PTY。
- 需要多个并发长生命周期服务,且要分别向它们发命令、分别读输出(前端 dev、后端、worker、日志监控)。
- 想让用户随时 attach 看 Claude 当前操作的现场,或在 Claude 离开后接管。
- 需要在跨多次 Claude 工具调用之间保留同一个 shell/程序的状态(环境变量、cd 后的目录、virtualenv、登录的 ssh 连接)。
不满足以上任何一条时,普通的 Bash 调用就够了,没必要套 tmux。
黄金法则
永远先抓 ID,再用 ID 操作。 创建任何对象时都加 -d -P -F '#{...}',把 stdout 拿到的 ID 存进 shell 变量,后续命令一律用 -t $ID。
# 幂等防御:同名会话已存在则先清掉,避免 new-session 失败
tmux has-session -t '=claude-work' 2>/dev/null && tmux kill-session -t '=claude-work'
# -d 不 attach;-P 打印目标;-F 指定打印格式
S=$(tmux new-session -d -P -F '#{session_id}' -s claude-work)
W=$(tmux new-window -d -P -F '#{window_id}' -t "$S")
P=$(tmux split-window -d -P -F '#{pane_id}' -t "$W")
这之后 $S、$W、$P 是稳定句柄,重命名/重排都不影响。-t '=name' 的 = 前缀强制精确匹配,避免误命中名字相近的别的会话。
五个核心模板
模板 1:创建一个后台会话,跑一个程序
# 创建会话,在第一个面板里启动 python,并拿到面板 ID
PANE=$(tmux new-session -d -s repl -P -F '#{pane_id}' 'python3 -i')
-i 是给 python 的:让它启动后进入 REPL 而不是执行完命令就退出(tmux 已经提供了真 PTY,所以 stdin 一定是 tty;-i 解决的是 python 的行为而非 tty 检测)。
本节模板为简化起见省略了
has-session幂等检查。脚本要可重复运行时,按"黄金法则"那段代码先做has-session && kill-session清理,或改用tmux new-session -A -d ...(已存在则复用、不存在才建)。
模板 2:向运行中的程序发命令
tmux send-keys -t "$PANE" 'import os; print(os.getcwd())' Enter
- 解析按位置参数为单位:每个独立参数先整体匹配键名(
Enter、Tab、C-c、F1等),匹配上就发那个键的转义序列;匹配不上就把整个参数作为字面字符串逐字符发送。注意:不会把"参数里的某些子串"再去匹配键名——'cmd Enter'是六个字符(c, m, d, 空格, E...)一起发,不会触发回车。 - 因此命令文本和回车要分成两个参数:
'cmd' Enter,不要写成'cmd\n'或'cmd Enter'。 - 要发的字面量恰好等于某个键名(比如真要键入 "Enter" 这个词),加
-l关闭键名识别:tmux send-keys -lt "$PANE" 'Enter'。 - 取消正在跑的程序:
tmux send-keys -t "$PANE" C-c。
模板 3:读取面板输出
# 读当前可见内容(包含已滚出屏幕之前的最后一屏)
tmux capture-pane -p -t "$PANE"
# 读完整历史(含 scrollback)
tmux capture-pane -p -t "$PANE" -S -
# 保留尾随空格 + 重新拼接折行(适合解析结构化输出)
tmux capture-pane -p -t "$PANE" -S - -J
-p 表示 print 到 stdout(不加 -p 则存到 paste buffer)。-S 是起始行,- 表示历史最早一行;-E 是结束行,默认到可见区末尾。
模板 4:发命令后等程序就绪
程序处理输入需要时间,盲目立刻 capture 容易拿到旧画面。轮询 capture 直到看到稳定的提示符:
tmux send-keys -t "$PANE" 'long_running_thing()' Enter
ok=0
for _ in $(seq 1 30); do
# 范围设大一点:覆盖命令本身的输出加上提示符所在行
out=$(tmux capture-pane -p -t "$PANE" -S -200 -J)
# 用两个分支匹配提示符:
# 1) "<换行>>>>" — 提示符前有更早的输出(绝大多数情况)
# 2) ">>>" — 极少数边界:捕获区起点恰好就是提示符所在行(无前导换行)
# 不依赖提示符末尾的尾随空格——不同环境(python "> "、shell "$ ")习惯不同,
# 而且 capture 在不带 -J/-N 时会吃掉它们。
case $out in
*"
>>>"*|">>>"*) ok=1; break ;;
esac
sleep 1
done
if [ "$ok" -ne 1 ]; then
echo "timeout waiting for prompt; last capture:" >&2
echo "$out" >&2
exit 1
fi
echo "$out"
要点:
- 在 send-keys 后不要不 sleep 直接 capture,至少留一个轮询循环;用面板里独有的提示符当结束信号,比"等固定秒数"可靠得多。
case模式里嵌入的是真实换行符(双引号内回车换行),不是\n转义——后者在 case 里会被当成两个字符\和n。"换行+提示符"模式锁定的是"提示符出现在某行行首",避免误匹配字符串里偶然出现的>>>;附加">>>"*兜底新会话刚启动、捕获区第一行就是提示符的边界情况。- 永远显式判定超时,不要让脚本在没看到提示符时静默继续。
-J同时拼接折行并保留尾随空格——长命令被终端宽度切断时仍能正确解析。
模板 5:并发跑多个服务
S=$(tmux new-session -d -s services -P -F '#{session_id}')
# 第一个面板/窗口已经存在但 new-session 没 -P 出来——单独取 ID
W0=$(tmux display -p -t "$S" -F '#{window_id}')
BACKEND=$(tmux display -p -t "$W0" -F '#{pane_id}')
tmux rename-window -t "$W0" services
tmux send-keys -t "$BACKEND" 'cd backend && npm run dev' Enter
# 再起一个窗口跑前端
FRONTEND=$(tmux new-window -d -P -F '#{pane_id}' -t "$S" -n frontend)
tmux send-keys -t "$FRONTEND" 'cd frontend && npm run dev' Enter
# 同窗口里再分一个面板跑日志
LOGS=$(tmux split-window -d -P -F '#{pane_id}' -t "$FRONTEND")
tmux send-keys -t "$LOGS" 'tail -F /tmp/app.log' Enter
之后任何时候都能用 $BACKEND / $FRONTEND / $LOGS 各自发命令、各自 capture。所有定位均用 ID,与黄金法则一致。
让用户接管 / Claude 离开
- 用户想看:让 Ta 跑
tmux attach -t =services(=前缀强制精确匹配,避免被名字相近的别的会话抢去)。-d选项可顺便把其他客户端踢掉以避免大小冲突。 - Claude 不能在自己的工具调用里 attach(会卡住前台),但可以在创建会话时不 attach,让会话留在后台供用户 attach。
- 完事清理:
tmux kill-session -t =services。一个孤立的会话不会自己消失。
关键陷阱(自动化场景最常翻车的几个)
- send-keys 不带 Enter 等于没按回车。 几乎所有"命令发了但没反应"都是这个。检查最后一个参数是不是
Enter。 - 键名 vs 字面量的歧义。 要发的字符串里如果有
Enter、Tab、Space这种英文单词,加-l强制字面量,否则会被当成按键。引号里的特殊 shell 字符($、反引号)也要按 shell 规则转义。 - capture 时机。 紧跟 send-keys 之后立即 capture 拿到的是发命令前的画面。永远轮询,用提示符或自定义 sentinel 行作为完成标志。
- 窗口/面板索引会变。 不要靠
-t 0.1这种位置定位长期跑的脚本,用%pane_id。 - 嵌套 tmux(在已经 attach 的 tmux 里再 attach 另一个)会让前缀键冲突。Claude 的脚本里发命令用绝对
tmux ...不需要前缀键,所以这条只在让用户手动接管时关心——告诉用户把外层前缀键连按两次(默认是Ctrl-b Ctrl-b,若用户改过就用他自己的 prefix 重复一次),就能把前缀传给内层。更彻底的办法是 Claude 本来就用独立 socket 启动会话(tmux -L claude ...),与用户的默认 socket 完全隔离,嵌套和前缀冲突都不存在;详见references/targets-and-ids.md的"嵌套"一节。 - TERM 必须是 tmux 兼容值。 自动化里很少需要管,但若程序投诉
unknown terminal,在创建会话前export TERM=tmux-256color或screen-256color。 - kill 不会问你。
tmux kill-server会干掉所有会话,慎用;用kill-session -t $S只清自己的。 - 进程退出后面板会消失,错误现场也丢了。 默认
remain-on-exit是off,进程一旦 exit,pane 立刻关闭,再去capture-pane -t %P会拿到错误"can't find pane"。要保留崩溃现场用于事后取错信息,创建会话时打开此选项:tmux new-session -d -s claude-work \; \ set-option -t claude-work remain-on-exit on # 之后 pane 内进程退出,面板保留并显示 "Pane is dead",capture 仍可用
何时翻参考资料
references/ 下按场景拆分。不要预先全读,需要时再开对应文件:
| 你正在做的事 | 打开 |
|---|---|
| 选 send-keys / capture-pane / pipe-pane 的某个细节 flag | references/automation.md |
拼 -t 目标(跨会话、相对位置、{last}/{up-of} 等) |
references/targets-and-ids.md |
| 找一个完整可用的脚本模板(dev server 等待就绪、并发服务、REPL 自动化) | references/recipes.md |
| 排查 send-keys 没生效 / capture 总拿空 / TUI 显示乱码 | references/pitfalls.md |
参考来源
本技能的命令语法和示例基于本仓库的 tmux 官方 wiki 内容(特别是 Advanced-Use.md 的 "Sending keys"、"Capturing pane content"、"Targets" 与 "Targets for new panes, windows and sessions" 章节)以及 Recipes.md。如果对某条 flag 行为有疑问,回去查 man tmux 是最权威的途径。