Claude Code 或 Codex 这类终端 AI agent 放进 tmux 后,画面会长时间停留在某个 pane 上。切到浏览器或编辑器,再回到终端时,常常要先反复看底部状态,确认 agent 是否已经结束,或者它正在等你输入权限。Mux Beacon 想解决的问题就在这:把 tmux 里 agent 产生的重要事件收集起来,推到 macOS 菜单栏的 inbox 里,让使用者不用一直盯着 pane。
名字里的 Mux 可以理解为 tmux 的简称,Beacon 则负责把信号送到菜单栏。它要做的不是替代终端,也不是替代 tmux 的状态栏,而是在你切走之后,仍然能用菜单栏这个系统级入口感知 agent 的事件。下面按实际开发思路走一遍:先理解 tmux 的输出模型,再设计数据和通信方式,然后搭一个最小的 SwiftUI 菜单栏应用,最后接入 Claude Code 和 Codex,并排查 PATH、跳转、去重这些落地问题。
1. 为什么 tmux 里的 agent 消息需要一个菜单栏收件箱
1.1 终端多路复用带来的“消息不可见”问题
tmux 是一个终端多路复用器。它允许你在一个终端窗口里维护多个 session,每个 session 里有多个 window,每个 window 里又可以切分成多个 pane。一个 pane 里跑 Claude Code,另一个 pane 里看日志,第三个 pane 里开一个交互式 shell,这在真实工作流中很常见。
这种工作流带来的代价是:agent 的输出只出现在它所在的 pane 里。如果这个 pane 不是当前前台 pane,消息就处于“不可见”状态。tmux 状态栏可以显示窗口活动,但它通常只告诉你“这个窗口有输出”,无法表达“agent 在等你确认权限”“任务已经执行完成”“命令报错了”这些语义。
如果只是在 IDE 和终端之间切换还好,一旦同时开着多个 terminal 窗口,或者切到其他桌面空间,消息丢失感会更强。微信、邮件等应用都有系统通知,终端 agent 却没有一条标准的推送链路。
1.2 菜单栏是 macOS 里适合放“全局收件箱”的位置
macOS 的菜单栏是少数可以长期常驻、不需要打开窗口就能看到状态的系统级 UI 区域。相比开着一个终端窗口,菜单栏图标不占用 Dock 和桌面空间,新消息到达时可以通过图标变化、列表项和未读状态提醒用户。
这也是 Mux Beacon 选择菜单栏做 inbox 的原因。用户不需要为了知道 agent 是否完成而切回终端,只需要低头看一眼菜单栏。点击图标后,下拉菜单里按时间倒序展示消息,每条消息都关联到具体的 tmux target。用户点一下,就可以跳回对应的会话和 pane。
这个交互模式和邮件客户端很像,只是邮件来源变成了“tmux 里的 agent 输出”。
1.3 inbox 需要表达哪些信息
一个菜单栏 inbox 不能只是简单把 pane 的最后几行文本搬过来,需要结构化。至少包含以下信息:
- agent 类型:这条消息来自 Claude Code 还是 Codex。
- tmux target:消息对应哪个 session、哪个 window、哪个 pane。
- 事件类型:需要用户输入、任务完成、发生错误、普通输出。
- 标题和摘要:人一眼能看懂的内容。
- 原始输出片段:用于排查误报。
- 时间:消息产生的时间。
- 已读/未读状态:用于菜单栏角标。
没有这些信息的时候,Mux Beacon 只是一个“终端输出阅读器”。有了这些字段,它才是一个真正的 inbox。
1.4 核心链路先想清楚
Mux Beacon 的数据流可以拆成四步:
- Agent 在 tmux 的某个 pane 里持续输出。
- 一个采集器定期读取该 pane 的尾部内容。
- 解析器从内容中识别出“需要关注的事件”。
- 事件进入本地 Store,菜单栏 UI 更新,并可选地发出系统通知。
用户点击消息后,再通过 tmux 命令切回对应的 pane。这个链路里最关键的不是 UI,而是“采集”和“解析”两层。这两层如果没做好,后面的菜单栏再漂亮也没有意义。
2. 环境准备:把 macOS、tmux、agent CLI 对齐
2.1 macOS 版本与 SwiftUI 菜单栏能力
Mux Beacon 的菜单栏界面适合用 SwiftUI 实现。macOS 13 引入了MenuBarExtra,可以快速创建一个菜单栏场景,而不需要自己管理NSStatusItem的生命周期。如果你还在使用更老的 macOS,可以先升级到 macOS 13 或更高版本,或者准备一个 Xcode 工程,并把 deployment target 设为 macOS 13。
具体版本号在写代码前要确认,因为MenuBarExtra的样式定义和可用 API 在不同 Xcode 版本里略有差异。下面代码以 macOS 13+ 的常见写法为例,如果你的项目部署目标更高,一般不需要改主体逻辑。
应用类型选择“App”,生命周期可以使用 SwiftUI App 协议,也可以使用 AppKit App Delegate。最小实现用 SwiftUI App 协议就够。
2.2 tmux 环境检查
让菜单栏应用读取 tmux pane 输出,前提是 macOS 机器上已经安装 tmux,并且当前用户有权限访问对应的 tmux server socket。先做一轮检查:
tmux -V tmux list-sessions第一行确认 tmux 版本,第二行确认有没有正在运行的 session。如果list-sessions报no server running on ...,说明 tmux server 还没有启动,需要先开启一个 session。
需要确定 tmux server socket 的位置。默认情况下,socket 文件位于/tmp/tmux-<uid>/default,其中<uid>是当前用户 ID。Mux Beacon 作为同一用户启动的 GUI 应用,理论上可以访问同一 socket,但前提是它运行时使用的用户和 tmux server 的用户一致。
排查 pane 目标时,可以用这条命令列出所有 pane:
tmux list-panes -a -F '#{session_name}:#{window_index}.#{pane_index} #{pane_current_command}'输出类似:
work:1.0 zsh work:1.1 claude codex:1.0 codex这个输出告诉你每个 pane 当前正在跑什么命令。claude和codex就是需要监控的目标。
2.3 Claude Code 与 Codex CLI 环境确认
Mux Beacon 不负责安装 Claude Code 或 Codex,它只负责读取 agent 在 tmux pane 里的输出。但安装和路径问题会直接影响排查,所以先确认 agent CLI 本身可用:
which claude claude --versionwhich codex codex --version如果你使用的是其他入口名,比如openai-codex,要以实际命令名为准。这里要注意一个典型问题:终端登录 shell 中能执行claude,不代表 GUI 应用里也能执行。GUI 应用的 PATH 通常不是登录 shell 的完整 PATH,/opt/homebrew/bin这类目录往往不在其中。
所以 Mux Beacon 的配置文件里应该支持显式指定 tmux、claude 或 codex 的绝对路径。不要依赖which去动态查找,否则在 GUI 环境里很容易出现“找不到命令”。
2.4 目录结构与最小工程
在 Xcode 里新建一个 App 工程后,建议按模块拆分职责,不要把采集、解析、UI 全部写进ContentView.swift。一个可复用的最小目录结构如下:
MuxBeacon/ MuxBeaconApp.swift Models/BeaconMessage.swift Stores/BeaconStore.swift Watchers/TmuxPaneWatcher.swift Watchers/AgentEventParser.swift Actions/TmuxSwitcher.swift这样的结构不复杂,但边界清楚:UI 只依赖 Store,Store 只接收已经解析好的事件,Watcher 不关心菜单栏表现。后面如果要换成 tmux hook 或 agent hook,只需要替换 Watcher 部分。
如果从零开始,建议先跑通“采集 + 菜单栏展示”这条主线,再加入 Claude Code 和 Codex 的解析规则。这样排错范围小,问题也更好定位。
3. 先设计数据模型与通信方式,不要在 UI 上急着动手
3.1 BeaconMessage 结构
菜单栏要显示一条消息,后台就必须有一个稳定结构。这里定义一个BeaconMessage模型,包含来源、agent 类型、tmux 位置、标题、正文和时间:
import Foundation enum AgentKind: String, Codable { case claudeCode case codex } struct BeaconMessage: Identifiable, Codable { var id = UUID() var agent: AgentKind var tmuxTarget: String var title: String var body: String var rawSnippet: String var createdAt: Date = Date() var isRead = false }字段说明:
tmuxTarget是形如work:1.0的字符串,用于在点击消息后定位到具体 pane。rawSnippet保留原始输出的一部分,方便调试解析问题。isRead决定菜单栏里是否显示未读角标。createdAt用于排序,也用于判断去重窗口。
3.2 capture-pane、tmux hooks、agent hooks 三种方案对比
采集 tmux pane 输出有三种常见思路,各有取舍。
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 定时 capture-pane 轮询 | 通用,不依赖 CLI 版本,实现简单 | 有延迟,需要去重,频繁读取会消耗资源 | 初期 Demo,兼容 Claude Code 和 Codex |
| tmux hook 事件转发 | 实时性更好,事件驱动 | 不同 tmux 版本支持情况不同,配置复杂 | 较新 tmux 版本,监控少量 pane |
| agent 自身 hook 或通知 | 事件结构化,准确度高 | 需要 agent CLI 支持,不同 agent 配置不同 | 生产环境,愿意逐 agent 适配 |
Mux Beacon 的最低可用版本推荐用第一种方案:定时capture-pane。它不需要修改 agent 本身的配置,也不需要依赖 tmux 的某个特定 hook,只要 tmux 能读到 pane 输出,就能工作。
如果 tmux 版本支持pane-output-changed这类 hook,可以先执行tmux list-hooks确认,再把事件转发到日志文件或 Unix socket。不过这个方式需要 GUI 应用监听事件文件,复杂度明显高于轮询,建议把它作为后续优化,而不是第一个版本的主线。
3.3 配置多个 watch 目标
Claude Code 和 Codex 可能同时跑在不同的 session 里。Mux Beacon 应该允许用户通过配置文件声明要监控哪些 pane。
下面是一个 JSON 配置示例:
{ "watches": [ { "name": "claude-main", "target": "work:1.0", "agent": "claude-code", "pollSeconds": 3 }, { "name": "codex-main", "target": "codex:1.0", "agent": "codex", "pollSeconds": 3 } ] }target必须和tmux list-panes -a输出的格式一致。pollSeconds表示采集间隔,默认 3 秒。间隔太小会增加 tmux server 的负担,太大则消息到达菜单栏会有明显延迟。
如果你的 tmux server 使用非默认 socket,配置里还需要增加socketName字段,并在运行 tmux 命令时传入-L参数。否则 GUI 应用可能读到空结果,问题表现是“菜单栏一直没消息”。
4. 用 SwiftUI 搭出最小可用的菜单栏应用
4.1 MenuBarExtra 入口
MenuBarExtra是 macOS 13 里最直接的菜单栏实现方式。它把一个 SwiftUI 视图放进系统菜单栏,点击图标后展开成一个菜单或面板。
import SwiftUI @main struct MuxBeaconApp: App { @StateObject private var store = BeaconStore() var body: some Scene { MenuBarExtra("Mux Beacon", systemImage: "tray.full") { BeaconMenuView() .environmentObject(store) } .menuBarExtraStyle(.menu) } }这里使用.menu样式,适合做下拉列表。如果后续需要展示更复杂的消息卡片、按钮和滚动区域,可以改用.window样式,但菜单形式的实现成本更低。
4.2 BeaconStore 与消息去重
Store 是菜单栏 UI 的数据源。它需要维护消息列表,并提供追加、标记已读、全部已读等方法。去重逻辑也应该放在这里,而不是 UI 层。
import Foundation import Combine final class BeaconStore: ObservableObject { @Published var messages: [BeaconMessage] = [] var unreadCount: Int { messages.filter { !$0.isRead }.count } func append(_ message: BeaconMessage) { if let last = messages.first, last.agent == message.agent, last.tmuxTarget == message.tmuxTarget, last.title == message.title, abs(last.createdAt.timeIntervalSinceNow) < 30 { return } messages.insert(message, at: 0) } func markRead(_ message: BeaconMessage) { guard let index = messages.firstIndex(where: { $0.id == message.id }) else { return } messages[index].isRead = true } func markAllRead() { for index in messages.indices { messages[index].isRead = true } } }去重条件里使用了“同 agent、同 tmux target、同标题、30 秒内”的组合键。这样做可以避免同一条 agent 输出在连续轮询中被重复插入。
这里有第一个常见坑:不要用pane_current_command作为唯一去重键,因为一个 pane 可能长时间运行同一个 agent,但期间会产生多条不同事件。去重键必须包含“事件标题”或“输出片段特征”。
4.3 tmux 输出采集
采集模块的核心是执行tmux capture-pane命令。这个命令会把 pane 的可见内容和部分历史内容打印到 stdout。Mux Beacon 只需要读取尾部 N 行,不需要读取全部历史。
import Foundation struct TmuxPaneWatcher { var tmuxPath: String var target: String func captureTail(lines: Int = 80) throws -> String { let process = Process() process.executableURL = URL(fileURLWithPath: tmuxPath) process.arguments = ["capture-pane", "-p", "-t", target, "-S", "-\(lines)"] let pipe = Pipe() process.standardOutput = pipe process.standardError = Pipe() try process.run() process.waitUntilExit() let data = pipe.fileHandleForReading.readDataToEndOfFile() return String(decoding: data, as: UTF8.self) } }-S -80的意思是“从当前可见区域向上回溯 80 行”,用来抓取 pane 尾部输出。注意tmuxPath不要填"tmux"这种可执行名,建议填绝对路径,例如/opt/homebrew/bin/tmux或/usr/local/bin/tmux,否则在 GUI 进程里可能找不到命令。
采集器负责给解析器提供“当前 pame 尾部文本”,它不负责判断这条文本是否是重要事件。判断逻辑放在AgentEventParser里。
4.4 菜单栏视图与消息展示
菜单栏视图按时间倒序展示 Store 里的消息。每条消息是按钮,点击后标记已读并跳转回对应 pane。
import SwiftUI struct BeaconMenuView: View { @EnvironmentObject private var store: BeaconStore var body: some View { if store.messages.isEmpty { Text("暂无 agent 消息") .padding(8) } else { ForEach(store.messages.prefix(10)) { message in Button { openMessage(message) } label: { VStack(alignment: .leading, spacing: 4) { Text(message.title) .font(.headline) Text(message.tmuxTarget) .font(.caption) .foregroundStyle(.secondary) Text(message.body) .font(.body) .lineLimit(2) } } Divider() } } Divider() if !store.messages.isEmpty { Button("全部标记已读") { store.markAllRead() } } Button("退出 Mux Beacon") { NSApplication.shared.terminate(nil) } } private func openMessage(_ message: BeaconMessage) { store.markRead(message) TmuxSwitcher.jump(to: message.tmuxTarget) } }菜单栏列表里只显示前 10 条,避免菜单过长。每条消息显示标题、tmux target 和正文摘要。用户点击后,openMessage会完成“标记已读 + 跳转”。
4.5 点击消息跳回 tmux pane
从菜单栏应用跳回 tmux 窗口,比想象中要麻烦。原因是 tmux 的 client 通常运行在某个终端 App 里,菜单栏应用本身并不是 tmux client,它不能直接调用switch-client把当前终端切到目标 session。
一个可行的方案是:先激活终端 App,再向终端发送对应的 tmux 命令。以 macOS 自带的 Terminal.app 为例:
osascript -e 'tell application "Terminal" to activate'然后通过tmux select-window和tmux select-pane切到目标 pane。但这条命令要由终端 App 里的 tmux client 执行,而不是由菜单栏应用直接执行。所以实际实现里,需要根据终端类型选择不同方案,例如:
- iTerm2 支持 URL scheme,可以通过
iterm2://相关方式唤起。 - Terminal.app 可以通过 AppleScript 模拟按键,但需要辅助功能权限。
- kitty、alacritty 等终端可能需要自定义快捷键或外部脚本。
在最小版本里,可以先只在菜单栏显示 tmux target,不实现完整的“点击跳回”。先跑通消息流,再补跳转能力。跳转涉及 macOS 权限,容易让整体排查复杂化。
注意:给菜单栏应用申请“辅助功能”权限时,要说明用途。不要把所有终端控制操作都塞进一个应用里,否则用户会担心权限范围过大。
4.6 运行验证
在 Xcode 里直接 Run,菜单栏会出现一个托盘图标。此时没有消息,下拉菜单显示“暂无 agent 消息”。
为了验证采集链路,可以先手工创建一个 tmux session,并往 pane 里发送模拟事件:
tmux new-session -d -s demo tmux send-keys -t demo:1.0 'echo "Continue? [Y/n]"'然后在 Mux Beacon 配置里把 watch target 指向demo:1.0,等一个轮询周期后,菜单栏应当出现一条“需要用户输入”类型的消息。如果出现,说明采集、解析、Store、UI 这条链路已经通了。
5. 接入 Claude Code 和 Codex:哪些输出值得进 inbox
5.1 把 agent 输出分成四类信号
Claude Code 和 Codex 的交互界面并不完全相同,但它们产生的事件可以归成几类:
| 事件类型 | 典型信号 | 用户应该怎么做 |
|---|---|---|
| 等待输入 | 出现[Y/n]、Continue?、权限确认提示 | 切回终端输入确认 |
| 任务完成 | 出现finished、complete、All done等结束语 | 查看结果 |
| 错误或退出 | 出现error、failed、非零退出码 | 查看日志并修复 |
| 普通输出 | 中间日志、进度信息 | 不需要打扰用户 |
Mux Beacon 真正需要进 inbox 的是前三类。普通输出如果也进 inbox,菜单栏很快会被刷屏,失去提示价值。