跳到正文
Hacker News· ankitg12·· 15 天前精选AI 评分52

Tmux 与 Claude Code 协作指南:会话、send-keys 及其局限

Tmux and Claude Code: Sessions, Send-Keys, and the Ceiling

AI 导读

tmux 与 Claude Code 配合可为 AI 编程智能体提供可分离、可脚本化的会话环境。指南覆盖 --worktree --tmux 内置标志、多会话布局、send-keys 与 capture-pane 命令、.tmux.conf 关键配置及状态栏监控,并指出 tmux 在多智能体场景下无法识别智能体状态、屏幕抓取脆弱、跨会话无共享状态、滚动日志成本高等局限。

推荐理由

原文给出了 tmux 与 Claude Code 搭配的完整工作流配置,读者可直接复用 send-keys、capture-pane 等命令片段。

正文 · AI 翻译

tmux 在命名的、可分离的会话中运行你的 AI 编码代理,这些会话在终端关闭或 SSH 连接断开后仍然存活。你得到一个承载多个代理的窗口,send-keys 可以从脚本向其中任何一个输入,capture-pane 则可以读取其中之一的输出。这种组合就是人们使用它的原因。

在手工构建任何东西之前,先看看内置路径:Claude Code 的近期版本可以通过 claude --worktree --tmux 为你创建 worktree 和 tmux 会话。本指南先介绍这个功能,然后介绍它未覆盖的部分,最后是 tmux 本身也无法触及的环节。

为什么 tmux 与代理相得益彰

编码代理是一个希望持续与终端对话的长时间运行进程。tmux 的三个特性恰好契合这种形态:

它能跨越断连幸存。拥有 pty 的是 tmux 服务器,而不是你的终端窗口。关闭窗口、丢失 SSH 连接、合上笔记本:代理持续运行,重新接入时它依然在那里。

它在同一窗口中容纳多个会话。四个代理变成四个命名会话,你用按键在它们之间切换,而无需在应用切换器中翻找四个窗口。

它可被外部脚本化。任何位置的任何 shell,都可以向正在运行的代理输入或读取其屏幕内容。无需构建 IPC,无需接入 API。这正是人们在其上构建的特性,也是 tmux 不断出现在代理编排脚本中的原因。

在你亲手构建之前,先看内置标志

Claude Code 已为「单分支单代理」这一用例集成了此功能。两个标志,在近期版本中通过 claude --help 提供:

-w, --worktree [name]   Create a new git worktree for this session
                        (optionally specify a name)
    --tmux              Create a tmux session for the worktree
                        (requires --worktree). Uses iTerm2 native panes
                        when available; use --tmux=classic for
                        traditional tmux.

于是整个「隔离此任务、为其分配独立会话」的操作就成了一条命令:

claude --worktree --tmux

# or name the worktree
claude --worktree auth-refactor --tmux

在依赖它之前,有两个细节值得注意。--tmux 在没有 --worktree 时会报错,因此它是一个恰好会创建会话的 worktree 功能,而非通用的会话标志。而在 iTerm2 上它使用原生窗格而非 tmux,后者看起来更舒适,但也意味着本页剩余部分的 tmux 命令找不到可以附着的目标。当你想在底层使用真正的 tmux 时,请传入 --tmux=classic;只要打算针对该会话编写脚本,就应当这样做。

对于单分支上的单个代理,这就是答案。使用它,并跳过后续内容。

它做不到的是:在同一会话中运行多个代理;布置窗格以便观察它们;允许你从脚本向正在运行的代理输入;告诉你哪个代理正在等待你;以及通过 SSH 在远程机器上完成这一切的设置。这些构成本指南余下的内容,而每一条都是人们最终不得不亲手书写 tmux 的地方。

关于该标志中 worktree 这一半的更多内容,请参阅 用于并行开发的 git worktree。

每个项目一个会话,按项目命名

让这一切保持可用的组织原则是:每个仓库一个 tmux 会话,并以仓库命名。不是按任务,也不是按代理。任务来来去去;项目才是稳定不变的对象。

# start a detached session in a project directory
tmux new-session -d -s api -c ~/code/api

# attach to it (creates it if it doesn't exist)
tmux new-session -A -s api -c ~/code/api

-A 表示「附着或创建」。它让单条命令可以安全地反复运行,这正是 shell 函数所需要的:

