我为什么没做另一套 AI 聊天界面,而是给 Codex / Claude Code 做了个本地控制台
只开一个 coding agent 的时候,一个终端标签就是最好的界面。
开到五个、十个,散落在几个项目里,问题就变了。你不再是在跟一个 Agent 对话,而是在管理一组处于不同状态的工作:一个还在跑,一个在等你确认,一个已经失败,还有一个十分钟前就跑完了,但那个标签页被压在最后面。
我想解决的是一个很具体的问题:**在不改变 Codex 和 Claude Code 使用方式的前提下,拿到一个跨会话视图。 **

边界:不重建 provider 的 UI
Agent Console 没有做统一聊天前端。它扫描 provider 已持久化的会话,按 workspace 分组,归一化成 working / waiting / idle / failed 四种状态,需要进入时调用 provider 自己的 resume 流程。进去之后,你面对的仍然是原生的 Codex 或 Claude Code 终端界面。
这个边界很重要。统一前端看起来整齐,但它会落后于 provider 的新能力,并且把 resume 行为、快捷键、审批流、渲染细节都变成第二套兼容层。而原生界面之上那一层 ------ 发现、状态、提醒、搜索、导航 ------ 小到可以做对。
Rust 这边刻意保守:ratatui 渲染,portable-pty 管子进程,vt100 做保留 pane 的终端模拟,rusqlite 存状态,完全没有 async runtime。整个程序就是一个同步循环,加上若干持有 PTY 的线程。
做下来学到的四件事
1. 发现会话 = 解析别人的私有格式
两家都没有「列出我的会话」这种 API,但都会持久化 transcript。所以发现会话意味着去读 ~/.codex/sessions/**/rollout-*.jsonl 和 ~/.claude/projects/**/<uuid>.jsonl,从一堆从没打算当公开接口的记录里反推结构。
当观测通道可以,当契约就很糟 ------ provider 每次发版都可能挪字段。应对办法是:除了一张表,任何地方都不再按 provider 分支。
rust
pub struct ProviderAdapter {
pub kind: AgentKind,
/// 该文件是否属于这个 provider 的会话 transcript
pub accepts: fn(&Path) -> bool,
/// 解析一个 transcript。Ok(None) 表示「不是可用的会话」
pub parse: fn(&Path) -> io::Result<Option<Session>>,
/// 可选的 provider 专属增强
pub enrich: Option<fn(&Path, &mut [Session])>,
}
发现逻辑遍历这张表,而不是在五个地方 match 枚举:
rust
for adapter in providers::enabled() {
let root = paths.root(adapter.kind);
let files = provider_files(root, adapter.accepts);
let mut parsed = parse_cached_files(files, adapter, cache);
if let Some(enrich) = adapter.enrich {
enrich(root, &mut parsed);
}
sessions.extend(parsed);
}
用裸 fn 指针而不是 Box<dyn Fn>,是为了让这张表能是 const ------ 加一个 provider 就是加一条表项,零分配。AGENT_CONSOLE_PROVIDERS=codex 可以在运行时裁剪同一张表,这在两家都开始自带多会话视图、某一侧可能变成重复劳动的当下挺有用。
我最在意的失败模式是:这个变量打错字,绝不能变成一个静悄悄的空面板。所以无法识别的值会记录原因并保持全部 provider 启用。
2. 持有 PTY 就要决定谁跟 TUI 一起死
最直觉的设计是从 TUI 进程里 spawn agent。那样关掉面板就杀掉所有 agent ------ 而这恰恰是让人宁愿开十二个终端标签的原因。
所以托管的 PTY 放在一个独立的常驻 daemon 里。关闭或崩溃 TUI 只是 detach,新的 TUI 重连并回放一段有界的 tail。不依赖 tmux,也不强制 git worktree ------ 会话就在原地跑。
原地跑意味着两个面板可能同时 resume 同一个 provider 会话、写坏同一份 transcript,所以每个 provider session ID 都由跨进程 lease 保护,带持有者信息、安全拒绝和显式强制接管。这块恰恰是 Rust 帮助最小的地方:类型系统对「同一台机器上的第二个进程」无话可说,靠的是进程卫生。
3. 让模型做摘要,不做判断
working / waiting / failed 来自 provider 的 hook 事件和进程状态,用固定优先级确定性归约。模型只负责把长对话压成一眼能扫的任务摘要,它走会话自己的 provider、在 coding conversation 之外运行,并且能用 AGENT_CONSOLE_SUMMARIZER=off 完全关掉。
还有一个只有真跑起来才会暴露的细节:alert 是「运行期观测到的状态跃迁」,不是「状态」。面板启动时就已经在 waiting 的会话,不算新消息。这个区分搞错,提醒列表一天之内就会变成噪音。
4. 时间都花在终端模拟上
pty.rs 有 6,600 行 ------ 是最大的模块,比 discovery、状态机和整个 UI 加起来还大。每个 pane 的保留回滚、给需要的 provider 做鼠标上报、alternate screen 处理、选择与剪贴板、resize、重连回放,每一项看起来都很小,实际都不小。
两个具体教训。托管的 Codex 会话用 --no-alt-screen 启动,这样 workspace pane 在 Codex 的任何状态下都能保留并滚动 transcript。以及 ------ 在 Codex 和 Claude Code 各自占掉自己的键之后,真正空闲的 Ctrl 组合只剩三个 :Ctrl-\、Ctrl-^、Ctrl-Q,其余全部原样转发给子进程。
目前状态
v0.0.11,支持 Codex 和 Claude Code,提供 macOS / Linux / Windows 包,macOS 二进制已签名并通过 notarization。
sh
cargo install agent-console
agent-console doctor # 检查 provider、hook、剪贴板、daemon、权限
agent-console
MIT OR Apache-2.0 双授权。源码:github.com/buhuipao/ag...
还很早期。我关心的问题比「再加十个 provider 图标」更基础:状态是否可信、提醒是否真的减少了切换、Shell 是否留在正确的 workspace、原生 resume 是否足够顺滑。
如果你每天同时跑好几个 coding agent ------ 跨会话控制层应该给你看什么,又绝不该接管什么?