自 [Paneflow 0.17.7](https://github.com/arthjean/paneflow/releases/tag/v0.17.7) 起，侧边栏只从一个地方获取智能体的回合状态：智能体写入自身终端的状态。因权限请求而暂停的智能体会写入如下形式的序列（示例来自规范）：

```text
ESC ] 7501 ; state=blocked:kind=permission:app=terraform:msg=QXBwbHkgMyB0byBhZGQs... ESC \
```

通常的假设是，运行智能体的工具必须从外部观察它们：匹配加载动画（spinner），识别权限提示，察觉输入框重新出现。这些我实现过好几次，又都删掉了。我现在的立场是：回合状态属于智能体，它是唯一真正知道这一状态的进程；终端的职责是传递这份声明，而不是去猜测。

以下是 Paneflow 0.19.0 中发布的内容。它覆盖会发出 OSC 7501 的智能体：v2.1.295 起的 Claude Code，以及 10 月在 [openai/codex#52725](https://github.com/openai/codex/pull/52725) 和 [#54114](https://github.com/openai/codex/pull/54114) 中合并了支持的 Codex。不声明任何状态的智能体不会获得回合状态，这是有意为之。

本文跟随一个 Claude Code 回合从智能体走到侧边栏，然后介绍整体结构、此前的六种方案、成本，以及最有力的反对意见。

## OSC 7501 是什么

如果您读过 Mitchell 关于该协议的文章，可以跳过本节。

Mitchell Hashimoto 于 2026 年 10 月 6 日发布了 [Program Status Protocol](https://www.superlogical.com/rex/docs/build/program-status)，称其为 "a completely generic, terminal-native specification"（一种完全通用、终端原生的规范），源自他在 Superlogical 和 Ghostty 上的工作（[他的文章](https://mitchellh.com/writing/program-status-osc7501)）。程序写入 `OSC 7501`，附带以冒号分隔的 `key=value` 键值对：

- `state`：`idle`、`working`、`blocked`、`done` 或 `error`，另有作为操作的 `clear`。
- `kind`，仅用于 `blocked`：`permission`、`question` 或 `auth`。
- `progress`、`app`、`title` 和 `msg`（base64，最多 2,048 字节）。

程序通过 `OSC 7501 ; ?` 查询终端是否支持。协议没有心跳。当 shell 提示符返回（`OSC 133 A`）或程序退出时，终端丢弃 `working` 和 `blocked`；`done` 和 `error` 会一直保留，直到终端决定隐藏它们，例如在下一次按键时。

对 Paneflow 而言，有两个特性很重要。状态来自唯一知道它的进程。生命周期规则则意味着终端永远不必为状态设置超时。

## 一个 Claude Code 回合：从智能体到侧边栏

下面跟随单个回合，经过所涉及的三个进程：持有 PTY 的会话宿主进程、负责归约状态的工作进程（worker），以及桌面窗口。

**Paneflow 以 Ghostty 的身份应答。** 宿主进程以 `TERM_PROGRAM=ghostty` 启动每个 shell（[`spawn_env.rs`](https://github.com/arthjean/paneflow/blob/v0.19.0/crates/paneflow-host/src/host/spawn_env.rs)），其终端引擎是 `libghostty-vt`。当 Claude Code 询问终端是否支持该协议时，引擎回复：

```text
ESC ] 7501 ; ? : states=idle,working,done,blocked,error : kinds=permission,question,auth ESC \
```

宿主进程看到该回复发往 PTY，便将会话标记为 `program_status_capable`。只有宿主进程会应答。桌面端也嵌入了 libghostty 用于绘制，但没有安装 OSC 7501 回调，因此智能体永远不会收到两个回复；名为 `desktop_terminals_neither_answer_nor_report_program_status` 的测试确保这一点不变。

**您提交提示词，Claude Code 写入 `state=working`。** 这些字节经过已在维护网格的同一个 libghostty 解析器，因此 Paneflow 不会再扫描一遍。libghostty 将解析后的状态交给一个回调，宿主进程的事件循环将其转换为一条记录：

```rust
ghostty::BackendEvent::ProgramStatus(report) => {
    self.program_status.apply(report);
    self.program_status_capable = true;
}
```

宿主进程中的一个线程每 500 ms 遍历一次会话，跳过没有新内容的会话，仅当当前声明发生变化时才将其写入会话的 manifest。工作进程通过推送流跟随宿主进程。

**工作进程负责归约。** `paneflow-serve` 位于宿主进程与所有客户端之间：桌面端、`paneflow` CLI、MCP 桥接。它先检查会话运行的是已知智能体，再映射声明。以下就是完整的状态机（[`state.rs`](https://github.com/arthjean/paneflow/blob/v0.19.0/crates/paneflow-serve/src/state.rs)）：

```rust
fn declared_verdict(&self) -> (Status, ActivitySource) {
    let Some(declared) = self.agent_declaration() else {
        return (Status::Idle, ActivitySource::None);
    };
    let running = self.lifecycle.is_running();
    let status = match declared.state.as_str() {
        DECLARED_ERROR => Status::Errored,
        DECLARED_WORKING if running => Status::Busy,
        DECLARED_BLOCKED if running => Status::Attention,
        _ => Status::Idle,
    };
    (status, ActivitySource::Declared)
}
```

没有计时器、hook 或响铃（bell）向它输入数据。已退出的进程永远不会处于忙碌或等待状态，而 `error` 在退出后仍会保留。归约状态每次变化都会递增 `state_seq` 计数器，`paneflow wait --idle` 和 `paneflow send --submit` 正是依据它工作。

**Claude Code 请求权限。** 它写入 `state=blocked:kind=permission`，并在 `msg` 中附上问题。工作进程将会话切换到 Attention，并且由于这是进入 Attention 的转换，决定发出一条携带该消息的 "Needs input" 通知。桌面端只判断该面板是否已经在您眼前。该行显示 `Need you`，并移到侧边栏 Agents 区域的顶部。

**回合以 `state=done` 结束。** 工作进程发布一条 "Finished" 通知，并将会话标记为未读。该行显示 `Done`，直到您打开该标签页。宿主进程会保留 `done` 记录，直到您在面板中输入；通过 `paneflow send` 或 MCP `write_pane` 工具发送的文本不算作输入。

## 整体结构

每个进程只做一件事。宿主进程负责解析并保存记录，且有上限：每个会话 256 条记录；待处理的状态事件突发量超过 256 KiB 时，它会清空全部内容，而不是保留部分状态。工作进程负责归约并决定通知，重启时会根据 manifest 重建每个会话。桌面端渲染通过推送流收到的内容，并合并唤醒。

智能体声明的内容，以及您看到的结果：

| 声明 | 侧边栏 |
|---|---|
| `working` | 加载动画 |
| `blocked` | `Need you`，并附带包含智能体消息的通知 |
| `error` | `Error`，并附带通知 |
| `done` | `Done`，直到您看过为止，并附带通知 |
| `idle` | 不显示 |
| 3 秒后仍无任何声明 | `No status` |

## hook 仍然负责什么

Claude Code 和 Codex 仍会接入 Paneflow 的 hook，各 9 个事件。hook 承载 OSC 7501 不承载的信息：使对话可恢复的提供方会话 ID 和对话记录路径、为标签页命名的提示词、填充通知的最后结果，以及子智能体的启动和停止。它们从不改变状态，并有测试固定这一点：`a_hook_event_carries_metadata_but_never_moves_the_declared_state`。

需要注意一点：Claude Code 同步运行这些 hook（`"async": false`、`timeout: 5`）。上报程序给自己 350 ms 建立连接，350 ms 完成请求，然后以 0 退出，stdout 上不输出任何内容。最坏情况下，Paneflow 缺失时每个事件约耗费 700 ms。

## 在此之前的六种方案

此功能之前的每个版本都靠推断得出状态。按时间顺序：

1. **窗口标题。** 最初，一个检测器监视 OSC 标题中的盲文加载动画字符。4 月 7 日，我将其移除，改用来自包装脚本的 hook（[`107098da1`](https://github.com/arthjean/paneflow/commit/107098da1)）。
2. **PTY 活动。** 6 月，没有 hook 的智能体获得了一种启发式方法：一阵输出突发会将智能体提升为 working，3 秒静默会将其降级，按键回显会被过滤掉。一周后我移除了它，因为它 "produced false spinners (a dev server streaming under a bare-shell thread, TUI redraws)"（产生了虚假的加载动画：纯 shell 线程下持续输出的开发服务器、TUI 重绘）（[`4200e8d49`](https://github.com/arthjean/paneflow/commit/4200e8d49)）。失败。
3. **不活动计时器。** 静默 300 秒后会触发 `Stalled` 状态。它在 9 月被移除："A long turn is a long turn, not a fault."（长回合就是长回合，不是故障。）
4. **不依赖 hook 的来源。** 组织可以通过 `disableAllHooks` 或 `allowManagedHooksOnly` 关闭 Claude Code hook。8 月，我加入了 OSC 9;4 进度、OSC 777 通知和 Claude 的会话注册表文件，与 hook 按优先级比较，接管窗口为 20 秒（[`0f3da66ea`](https://github.com/arthjean/paneflow/commit/0f3da66ea)）。
5. **持久化归约器。** 9 月，会话一旦锁定（latch），hook 就成为权威来源；一个由屏幕变化重新续期的 5 分钟租约，为丢失的 `Stop` 设定了上限；视口扫描则用于查找菜单。10 月，终端响铃也作为需要关注的信号加入。
6. **声明式屏幕规则。** 10 月 2 日，每个智能体都有了一个 `screen.toml`，包含按优先级排列的正则规则，并配有签名的远程目录和一个由捕获屏幕组成的语料库（[`c1d48e580`](https://github.com/arthjean/paneflow/commit/c1d48e580)）。它随 0.17.6 发布。

方案 5 的设计笔记列出了我不断修补的症状：丢失的 `Stop` 会让面板一直显示思考中；Claude Code 的文档说明 `Stop` 在用户中断时不会触发，因此回合中途按下 Escape 后加载动画仍在转。它的编号菜单完全不触发 hook，因此等待用户选择的会话显示为忙碌。每一层都是为了修正下面那一层而存在。

10 月 7 日，我重新固定了 libghostty 版本，以获取其 OSC 7501 API。3 天后，[`328a8fe12`](https://github.com/arthjean/paneflow/commit/328a8fe12) 将声明设为回合状态的唯一来源：119 个文件，新增 1,020 行，删除 12,417 行。屏幕规则、远程目录、捕获命令、响铃关注和 Escape 处理全部移除。

有三点促成了这个决定。Claude Code 和 Codex 现在会自行声明状态，而来自智能体的声明胜过我从外部做出的任何推断。推断出的状态以各种方式出错，我一直在修补。此外，规则在每次智能体发布时都有维护成本：语料库、目录签名、测试夹具（fixture）。

不过，这些规则在其测试夹具上并没有出错。被删除的语料库基线中，每个智能体的准确率都是 1.0，每个智能体各有 4 到 7 次捕获。这个数字衡量的是我已有的捕获，而不是智能体的下一个版本。

另一种选择是保留这些规则，作为不声明状态的智能体的回退方案。0.17.7 没有提供回退："Paneflow offers no replacement: the status comes back once the agent itself emits OSC 7501."（Paneflow 不提供替代方案：一旦智能体自身发出 OSC 7501，状态就会恢复。）

## 身份靠匹配，状态不靠

Paneflow 只在一个地方针对特定智能体：识别面板中运行的是哪个智能体。[`runtimes/`](https://github.com/arthjean/paneflow/tree/v0.19.0/runtimes) 目录描述了 13 个智能体，宿主进程将面板的前台作业与之匹配。

在 Linux 上，它遍历 `/proc/<pid>/task/<tid>/children` 并读取 `cmdline`。在 macOS 上，它使用 `proc_listpids(PROC_PGRP_ONLY)` 和 `KERN_PROCARGS2`。在 Windows 上，由于没有进程组，它会从会话首进程向下遍历最多 64 个后代进程，并从每个进程的 PEB 中读取命令行。`node`、`bun` 或 `python` 包装器会通过脚本路径解析，因此在 `node.exe` 下运行、通过 npm 安装的 Claude Code 仍能被识别，而 `-e`、`-c` 和 `-m` 永远不会指向某个智能体。内核启动时间会在读取参数之前和之后各读取一次，因此被回收复用的 pid 永远不会被归到错误的智能体上。

**提示：** `fx` 这个别名同时也是一个 JSON 查看器的名称。只有当面板标题以 `fx v` 开头时，Paneflow 才将 `fx` 视为该智能体。

错误的匹配可能会标错面板中运行的是哪个智能体。它无法凭空编造该智能体正在做什么。

## 成本

在 Windows 11 Pro、Ryzen 7 7800X3D、RTX 4070 Ti SUPER 上，使用 0.18.0 的 release 构建（`b57da648`），一个面板运行一个测试夹具程序，通过 OSC 7501 声明 `working` 达 62 秒（[基准测试摘要](https://github.com/arthjean/paneflow/blob/main/bench/results/hardware-summary-2026-10-27-windows.md)）：

| 进程 | CPU（单核） |
|---|---|
| 宿主进程 | 0.00 % |
| 工作进程 | 0.05 % |
| 桌面端 | 4.80 %，其中主线程 3.66 % |

桌面端每秒绘制 11.13 次根渲染，这些都来自加载动画。侧边栏中所有加载动画由同一个 90 ms 动画时钟驱动；测试将 1 个或 8 个 working 智能体的侧边栏唤醒次数固定在每秒 10 到 12 次；`reduce_motion` 则绘制静态加载动画，完全不使用时钟。

延迟也单独做了修复。宿主进程本就从 shell 集成中读取 `OSC 133 A`，自 10 月 31 日起，提示符开始会立即唤醒扫描，而不再等待下一次 500 ms 遍历。在 5 个周期中，从智能体退出到释放的时间从 434 至 491 ms 降至 6 至 9 ms（[`96c525144`](https://github.com/arthjean/paneflow/commit/96c525144)）。

**重要：** 以 CPU 为由反对屏幕规则，论据比听起来要弱。我的规则测得 "20 rules on 200x60 evaluate in 24 us p95"（200x60 屏幕上 20 条规则，p95 评估耗时 24 us）。我移除的是维护成本和错误状态，而不是热循环。没有任何基准测试单独衡量 OSC 7501 本身的成本，因为它依附于终端本来就要做的解析。

## 不声明状态的智能体怎么办？

这是最有力的反对意见，也很公允。Claude Code 和 Codex 会声明状态。目录中的其他智能体只有在也发出 OSC 7501 后才会获得回合状态；在此之前一律没有。3 秒内既没有支持查询也没有声明时，该行会显示一个弱化的 `No status` 徽标，而不是假装空闲；如果 Paneflow 知道首个支持上报的版本，提示框中会注明该版本（Claude Code 2.1.295）。

对于这类智能体，`paneflow send --submit` 会报告 `no_signal`，`paneflow wait --idle` 会等到输出安静下来，而不是在回合结束时返回。

Claude Code 的[终端文档](https://code.claude.com/docs/en/terminal-config)提到了几个需要注意的地方：在 tmux 中，Claude Code 检查的是 tmux 是否支持，而不是您的终端；后台会话不会上报状态；`CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1` 会关闭上报。至于 Codex，我还不知道哪个版本会首次包含这两个 PR。

[Herdr](https://github.com/ogulcancelik/herdr) 在这一取舍上站在另一边，如今能为更多智能体显示状态。对于 Claude Code 和 Codex，其[集成文档](https://herdr.dev/docs/integrations/)称状态 "comes from Herdr's screen manifest detection"（来自 Herdr 的屏幕 manifest 检测），hook 则负责上报会话身份；对于 Pi、OpenCode 和其他少数几个智能体，它使用这些智能体的 hook 上报的状态。它的 [Claude manifest](https://raw.githubusercontent.com/ogulcancelik/herdr/HEAD/distribution/agent-detection/claude.toml) 做得很细致：基于屏幕区域、按优先级排列的规则，并用注释逐版本追踪 Claude Code 的加载动画字形。Mitchell 指出，该文件的历史 "shows ten changes in three months for just Claude Code"（仅 Claude Code 一项，三个月内就改了十次），并补充说这并不是在批评 Herdr。我同意。这是我选择不去承担的成本，而 `No status` 就是代价。

感谢 Mitchell Hashimoto 编写该规范，感谢 Ghostty 项目提供将状态交给 Paneflow 的 `libghostty-vt` 解析器，也感谢 Claude Code 和 Codex 团队发布这项支持。

如果您维护的 CLI 会在某些时候等待人工操作，请发出 OSC 7501。在 Paneflow 面板中运行以下命令，即可看到终端如何处理它，面板标题栏会显示 `blocked: permission · Apply?` 10 秒：

```sh
printf '\033]7501;state=blocked:kind=permission:app=deploy:msg=QXBwbHk/\033\\'; sleep 10
```