# ~/.zshrc
agent() {
  local dir="${1:-$PWD}"
  local name="$(basename "$dir" | tr . -)"
  tmux new-session -A -s "$name" -c "$dir"
}

会话名称不能包含 . 或 :(tmux 将它们用作目标分隔符),因此要使用 tr。

于是 tmux ls 变成一份真实的清单:

$ tmux ls
api: 3 windows (created Fri Jul 31 09:12:04 2026)
web: 2 windows (created Fri Jul 31 09:40:11 2026)
infra: 1 windows (created Fri Jul 31 11:02:55 2026)

用格式字符串补充一些信息:

tmux list-sessions -F '#{session_name}  windows=#{session_windows}  attached=#{session_attached}'

一种用于观察多个代理的布局

在会话内部,为每个代理分配独立的窗口,并以任务命名:

tmux new-window -t api -n auth   -c ~/code/api
tmux new-window -t api -n tests  -c ~/code/api

对于你不主动观察的代理,窗口比窗格更合适,因为窗口不占用屏幕空间。窗格留给当下希望并排查看的两三个:

# split the current window and tile whatever is in it
tmux split-window -h -c '#{pane_current_path}'
tmux split-window -v -c '#{pane_current_path}'
tmux select-layout tiled

#{pane_current_path} 很容易遗漏。没有它,新窗格会在会话启动时的目录中打开,而不是你当前所在的目录,在那里启动的代理将被指向错误的仓库。

两种按名称区分的布局:tiled 用于智能体的均匀网格,main-vertical 用于一个大窗格(你正在操控的智能体),其余的堆叠在旁边。

tmux select-layout main-vertical
tmux set-window-option main-pane-width 60%

main-pane-width 的百分比值需要 tmux 3.2 或更新版本。在较旧的版本上使用绝对的列数(main-pane-width 120)。可以用 tmux -V 检查。

值得拥有的 .tmux.conf

这些是需要专门针对智能体工作的配置行。它们不是完整的配置,而是一组智能体让你想要对默认值做出的差异。

# agents produce a lot of output, and you will want to scroll back through it
set -g history-limit 100000

# 24-bit colour, so agent TUIs render the way they were designed to
set -g default-terminal "tmux-256color"
set -ga terminal-overrides ",*256col*:Tc"

# scroll and click between panes with the mouse
set -g mouse on

# don't let a running process rename the window out from under you
set -g allow-rename off
set-window-option -g automatic-rename off

# 1-indexed windows match the number keys on your keyboard
set -g base-index 1
set-window-option -g pane-base-index 1
set -g renumber-windows on

# flag windows whose agent produced output while you were elsewhere
set-window-option -g monitor-activity on
set -g visual-activity off

# status line refreshes often enough to be a monitor
set -g status-interval 5

# no delay when an agent's TUI reads an escape sequence
set -sg escape-time 10

# jump panes without the prefix key
bind -n M-h select-pane -L
bind -n M-j select-pane -D
bind -n M-k select-pane -U
bind -n M-l select-pane -R

# reload without restarting the server
bind r source-file ~/.tmux.conf \; display-message "config reloaded"

智能体 TUI 喜欢设置终端标题,所以如果没有 allow-rename off,你精心命名的 auth 窗口就会变成智能体自己想叫的名字。

escape-time 10 修复了「在智能体里方向键反应迟钝」的问题。默认的延迟是为了区分真正的 Escape 键和转义序列,它让交互式 TUI 感觉反应迟钝。

使用 send-keys 操控智能体

send-keys 像你在键盘上操作一样向窗格输入内容。这就是如何从 cron、git 钩子或另一个智能体来脚本化操控智能体的方式。

# target syntax: session:window.pane
tmux send-keys -t api:auth "run the test suite and fix what fails" Enter

这里有三个常见问题,按发生频率排序:

裸参数会被解释为按键名。Enter、C-c、Escape 和 Space 是按键而不是文本。如果你的提示中作为独立参数包含这些词之一,它会被作为按键发送。使用 -l 来发送字面字符串:

tmux send-keys -t api:auth -l "press Enter to continue was the old flow"
tmux send-keys -t api:auth Enter

-l 完全禁用按键名查找,所以换行符需要再调用一次。

分号会结束 tmux 命令。在你的文本中末尾的 ; 会被视为命令分隔符。用 \; 对其进行转义。

智能体 TUI 需要在文本和换行符之间留一点间隔。许多智能体输入框在粘贴时会进行防抖或重新渲染,与文本在同一帧到达的回车键会被吞掉。可靠的做法是:

