工具 · · 11 min

十个 tab 跑着十个 AI,我手一抖关了一个
Ten tabs, ten AIs, and the one I closed by accident

我桌面上常年开着十个 Windows Terminal tab。

每个 tab 里跑着一个 Claude Code session,每个 session 在写不同项目的代码:左边那个在改 A 股策略的回测,中间那个在调 Caddy 反代,最右边那个在 review 一个我自己都快忘了的 PR。

它们安静地跑着,偶尔在底部闪一下 prompt,等我回去说下一句话。

这是我一天里大部分时间的样子。

直到那天下午,我伸手够咖啡,肘子蹭到了键盘上的 Ctrl+W

我盯着那个一秒前还在跑着东西、现在只剩下一片空白的 tab,第一反应不是骂脏话。

是慌。

因为那个 tab 里跑的不是一个 shell,是一个我已经磨了一整个上午的 Claude——它刚帮我把一个反复出 bug 的 PnL 计算逻辑捋清楚了,正准备落代码。它脑子里装着我们刚刚那二十轮对话的所有上下文。

那条会话历史,在那一秒之后,不会回来

哦对了,分屏也没了。我之前在那个 tab 里 Alt+Shift+D 切了一个左右分屏,左边跑 Claude,右边开着 git log --oneline 做参照。分屏的工作目录、profile 选的是哪个 shell、那一组配套关系——一并蒸发。

我那天没继续干活。我打开了 GitHub,开始翻 Windows Terminal 的源码。

我们这些每天在 terminal 里跑 AI 的人,心里多多少少有个错觉:tab 嘛,关了再开就好。

这个错觉来自一个老习惯——以前 tab 里跑的只是 shell,shell 是一个无状态的东西,你关掉它和你重开它,没差别。

但现在不一样了。

现在 tab 里跑的是 AI session。AI session 是有状态的——上下文是状态、待办是状态、它记下的项目规矩是状态、它跟你磨出来的那种”它已经知道你不喜欢加注释”的默契,也是状态

你关一个 tab,不是关一个 shell。

你是在关一个正在累积的东西

这跟我上次写 claude-repath 时碰到的问题,本质上是同一个:AI 编程时代最值钱的东西,往往挂在最脆弱的载体上。一次是路径字符串,这次是 tab 进程。

我那天晚上的目标很简单:让这件事不能再发生

不是”少发生”,是不能再发生。手一抖、咖啡蹭到、windows update 重启、WT 崩了——任何一种意外,都不能让我再丢一次会话。

我列了一下要防的事:

  1. 单个 tab 误关(最常见,每周至少一次)
  2. 整个 Windows Terminal 进程退出(WT 闪退、系统重启、不小心点了红 X)
  3. 后台 tab 跑完了,我不知道——必须一个个切过去才能确认 Claude 是不是 idle 了

前两个是结构问题,第三个是感知问题。

如果只防第一个,重启一次还是全军覆没;如果只防前两个,我每天还是要切来切去看哪个 Claude 在等我;如果只防第三个,那只是把”漏了”的时刻往后推,没解决底层。

得三层一起做。

第一层:tab 布局不能丢。

这一层其实 Windows Terminal 自己就能解。我之前没去翻设置,所以一直不知道。

settings.json 顶层加一行:

"firstWindowPreference": "persistedWindowLayout"

keybindings 数组里追加:

{ "id": "Terminal.RestoreLastClosed", "keys": "ctrl+shift+z" }

前者管整体——关 WT 时自动把整个窗口的 tab 结构、分屏布局、每个 pane 的工作目录序列化到磁盘,下次启动自动还原。后者管单个——关错一个 tab 按 Ctrl+Shift+Z 撤销,跟 VS Code 撤销关闭文件一个道理。栈深 100,挪过的 pane 也能恢复。

我顺手翻了一下 microsoft/terminal 仓库的 TabManagement.cpp,发现关闭 tab 时它会调用 tab->BuildStartupActions(BuildStartupKind::None),递归遍历整棵 pane 树,把每个 split 序列化成 SplitPane action,含方向、比例、profile GUID、实时 CWD。重启时把这些 action 回放一遍。

