跳到内容

Paneflow 中的智能体状态:从屏幕规则到 OSC 7501

自 Paneflow 0.17.7 起,侧边栏只从一个地方获取智能体的回合状态:智能体写入自身终端的状态。因权限请求而暂停的智能体会写入如下形式的序列(示例来自规范):

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 和 #54114 中合并了支持的 Codex。不声明任何状态的智能体不会获得回合状态,这是有意为之。

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

OSC 7501 是什么

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

Mitchell Hashimoto 于 2026 年 10 月 6 日发布了 Program Status Protocol,称其为 "a completely generic, terminal-native specification"(一种完全通用、终端原生的规范),源自他在 Superlogical 和 Ghostty 上的工作(他的文章)。程序写入 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),其终端引擎是 libghostty-vt。当 Claude Code 询问终端是否支持该协议时,引擎回复:

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 将解析后的状态交给一个回调,宿主进程的事件循环将其转换为一条记录:

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

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

工作进程负责归约。 paneflow-serve 位于宿主进程与所有客户端之间:桌面端、paneflow CLI、MCP 桥接。它先检查会话运行的是已知智能体,再映射声明。以下就是完整的状态机(state.rs):

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加载动画
blockedNeed you,并附带包含智能体消息的通知
errorError,并附带通知
doneDone,直到您看过为止,并附带通知
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)。
  2. PTY 活动。 6 月,没有 hook 的智能体获得了一种启发式方法:一阵输出突发会将智能体提升为 working,3 秒静默会将其降级,按键回显会被过滤掉。一周后我移除了它,因为它 "produced false spinners (a dev server streaming under a bare-shell thread, TUI redraws)"(产生了虚假的加载动画:纯 shell 线程下持续输出的开发服务器、TUI 重绘)(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)。
  5. 持久化归约器。 9 月,会话一旦锁定(latch),hook 就成为权威来源;一个由屏幕变化重新续期的 5 分钟租约,为丢失的 Stop 设定了上限;视口扫描则用于查找菜单。10 月,终端响铃也作为需要关注的信号加入。
  6. 声明式屏幕规则。 10 月 2 日,每个智能体都有了一个 screen.toml,包含按优先级排列的正则规则,并配有签名的远程目录和一个由捕获屏幕组成的语料库(c1d48e580)。它随 0.17.6 发布。

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

10 月 7 日,我重新固定了 libghostty 版本,以获取其 OSC 7501 API。3 天后,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/ 目录描述了 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 秒(基准测试摘要):

进程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)。

重要: 以 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 的终端文档提到了几个需要注意的地方:在 tmux 中,Claude Code 检查的是 tmux 是否支持,而不是您的终端;后台会话不会上报状态;CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 会关闭上报。至于 Codex,我还不知道哪个版本会首次包含这两个 PR。

Herdr 在这一取舍上站在另一边,如今能为更多智能体显示状态。对于 Claude Code 和 Codex,其集成文档称状态 "comes from Herdr's screen manifest detection"(来自 Herdr 的屏幕 manifest 检测),hook 则负责上报会话身份;对于 Pi、OpenCode 和其他少数几个智能体,它使用这些智能体的 hook 上报的状态。它的 Claude manifest 做得很细致:基于屏幕区域、按优先级排列的规则,并用注释逐版本追踪 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 秒:

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

继续此工作流

所有文章

在 Paneflow 中启动你的下一个智能体。