tmux send-keys -t api:auth -l "$PROMPT"
sleep 0.3
tmux send-keys -t api:auth Enter

中断失控的智能体使用的是相同的机制:

tmux send-keys -t api:auth C-c

使用 capture-pane 回读智能体

capture-pane 将窗格的内容转储到标准输出。它是这一对操作中的读取部分。

# the visible screen
tmux capture-pane -p -t api:auth

# the last 200 lines, with wrapped lines joined back together
tmux capture-pane -p -J -S -200 -t api:auth

# the entire scrollback
tmux capture-pane -p -J -S - -t api:auth

# keep the colour escape sequences
tmux capture-pane -p -e -t api:auth

-J 比看起来更重要。没有它,在你的终端宽度处换行的一行会作为两行返回,你对输出做的每次 grep 在拼接处都会出错。

如果想要持续日志而不是快照,pipe-pane 将窗格产生的所有内容流式传输到一个命令:

tmux pipe-pane -o -t api:auth 'cat >> ~/agent-logs/api-auth.log'

# stop it
tmux pipe-pane -t api:auth

-o 是切换式的,所以运行两次会将其关闭。输出是原始的,包括所有转义序列,所以如果之后想让它可读,就用类似 sed -E 's/\x1b\[[0-9;]*[a-zA-Z]//g' 的工具进行管道处理。

状态行监视器

每个在 tmux 中运行多个智能体的人最终都会构建这个:一个状态行,告诉你哪些智能体在工作,哪些在等待。

机制是状态行中的 #(),它运行一个 shell 命令并插入其输出,每 status-interval 秒刷新一次。

set -g status-interval 5
set -g status-right '#(~/bin/tmux-agent-status) | %H:%M'
set -g status-right-length 100

以及脚本,它对每个窗格进行 capture 抓取以查找生命迹象:

#!/usr/bin/env bash
# ~/bin/tmux-agent-status
out=""
while read -r target; do
  screen="$(tmux capture-pane -p -J -S -8 -t "$target" 2>/dev/null)"
  name="${target%%:*}"
  case "$screen" in
    *"Do you want"*|*"❯ 1."*) out+="⏸ $name " ;;   # parked on a prompt
    *"esc to interrupt"*)      out+="● $name " ;;   # working
    *)                         out+="○ $name " ;;   # idle
  esac
done < <(tmux list-panes -a -F '#{session_name}:#{window_index}.#{pane_index}')
printf '%s' "$out"

它有效,并且值得构建。要清楚它正在做什么:对 TUI 渲染帧进行字符串匹配,以五秒为轮询周期,针对你打开的每个窗格。匹配字符串取决于你这周恰好使用的智能体版本打印的内容。

更轻量的版本使用 tmux 自身的活动跟踪而不是抓取。monitor-silence 在窗格 N 秒内没有输出时触发警报,这是最接近「这个智能体停止了」的原生信号:

set-window-option -g monitor-silence 30
set -g visual-silence on

你可以在此基础上挂载一个动作:

set-hook -g alert-silence 'run-shell "osascript -e \"display notification \\\"agent idle\\\"\""'

沉默是完成的代理信号,而非完成的报告。智能体在大文件上深度思考时也是沉默的。

分离、重新连接,以及哪些能保留哪些不能保留

tmux detach-client            # or the prefix, then d
tmux attach -t api
tmux switch-client -t web     # from inside another session
tmux kill-session -t api

能保留的内容:

事件 智能体继续运行?
关闭终端窗口 是
分离客户端 是
SSH 连接断开 是
tmux kill-session 否
tmux kill-server 否
重启 否
macOS 重启后「重新打开窗口」 否

tmux 服务器只是一个由你的用户拥有的进程。tmux 本身没有任何机制让它在重启后存活,会话的回滚历史也会在服务器停止时丢失。如果对话记录很重要,pipe-pane 把它存到一个文件,或者使用一个自己会持久化历史记录的工具。

一个常被人忽视的细节:分离会话的窗格大小取决于最近一次连接的客户端,如果从未连接过客户端,则默认为 80x24。当一个代理的 TUI 在你离开时渲染到 80 列宽的窗格里,等你用 200 列宽重新连接时显示会错位,直到它重新绘制。set-window-option -g aggressive-resize on 在多个客户端以不同尺寸连接时很有用。

tmux over SSH

