keepane,AI 时代的终端多路复用
终端复用器是给坐在键盘前的人写的,分屏、切窗口、断线重连,前提都是有个人在看着。现在 pane 里跑的常常是 agent,人不在跟前,要的东西就变了。
keepane 是按后面这个前提写的。每个 pane 有名字、有收件箱、有工作模式,pane 之间能互发消息,也能让对方直接执行一条命令;同一台机器上如此,配过对的两台电脑之间也一样。人、脚本和 agent 走同一套,agent 那侧还能走内置的 MCP 服务端。
终端断了 pane 里的程序照跑,手机扫个二维码就能看每个 pane 在干什么、往里打字。tmux 的键、命令和配置在这里照用。
起因
一开始是想找个现成的用,找了一圈没有合预期的。能用的那些都还是按「有人看着」写的,pane 里跑的是什么它不关心,更不会让两个 pane 说上话。于是自己写,第一个提交的标题是「wmux: tmux-style terminal multiplexer for Windows」,那会儿还叫 wmux。
真用起来才发现分屏解决的只是摆放。几个窗口里各跑一个 agent,想让它们配合起来,眼下就两条路。一条是用各家 agent 自己的协议,一家一套,谁也不认谁,换个工具就得重接一遍。另一条是用 agent 内部那套派子任务,它跑在一轮之内,不占 pane、看不见、也接管不了,断了就断了。
那就往下挪一层。不管跑的是哪家 agent,还是一个普通 shell、一个脚本,它都坐在一个 pane 里,pane 是大家都有的那层东西。通信放在这一层,给每个 pane 一个名字和一个收件箱,里面跑的是谁就不重要了。
pane 于是从一块屏幕变成了一个 actor。改名也是这时候的事,wmux 这名字 GitHub、winget、crates.io 上都有人在用了,0.14.0 起改叫 keepane,keep 加 pane,终端没了 pane 里的程序还在。
消息
给 pane 起个名字,它就能收消息:
keepane rename-pane -t %3 builder
keepane set-work-mode -t %builder shell # 指向别的 pane,得在 keepane 外面或者 C-b : 里运行
keepane send-message -t %builder -w 30 "cargo test"
keepane trace-message 12 -w 600-w 是等送达,trace-message 是等做完,回来的是输出和成败。
发送方能填的只有 --to、--re、--task 三样,其余由服务端盖章:
[keepane id=12 task=12 from=$1:@1.%3 name=lead mode=ai to=$1:@2.%7 via=shell hop=0]from、name、mode、id、hop、via 都不许发送方给,给了报错。来源能伪造的话 hop 上限就拦不住循环,两个 agent 能一直对着回信,现在超过 8 跳拒收。
字段顺序固定、中间不留空格,同一条消息每次生成的字节完全一样,写测试和给程序解析都省事;值里不会出现空格和 ],按空格切开就读回来了。这行原本是 JSON,用了一阵实在看不下去,0.17 改成字段式,JSON 只留在事件日志里。
工作模式
投递时机由收件方决定,一共三种:
normal:不自动投递,程序自己用read-message取,窗口标记上显示@shell:keepane 的提示符钩子看到 shell 回到提示符,就把命令打进去回车ai:agent 的轮次结束 hook 调过pane-ready,就把正文当提示词打进去
投给 shell 的那条,信封写成不执行任何东西的形式放在命令前面,PowerShell 里是行内注释 <# … #> cargo test,bash 和 zsh 里是 : 的参数。这样来源同时留在屏幕、shell 自己的历史和 keepane 的历史日志三处。
消息按发送当时对方的模式投递,不按现在的。写给 agent 的一段文字,不会因为这个 pane 中途被切成 shell 就被当命令执行。
空闲判断
什么时候算「这个 pane 腾出手了」,只认两个信号:shell 模式认 keepane 自己装的提示符钩子(OSC 7777;keepane-prompt),ai 模式认 agent 每轮结束时调的 pane-ready。ssh 到远端的 shell 也会发 OSC 133,那个不算,免得把别人机器上的提示符当成本地空闲。
按键、粘贴、send-keys 一律转成忙,消息不会插进正在打的字中间。提前打字也照顾到了:keepane 投的命令还在跑、人先敲了半行,这条命令结束时的提示符不算空闲,状态机里另记一位「行上留着东西」,一直等到有人把那行运行掉或者清掉。
还有一层是 ConPTY 的时序。提示符标记是个 OSC,ConPTY 立刻转发,它前面那些文字要等下一帧才画出来,于是标记到达时命令最后几行输出还不在屏幕上。所以标记到了先判在提示符,当前消息再等 60 ms 才算结束、才截输出,这期间不投递新的。截输出也不靠推算行数,而是从送达那行往下找到命令末行,输出从下一行起。
跨机器
pane 里的 agent 不一定都在一台机器上。Mac 上挂着 agent,编译和测试在 Windows 那台跑,这之前想让两边搭上话,得自己 ssh 过去看一眼再回来转述。0.21 起收件箱能跨机器了,同一个局域网,或者 Tailscale 这种私有网络。
两台都开着 keepane web,拿另一台打印出来的那个地址(手机扫的就是它,带着密钥),在这台上配一次对:
keepane link add http://100.64.0.3:7681/#k=... # 配对,一次双向生效
keepane link list # 配过对的机器
keepane link panes 100.64.0.3:7681 # 对方的 pane
keepane send-message --to 100.64.0.3:7681/%worker "cargo test"跟 ssh-copy-id 是一回事。web 密钥只在这一下用来证明身份,之后每个请求走两边各自的 Ed25519 密钥签名,对方的 web 后来换了密钥也不影响。表里记的是公钥,地址只是顺带记着,所以同一台机器从局域网挪到 Tailscale 再来,验签过了照收,那一行的地址自己更新。
地址写成 主机:端口/ 再跟上它在那台机器上的写法,%worker 或者 $1:@3.%7 都行。信封到那边是 from=100.64.0.3:7681/$1:@1.%3,主机那一段由收件方按验过的公钥填,发件方写不了。对方 send-message -r 回过来,落在这边发起的那个 pane 上,归进同一个任务。-w 由对方那台等到投递,超时退出码非 0,和发给本机 pane 一样。
对方的机器情况、自己那条消息的下落,也都能问:
keepane link info 100.64.0.3:7681 # 主机名、系统、版本、开机多久、CPU、内存、pane 数
keepane trace-message 12 -w 600 # 发过去那条在那边成没成、输出了什么
keepane link capture -S 100 100.64.0.3:7681/%worker # 对方那个 pane 现在显示什么,要它授权 --screen配完对,对方的消息只进 ai 和 normal 的 pane。想让它在 shell 里当命令执行,这边得单独给它开一次:
keepane link allow 100.64.0.3:7681 --shell这条和 set-work-mode 同一个规矩,在 pane 里面运行会被拒,得在 keepane 外面的终端或者 C-b : 里。不然 pane 里的 agent 自己就把远程执行的权限开了。--screen 也一样。
剩下几条是照着「有人存心捣乱」定的:
签名签进去的内容里含收件方的公钥,一条请求截下来拿到别的机器上用不了,答复也签
时钟差过两分钟不收,同一个随机数 240 秒内再来一次也不收
对方送来的from、name、mode落地前要按本机的字段规则过一遍。少了这一关,一台改过的机器可以在信封里塞出一行,看着像是本机 pane 发来的
同一个地址一分钟只记一次被拒。局域网上有人灌垃圾请求的时候,状态栏不至于刷屏,当天的事件日志配额也写不满
连不上就当场报错,不排队也不等以后补发,隔了半小时才执行一条过时的命令比失败麻烦keepane web --read-only的机器什么远程消息都不收
keepane web 现在会同时监听局域网地址和本机的 Tailscale 地址。每条决定为什么这么定,写在 link.md 里。
MCP
只想让 pane 之间通通气,不需要 MCP。agent 那侧装一个 hook 就够:每轮结束时跑一下 keepane pane-ready -q,这个 pane 在 ai 模式下就算腾出了手,排队的消息这时候投进去;它要回话,命令行的 send-message 拿来就用,-r 回给手上这条消息的发送方,--re 12 回给第 12 条的。这条路上 agent 只管自己这一个 pane。
MCP 是给另一件事准备的,让 agent 自己去编组。keepane mcp 是个 stdio 服务端,从 KEEPANE_PANE 认出自己在哪个 pane,之后它可以开新的 session、新的窗口、新的 pane,开的时候就把名字、工作模式和第一条消息一并给上,pane 就绪后自动投递,省掉「发早了」的时序问题。开完了还能看谁忙谁闲、翻任务链、给自己上报进度,整套 keepane 的 session 由它自己管着。
keepane setup claude --install这条往 ~/.claude/settings.json 里写两个 hook(写前备份),再 claude mcp add --scope user keepane -- keepane mcp,只要通信的话把后半截去掉就行。自己手写 hook 记着程序路径别加引号:Claude Code 在 Windows 上可能用 PowerShell 跑 hook,带引号的路径后面跟参数是语法错误。hook 在 keepane 外面是静默的,不在 ai 模式的 pane 里也不管事。换别的 agent 一样,只要它能在每轮结束时跑条命令、能用 stdio 的 MCP。
二十个工具,分五类:
create_session、create_window、split_pane、rename_pane、kill_pane:自己开一队 pane 出来,用完收掉whoami、list_panes:自己是谁,别人都在干什么、忙不忙、收件箱里积了几条send_message、reply、wait_message、current_message:派活、回信、在自己一轮里等下一条、查服务端记的当前消息list_messages、trace_message、drop_message、move_message:翻收件箱,查一条的下落,插队或者删掉set_status、set_work_mode、list_tasks、show_task、query_events:上报进度,翻任务链和事件日志
跨机器那几样也做成了工具:list_links 是配过对的机器,link_info 问其中一台的情况,read_screen 读它某个 pane 的屏幕;list_panes 带上 host 看的就是那台的 pane。
新 pane 跑 claude、codex、gemini 默认进 ai 模式,跑 shell 默认进 shell 模式。能起的程序限在 agent-commands 那几个名字里,一个 agent 连同它开出来的 pane 最多再开 8 个。还有一条:set-work-mode 在 pane 里运行时只改得动这个 pane 自己,指向别人会报错,所以一个 agent 没法把别人的 pane 变成「收到什么就执行什么」;人在 keepane 外面的终端、快捷键或者 C-b : 里运行,才能改任意 pane。
手机
keepane web 在终端里打一个二维码,手机扫了是一个列出所有 pane 的页面,按 session 和窗口分组,标着每个跑的什么程序、有没有提醒标记。点进去看屏幕、带颜色,屏幕一变就推过来,不用刷新。
底下有输入框和一排手机键盘没有的键(Esc、Tab、方向、Ctrl+C,还有 y / n / 1 / 2 / 3,agent 问你选哪个的时候用)。+ 菜单分屏、开窗口、关 pane,⏱ 在左边加一列每条命令的开始时间,点一下看它跑了多久、退出码多少。跑的全在电脑上,手机只负责显示和输入,「添加到主屏幕」之后跟个 app 一样。
卡片上那行大字现在是程序自己设的终端标题,agent 会往那儿写它正在做什么,命令挪到下面一行;标题只是程序的路径或者名字、说不出新东西的,还是照旧显示命令。
手机屏幕行太少,vim、htop、agent 这些占满屏的程序摊开就没法看。⤢ 按钮让当前 pane 占满它所在的窗口,整个会话调成手机的行列数,这些程序按手机的大小重画一遍。一个会话只有一个大小,所以适配的这段时间,电脑跟前和别的手机上看这个会话也是小的,页面和状态栏都会说一声。离开这个 pane、退回列表、锁屏都会恢复,没人看了 10 秒 keepane 自己也会恢复。
二维码里带着地址和一把每次启动新生成的 128 位随机密钥,除了页面本身,没这把钥匙什么都不回。起来之后是服务端在后台供着,运行 keepane web 那个终端就空出来了,web status 看谁连着、在看哪个 pane,web stop 掐掉。--read-only 只让看不让打,--keep-key 让二维码每次一样、书签不失效,写 web-start -k 进配置就是开机自己起。自己家网络直接连,在外面就在中间放一层 Tailscale 这类私有网络,绑到它的地址上。
日常
剩下这些是为「人不在跟前」配的:
C-b v的 dashboard 把所有 pane、选中那个的收件箱、任务摆在一屏里,右边是它的实时屏幕、滚动历史或者事件,能直接发消息、改模式、解卡、跳过去
重启电脑之后keepane resume把 session、布局、每个 pane 的目录和原命令摆回来,屏幕上最后 500 行也回放(save-history all连整个滚动历史一起);Windows 上keepane startup on让它开机自己起,Linux 和 macOS 先写进自己的登录脚本C-b u十秒内找回手滑关掉的 pane 或者窗口,程序还在跑C-b q每块显示自己的编号,按数字直接跳过去,起过名字的在编号下面显示%名字C-b C-t在每条命令那行的行尾空白处标出开始时间、耗时和成败,不改 pane 宽度,也不进capture-paneC-b /按 pane、按天翻它打印过的东西,一天一个文件留 30 天,C-b B是任务板,一行一个 pane,能跳转、关掉、重启
后台窗口有动静、响铃或者太久没声音,状态栏标出来,C-b M-n跳过去;set -g notify on之后直接发系统通知,Windows 上的 toast 带一个「跳到 pane」的按钮
装和用
Windows 上 scoop 直接读仓库里的清单,不要管理员权限,另外有 MSI 和 zip;Linux 和 macOS 走 Homebrew。
scoop install https://raw.githubusercontent.com/newdee/keepane/master/packaging/scoop/keepane.jsonbrew install newdee/tap/keepanekeepane # 新建 session 并进去
keepane new -d -s bg htop # 后台起一个
keepane attach -t work # 换个终端窗口也能接回来
keepane resume # 重启之后把存过档的 session 都摆回来日常那半边不用重新学:前缀 C-b,% " 分屏,[ 进 copy mode,: 命令行,format 串、hook、插件、配置文件的写法照旧,keepane import-config 把现成的 .tmux.conf 过一遍就能接着用。tmux 3.5 的命令表没有落下的,逐条对照在 tmux-parity.md。
实现
Rust 写的,一个二进制既当客户端也当服务端。第一次运行拉起一个脱离终端的服务端进程,所有 session 归它管,客户端用命名管道(Windows)或者 Unix socket 连上去。每个 pane 是一个伪终端加一份 vt100 屏幕模型,服务端把可见的 pane、边框和状态栏合成一帧,只把变了的格子发给客户端。
主循环有 16 ms 的帧率上限。4 个 pane 满速刷屏、挂着一个客户端的时候,服务端 CPU 在一个核以内,同样负载下没有这个上限是 166% 核;按键仍然立刻画出来,不加打字延迟。
平台相关的都收在 src/platform/ 下面,Windows 走 ConPTY、命名管道、job object,Linux 和 macOS 走 pty、Unix socket、进程组和信号,其余代码两边共用,三平台 CI 每次提交跑全量。