这是 WT 早就埋好的能力,没几个人知道。

但它有个边界:能恢复结构,没法恢复进程。tab 重启回来,分屏布局还在,工作目录还对,但 Claude session 是从零开始的——里头跑的进程不会被序列化。

这意味着,第一层只能解决一半问题:我的工作”摆位”不丢了,但工作”内容”还是没了。

得有第二层。

第二层:Claude session 的对话历史本身要落盘。

这一层我本来准备自己写——想着在 Claude Code 启动时拉一个 daemon,定时把对话状态序列化到本地 JSON。

写了一半我去翻 Claude Code 的 ~/.claude/projects/ 目录,发现:它早就在干这件事了。

每个项目目录下面有个 sessions/ 子目录,里头一堆 .jsonl 文件,每个文件是一次会话的完整流水。每一轮对话——user 说了啥、assistant 回了啥、调了哪些 tool、tool 返回了啥——按 turn 一行一行落盘,实时写入

我关 tab 那瞬间,Claude session 的进程是死了。但那个 .jsonl 文件,从头到尾完整躺在硬盘里,一个字节都没丢

我那天慌得没意义。

我只要在新 tab 里 cd 进同一个项目,然后 claude --resume <session_id>,整段对话就接着续上了。它甚至连”我刚才说到哪了”都不需要我提醒——.jsonl 完整放着,Claude 一读就知道。

Anthropic 已经替我做完了。 我之前没意识到只是因为没出过事——出事的人才会去翻这个目录。

所以第二层我什么都不用做。只需要在文档里写明:“会话内容这一层 Claude Code 自己保住,不用配置。”

到第二层为止,意外丢失这件事已经被防住了。

但还剩第三个问题:我每天还是在不停切 tab,因为我不知道哪个 Claude 跑完了。

这不是”丢失”问题,是”感知”问题。十个 tab 在后台跑,谁好了?谁卡了?谁正等着我点 yes?我只能一个个切过去看。

切来切去这事看起来不重,但放在一整天里——每次切 tab 5 秒,每天切个一百次,半小时没了。

我想要的很简单:Claude idle 了就主动叫我,不要让我去问它。

这一层是我撞坑撞得最狠的一层。也是这个工具包的灵魂。

我第一版(v0.1.0)的方案听起来天经地义——

Claude Code 有个 hook 机制,能在 StopNotification 事件里执行任意命令。我让它在 idle 的时候 printf '\a',那是 ASCII 响铃字符。Windows Terminal 默认收到响铃会触发 bellStyle: ["window", "taskbar"]——任务栏图标闪一下、窗口边框闪一下——这不就完美吗?

我配好了,重启 Claude,等它 idle。

任务栏没动。

我以为是 hook 没触发,去翻 debug log,发现 hook 是触发了的——printf '\a' 确实跑了。

但响铃没出来

我盯着这事看了快两个小时。

后来我去翻了 Claude Code 的 hook 文档,找到一行字,看完想骂街:

For most events, stdout is written to the debug log but not shown in the transcript.

翻译人话:hook 命令的 stdout,根本不会到 terminal

它被 Claude Code 自己捕获走,写到 debug log 里去了。

那一刻我才想明白:printf '\a' 输出的那个字节,确实被打印了——但它打进了 Claude Code 的内部 log 文件,没有任何一条路径让它到达 Windows Terminal 的输入流。WT 自然不会响铃。

不死心。

我说那我绕开 stdout——直接写 /dev/tty,那是 controlling terminal 的设备文件,写它就是直接绕过 Claude 的捕获写到终端设备本体上。Linux 老办法。

我把命令改成 printf '\a' > /dev/tty 2>/dev/null || true,重启,等 idle。

任务栏没动。

我去 Git Bash 里手动跑这条命令:

$ printf '\a' > /dev/tty
/usr/bin/bash: line 1: /dev/tty: No such device or address

No such device.

Claude Code v2.x 的 hook 子进程没有分配 controlling tty/dev/tty 在那个上下文里根本不存在——它不是被 Claude 捕获了,它压根没出生。

而我那个 || true 是好心办坏事——它把错误吞了,hook 看起来一切正常,没有任何错误日志。静默失败

