终端复用器 keepane
Rust终端WindowsAgentWindows 上没有 tmux,终端一关,跑着的编译、部署、agent 全跟着没,WSL 里的 tmux 又管不到 PowerShell 的 pane。
keepane 就是补这个的。脱离之后 pane 里的程序继续跑,重启电脑后 keepane resume 把布局摆回来,tmux 的按键、命令和配置写法照旧。
另一半 tmux 没有,每个 pane 有名字、有收件箱、有工作模式,pane 之间能互发消息,也能让对方直接执行一条命令。
起因
最早只是想在 Windows 上有个 tmux,第一个提交的标题就是「tmux-style terminal multiplexer for Windows」,那会儿还叫 wmux。
真用起来才发现分屏解决的只是摆放。几个窗口里各跑一个 Claude Code,让它们互相派活得靠人来回复制,人一离开活就停了。tmux 管排版不管通信,这部分只能自己加。
于是 pane 从一块屏幕改成了一个 actor,有名字、有收件箱、有工作模式。改名也是这时候的事,wmux 这名字 GitHub、winget、crates.io 上都有人在用了,0.14.0 起改叫 keepane,keep 加 pane,终端没了 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,: 命令行,配置文件是 tmux 语法。keepane import-config 把现成的 .tmux.conf 过一遍,不认的行注释掉并写上原因,不会悄悄丢。跟 tmux 逐条对着的差异在 docs/tmux-parity.md。
消息
给 pane 起个名字,它就能收消息:
keepane rename-pane -t %3 builder
keepane set-work-mode -t %builder shell
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 就被当命令执行。
空闲判断
这部分的规则最琐碎。
只认 keepane 自己的提示符标记(OSC 7777;keepane-prompt),不认 OSC 133。ssh 到远端的 shell 也会发 OSC 133,认了就会把别人机器上的提示符当成本地空闲。
按键、粘贴、send-keys 一律转成忙,免得消息插进正在打的字中间。麻烦的是提前打字:keepane 投的命令还在跑,人在那儿先敲了半行,这条命令结束时的提示符就不能算空闲,否则消息会接在那半行后面执行。所以状态机里除了「在提示符」还有一位记着「行上留着东西」,一直忙到有人再按键,把那行运行掉或者清掉。bash 每收到提前打的字会重画一次提示符,不记这一位就会被提前放行。
还有一处是 ConPTY 的顺序。提示符标记是个 OSC,ConPTY 立刻转发,而它前面那些文字要等下一帧才画出来,于是标记到达时命令最后几行输出还不在屏幕上,实测 keepane-cmd 和 9;9 都排到了它们之后才写出的输出前面。现在的做法是标记到达即判在提示符,但当前消息要等 60 ms 才算结束、才截输出,这期间不投递新的。
有一条一开始就写反了。read-message 取走的消息应该取走就算完,不该算成这个 pane 正在处理的那条,当初按后者写,结果 agent 在自己一轮里先取回信、再发下一条,hop 一路往上累,几轮往来之后就被跳数上限拒收了。
ConPTY
Windows 上伪终端只有 ConPTY 一条路。它跟 Unix 的 pty 不太一样,自己带一个屏幕模型,会重排也会补行,下面这两处都栽在这上面。
输出起点
有个测试只在 Windows 的 CI 上偶发失败,最近 60 次里挂了 4 次,Linux 和 macOS 一次没挂,名字叫「命令正好写满行尾时输出的第一行还在」。
本机复现不了,加了临时诊断(环境变量打开,记每次的输出字节、送达时的光标与行、截取范围),把机器压到 2 核再挂 4 个空转进程,80 次里失败 6 次,6 次模式一模一样:前一条命令正好写满行尾时,ConPTY 把这一行和 shell 的换行分两帧发出来,第一帧后光标停在行尾等换行,第二帧才是 \r\n,而 conhost 在最后一列已经换过行了,于是比终端多出一行。之后 PSReadLine 按 ConPTY 的行号用绝对坐标画下一条命令(ESC[8;9H),比 keepane 看到的提示符低一行,按「送达时光标行加所占行数」推算出来的输出起点就落到命令自己的最后一行上,输出首行成了那行末尾的字符。负载低的时候两部分在同一帧,ConPTY 会补上空行,两边一致,所以平时看不出来。
修法是不再推算,命令结束时到屏幕上去找:从送达那行往下逐行拼接、去掉空白,第一次包含整条送入文字的那行就是命令末行,输出从下一行起;找不到(多行命令,或者 shell 中间画了别的东西)才退回推算。同样的压法,修完 80 次 0 次,去掉诊断的最终代码 31 次 0 次,同负载下耗时没变。
多行命令
一条多行命令没法逐行打进 shell 执行。PSReadLine 不开括号粘贴,而 ConPTY 会把 Shift+Enter 的 Shift 丢掉(实测收到 vk=13 state=0),逐行打就是每行各自回车各自执行,消息在第一个提示符就算结束了。
所以多行在打入前先改写成一行:PowerShell 走 . ([scriptblock]::Create(…)),bash 和 zsh 走 eval,都在 shell 自己的作用域里执行,成败取整块(bash 和 zsh 取最后一行的)。
剪贴板
这条是排查里最值钱的一次。验收时 e2e 有一次在打结果之前就退出了,退出码 0xC0000374,堆损坏。
并行跑 8 次挂 2 次,串行跑 6 次一次不挂,所以问题出在同一个进程里多个 server 并发的时候。没有管理员权限用不了 Page Heap,也没有 nightly 和 cdb,只能做对照排除:关掉 sysinfo 还是崩,环境变量只设一次还是崩。想到进程级共享的东西还剩剪贴板,写了个 8 线程同时读写剪贴板 5 秒的压力测试,3 次运行 3 次都是堆损坏。
原因是 OpenClipboard(NULL) 能挡住别的进程,挡不住同一进程的别的线程,一个线程的 EmptyClipboard 会把另一个线程正在读、或者刚交出去的内存块释放掉。改成进程内自己加一把锁,从打开到关闭全程持有。修完压力测试 5 次全过,每次两三万次操作,e2e 并行 12 次加 10 次都干净;压力测试缩到 2 秒留作永久单元测试,测完把剪贴板原来的内容写回去。
真实使用里同样的风险也在,两个客户端同时复制,或者右键粘贴和 copy mode 同时发生,只是概率低得多。之前有一次「原因不明的中断」也被这条解释掉了。附带损失是修好之前那次崩溃没能把这台机器剪贴板里原来的东西还回来。
帧率
15 分钟的 soak,4 个 pane 满速刷屏,服务端 CPU 平均 165% 核。拆开看,没有客户端只解析是 113%,挂一个客户端做渲染是 166%,渲染自己吃掉半个核。
主循环每处理一批事件就 render_all 一次,满速输出时每次读 pty 都重画一遍。加了 16 ms 的帧率上限,不足 16 ms 只记一个待画,在间隔末尾醒来补一帧,画面不会停在旧状态。同一个脚本前后对比,服务端从 166% 核降到 99%,客户端进程同样时长从 2719 ms 降到 62 ms,隔一阵之后来的按键仍然立刻画出来,打字延迟没变。
SSH
从 SSH 里 C-b d 脱离、关掉连接,回来 session 全没了。server.log 里那个 server 没有任何退出记录,正常退出、panic、restart 都会记,所以它是被外面杀掉的。
OpenSSH 把每个会话放进一个 KILL_ON_JOB_CLOSE | BREAKAWAY_OK 的 job,而起 server 的地方没带 CREATE_BREAKAWAY_FROM_JOB,这参数之前只给 restart-server 加了,起 server 的两处没跟上,server 就留在会话的 job 里,连接一断整个 job 被清掉。
起 server 的两处补上脱离标志,job 不许脱离(CreateProcess 返回拒绝访问)就退回不带再起一次。
手机
keepane web 在终端里打一个二维码,手机扫了是一个列出所有 pane 的页面,按 session 和窗口分组,点进去看屏幕、带颜色,底下有输入框和一排手机键盘没有的键(Esc、Tab、方向、Ctrl+C)。跑的全在电脑上,手机只负责显示和输入。

二维码里带着地址和一把每次启动新生成的 128 位随机密钥,除了页面本身,没这把钥匙什么都不回。它是纯 HTTP,给自己家网络用的,共享网络上有人抓包就能看到那把钥匙;从外面连要在中间放 Tailscale 这类私有网络,绑到它的地址上。
限制
已知的几处:
卡在忙:在 agent 输入框里打了字又删掉,或者 Ctrl+C 取消而提示符没重绘,这个 pane 会一直忙,得用
pane-ready -t手动解开
cmd、fish 和 WSL 里的 shell 没有 keepane 的提示符钩子,shell 模式下永远不空闲,消息只排队不执行,不会乱跑,但也确实用不了
跳数只拦得住处理中互相转发形成的循环,agent 空闲之后凭记忆重新发起的拦不住,靠收件箱上限兜底
投递和人手打字之间还有毫秒级的竞争窗口
shell 模式执行的命令带着信封注释,会进 PSReadLine 的持久历史,之后按上箭头看得见;要只留在当前会话,得在钩子里设AddToHistoryHandler,可能覆盖用户自己的,暂时不做
权限规则防的是失误,同一个 Windows 用户的任何程序本来就能连命名管道、对任意 panesend-keys,挡不住存心的
取舍
要不要从 tmux 换过来,分两种情况。
在 Linux 上、又不在 pane 里跑 agent 的,tmux 就够了,keepane 多出来的只有消息这一套,不需要的话换过来没有收益,何况 tmux 久经考验。Windows 上是另一回事,那里本来也没得选。
还有一条得说清楚,这东西写了半个月、一百八十几个提交,别当成成熟软件用。三平台 CI 每次提交跑全量,但上面那几条坑都是这半个月里现撞出来的,后面肯定还有。
剩下的都写在 README 和 keepane man 里了,也挂了一份功能一览。手机那页做完之后,我最常拿它干的事是躺着看编译跑完,大概不算什么好习惯。