这正是 tmux 真正难以被替代的场景。在一台远程机器上运行代理,而那条链路本身就不可靠:

# attach or create, in one command, with a tty allocated
ssh devbox -t tmux new-session -A -s agents

你的连接在任务进行到一半时断开,你再次运行同一条命令,就回到了同一个会话,代理还在继续跑。如果你的网络差到连 SSH 都频繁断线,那就再搭配 mosh 使用。

嵌套 tmux 的问题:如果你本地跑 tmux,远程机器上也跑 tmux,两者就会争抢同一个前缀键。两种解决办法是在远程使用不同的前缀键(set -g prefix C-a),或者连续按两次前缀键,bind C-b send-prefix 给你提供了这种支持。

要在不是你面前的这台机器上运行代理,请参阅 remote development。

tmux 的局限

tmux 是一款优秀的软件。它对多路复用和会话持久化这两件事的处理,几乎做到了能做到的极限,并且从 2007 年坚持到了今天。它不做的,是任何与代理相关的事——因为在它的模型被设计出来时,代理还不存在。

看不出哪个代理处于停等状态。 一个在等待审批提示的代理,看起来和一个正在思考的代理毫无二致,也和一个二十分钟前就已结束的代理毫无二致。三者都只是"一个带点文字的窗格"。前面提到的状态栏小技巧,就是为了糊上这个问题,而它的糊法是抓取屏幕。

某个代理跑完时没有通知。 monitor-silence 是最接近的方案,但任何静默的窗格无论出于什么原因它都会触发。并不存在"代理已完成"这类事件,因为 tmux 完全不知道代理是什么。

屏幕抓取在结构上就是脆弱的。 capture-pane 给你的是渲染出来的画面:转圈动画、方框字符、被截断的列、ANSI 转义码。它不是代理说了什么,不是调用了哪个工具,也不是那次调用是否成功。代理发布一次 UI 改动,你写的任何解析器都会失效。

会话之间没有共享状态。 两个分别跑在两个 tmux 会话里的代理,彼此看不到对方。如果你想让一个读取另一个的会话记录,或者交接工作,你得自己动手,靠文件、锁和轮询拼出来。

多个流式输出代理的回滚历史开销巨大。 十几个代理输出窗格的回滚历史 history-limit 100000 是实打实的内存,按窗格各自持有,只要服务器活着就一直占着,而服务器一停就全没了。至于渲染这一侧为什么会变得昂贵,请参阅 terminal emulators for AI agents。

任何东西都经不住一次重启。 所有会话,所有记录,统统消失。

这些都不是缺陷。这是一个为坐在键盘前的人提供 shell 多路复用能力的工具所必然抵达的边界。

crystl 接手之处

crystl 是一款 macOS 终端,专为 tmux 并未成形去应对的工作负载而打造:同时跑大量代理,绝大多数在屏幕之外,每个偶尔需要你介入一下。

这些差异几乎能和上面的清单一一对应。

  • 审批浮出表面,而非被埋没。 当任何代理暂停以请求权限时,crystl 会将其作为卡片浮出,按项目配色,你无需切换会话即可回复。无需抓取,无需轮询。参见 浮动卡片。
  • 代理状态是一等信号。 代理活动面板 同时显示每个会话的状态:运行中、等待你处理、空闲、已完成。这正是状态行脚本试图近似表达的东西。
  • 输出是结构化的,而非抓取来的。 crystl 将每一轮、工具调用和结果捕获为类型化记录,因此 历史 可在所有会话中搜索,代理可以相互阅读转录内容而无需解析 ANSI。
  • 会话按项目组织,并且持续存在。 项目是宝石(gems),其中的会话是碎片(shards),关闭窗口或重启 Mac 后历史记录依然保留。更多内容见 多任务。
  • 同一仓库上的并行工作是隔离的。 隔离的碎片由真正的 git worktree 支持,所以同一仓库上的两个代理不会发生冲突。参见 并行会话。

建议:保留 tmux。对于远程服务器、服务器上的长时间运行进程,以及 SSH 连接是脆弱环节的任何场景,它仍然是正确的答案。请将其用于这些用途。当你要管理的是六个随时需要你做出决策的代理时,问题就不再是多路复用的问题了。

更多关于这方面的组织方式,见 管理多个 AI 编码会话;关于让代理持续工作而非闲置,见 让 AI 编码代理运行更长时间。

来源:Hacker News · crystl.dev