那天晚上我对着屏幕坐了很久。我突然意识到一件事:

任何”让一个字节到达 terminal 来触发 WT 行为”的方案,都走不通。

不是路径选错了。是这整个方向是死的。

我换了思路。

既然字节到不了 terminal,那就绕过 terminal

Windows 11 本身就有现代化的桌面通知系统——Toast 通知,靠 Windows.UI.Notifications API。这玩意儿不需要 terminal 当中间人,直接走系统 API,弹到右下角。

PowerShell 社区有个模块叫 BurntToast,封装了那套 WinRT API。一行命令就能弹 Toast:

New-BurntToastNotification -Text 'Claude 等你输入', '项目: quant-trading'

我把 hook 命令改成这个,重启 Claude,等 idle。

右下角”叮”一声,弹出一条 Toast。

成了。

这才意识到 Claude Code 的 hook 配置里有个 shell 字段——

{
  "type": "command",
  "shell": "powershell",
  "command": "New-BurntToastNotification -Text ..."
}

加上 "shell": "powershell",hook 命令直接在 PowerShell 中执行,完全跳过 bash。再也不用纠结那个 bash 嵌套 PowerShell 的引号转义噩梦了。

整个 v0.2.0 的核心就一句话:别让字节走 terminal,让系统 API 直接弹通知。

弄通之后我又顺手做了一件事——把通知分类。

Claude Code 的 Notification hook 不止 idle_prompt 一种。我翻了它的源码,发现有六种事件类型:

matcher什么时候触发
idle_promptClaude 回答完了,等你输入
permission_promptClaude 想执行个 Bash 命令,等你按 yes
elicitation_dialogClaude 中途用 AskUserQuestion 问你东西
auth_success认证成功
elicitation_complete你回答完 elicitation
elicitation_responseelicitation 响应

后三个是 Claude 自己内部状态变化,不需要打扰我;前三个都需要我介入,但需要我做的事情不同——是该输入还是该批准还是该决策。

所以我让三个 Toast 用不同标题:

扫一眼通知标题就知道是什么级别的事。需要立刻处理的和可以稍等的,视觉分开

这三层加起来,整个工作流变成了这样:

整套配置加起来不到 40 行 JSON 加一个 PowerShell 模块。但每一行背后都是一次撞墙——从 \a 响铃到 /dev/tty 到最后绕开 terminal 走 WinRT,三个晚上才走到对岸。

回看这件事,我有个挺扎心的体会。

我们这些把 AI 当工具用的人,常常以为:只要工具本身没坏,工作流就是稳的。

但其实工作流的脆弱性,从来不在工具本身。

它在工具的承载方式上。

claude-repath 那次,我以为我的”AI 默契”在项目代码里,结果发现它绑在一个绝对路径字符串上,挪个文件夹就断了。

这次,我以为我的”工作上下文”在 Claude 会话里,结果发现它绑在一个 tab 进程上,手一抖就没了。

每次都不是 Claude 出问题,是承载 Claude 的那一层基础设施——文件系统的路径、操作系统的 tab、终端的 stdout——在不经意的瞬间,把上面所有积累全部清零。

我们一直在用脆弱的基础设施,去承载越来越值钱的 AI 上下文。

这件事,是早晚要被认真对待的。

claude-code-wt-tab-resilience 在 GitHub 上(xPeiPeix/claude-code-wt-tab-resilience),MIT,免费。configs/ 目录里两个 snippet 文件,复制粘贴改两处配置就跑起来。BurntToast 一次 Install-Module,从此不用管。

我没期待它火。

但下一次你伸手够咖啡、肘子蹭到 Ctrl+W 的时候——我希望那个 tab 还能回来。

或者更好的——你已经习惯了它一定会回来,所以你从来不会再为它心疼一次。

也想问问你——

你每天和 AI 一起堆出来的那些上下文:对话历史、待办、那些它已经懂你的东西……

你有没有想过,它们其实并不在 AI 那一边?

它们在你这一边。在一个 tab 进程里、在一个 PID 里、在一个 stdout 流里。

挂在那些你从来没在意过的东西上。

哪天你伸手够咖啡,它还在吗?