尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Mux Beacon:将tmux中AI Agent事件推送至macOS菜单栏

Mux Beacon:将tmux中AI Agent事件推送至macOS菜单栏 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 目标时可以用这条命令列出所有 panetmux 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 只依赖 StoreStore 只接收已经解析好的事件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 和 Codextmux hook 事件转发实时性更好事件驱动不同 tmux 版本支持情况不同配置复杂较新 tmux 版本监控少量 paneagent 自身 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哪些输出值得进 inbox5.1 把 agent 输出分成四类信号Claude Code 和 Codex 的交互界面并不完全相同但它们产生的事件可以归成几类事件类型典型信号用户应该怎么做等待输入出现[Y/n]、Continue?、权限确认提示切回终端输入确认任务完成出现finished、complete、All done等结束语查看结果错误或退出出现error、failed、非零退出码查看日志并修复普通输出中间日志、进度信息不需要打扰用户Mux Beacon 真正需要进 inbox 的是前三类。普通输出如果也进 inbox菜单栏很快会被刷屏失去提示价值。5.2 用输出扫描实现事件
返回列表