This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
A macOS status-bar app (Swift + AppKit, SPM, no Xcode project) that observes the locally-running Claude Code CLI sessions and surfaces their state in the menu bar: aggregate icon, per-session list, lifetime token usage by model, current 5-hour rolling window, notifications when a session enters waiting / completes / has been waiting too long, an in-app permission-prompt panel, and a preferences window (icon colors, notification toggle, reminder interval, launch-at-login).
SwiftPM, macOS 13+. No external dependencies.
swift build # debug build (.build/debug/ClaudeStatusBar)
swift run # build + launch the menu bar app
swift test # run all XCTest targets
swift test --filter ClaudeStatusBarTests.SessionTests # one suite
swift test --filter ClaudeStatusBarTests.SessionTests/testDecodesBusySession # one testNotifications use UNUserNotificationCenter only when launched from a bundle with an Info.plist (i.e. packaged as .app). When run via swift run, WaitingNotifier falls back to osascript display notification.
AppDelegate 是 wiring 层 —— 实例化各组件、把 Combine 流串起来、跑 5s reminder timer,业务行为分散在三个协调器里:MenuController(菜单 + 状态栏图标 + 热键)、NotificationOrchestrator(detector 三件套 + reminder + 通知派发)、TerminalActivator(sessionId/pid → 终端 app / Finder)。菜单显示用到的 jsonl 派生数据由两组缓存层 store 喂(SessionContextStore / SessionDetailsStore)—— 主线程构造菜单时永远不触发 jsonl I/O。所有浮窗(权限 / AskUserQuestion / 未来扩展)共享一条 FloatingPanelStack 垂直队列。
~/.claude/sessions/*.json ──► SessionWatcher ──► SessionStore.@Published sessions ──┐
(FSEvents + 30s safety timer) │
├──► AppDelegate (wiring + 5 Combine sinks)
│ ├─► MenuController ─► MenuBuilder ─► NSMenu (sessions, lifetime, 5h window)
~/.claude/projects/**/*.jsonl ──► UsageTracker ──► @Published lifetimeByModel ───────┤ │ └─► StatusIconAnimator
(30s timer) └► @Published currentWindow │ ├─► NotificationOrchestrator ─► WaitingNotifier (gated by SettingsStore.notificationsEnabled)
+ LiveUsageAggregator (lifetime sums) │ ├─► TerminalActivator (banner click / row click / panel "跳回终端")
+ RollingWindowAggregator (5h window, ISO ts) │ └─► SettingsWindowController (preferences UI)
──► SessionContextStore (30s) ─► @Published contextByPid ─► MenuBuilder.Snapshot.contextByPid
──► SessionDetailsStore (30s) ─► @Published detailsByPid ─► MenuBuilder.Snapshot.detailsByPid
──► RecentConversationsStore (30s) ─► @Published recentResumeByPid ─► MenuBuilder.Snapshot.recentResumeByPid
│
SessionStore.sessions ──► (NotificationOrchestrator 内部持有,详见下方)
├─► WaitingTransitionDetector (idle/busy → waiting edge → notify)
├─► TaskCompletionDetector (busy → idle edge → "任务完成"; first call absorbs baseline)
├─► PermissionPromptSessionExitDetector ─► permissionStore.abandonAll(sessionId:) (waiting → 非 waiting edge → close all panels for that sessionId)
└─► WaitingReminderTracker (5s timer 从 AppDelegate 调 orchestrator.tickReminder; re-fires up to maxReminders × interval from SettingsStore)
SettingsStore (UserDefaults: workingColor, attentionColor, notificationsEnabled, reminderInterval, showCurrentWindow, showLifetimeUsage) ──► AppDelegate.objectWillChange sink ──► MenuController.refresh() + NotificationOrchestrator.rebuildReminderTracker(...)
claude (PermissionRequest hook) ─► ClaudeStatusBarHook ─► Unix socket
↓
PermissionPromptListener ─► PermissionPromptStore ──┬─► PermissionPromptPanelManager ─► PermissionPromptPanel (NSPanel)
│ (registers Ctrl+Shift+Y / Ctrl+Shift+N
│ global hotkeys while ≥1 panel is visible;
│ resolves the latest panel)
└◄────────── allow / deny ◄──────────────────────────────────────────
The permission-prompt pipeline is a separate SPM target (ClaudeStatusBarHook executable + ClaudeStatusBarHookCore library, with SocketClient and HookProcessor). The hook is spawned by claude per PermissionRequest event and short-circuits to the main app over a Unix domain socket at ~/Library/Application Support/ClaudeStatusBar/prompt.sock. The hook races against Claude Code's terminal prompt (Promise.race inside the CLI's permission engine) — first response wins. The CLI's "abort the loser" mechanism is to close the loser's stdin, which is unobservable to our helper: by the time the race resolves, the helper is past FileHandle.standardInput.readToEnd() (which auto-closes fd 0 on the helper side too) and is blocking on the socket read; nothing inside the helper polls stdin. So the helper neither dies nor returns when the terminal wins — it just keeps blocking until the app side replies or closes the fd. The "panel dismisses when the user answered in the terminal" behaviour therefore comes from PermissionPromptSessionExitDetector on the app side (see the Session-exit dismissal convention below), not from a helper-disconnect signal. If the helper exits silently before ever talking to the app (no socket, app not running), the terminal prompt simply takes over. The UI is a non-activating floating NSPanel (top-right), not a UNUserNotificationCenter notification — chosen because macOS folds multi-action notifications under an "Options" button, defeating single-click. See docs/permission-prompt.md for the user-facing setup.
Key conventions to keep when extending:
- AppDelegate 是 wiring 层,业务逻辑搬到协调器里。 想加一条新的「sessions 变了要做的事」,改
NotificationOrchestrator或新写一个 orchestrator;想改菜单结构,改MenuBuilder(纯静态)+ 必要时MenuController(状态栏 owner)。AppDelegate 上只该有「持有 + 把数据流接起来 + 必须的 @objc selector」。新功能不要直接往 AppDelegate 上塞 sink 闭包,会把分层退回 God Object 形态。 - MenuBuilder 是纯静态的菜单构造器。 接受
Snapshot(sessions / lifetime / window / contextByPid / detailsByPid / now)+Actions(closure + selector)+ settings + relativeFormatter + menuDelegate,返回新NSMenu。不持有状态、不订阅任何东西 —— 跨刷新需要保留的可变状态(statusItem / iconAnimator / 热键 / menuIsShowing)归MenuController。菜单构造路径不触发任何 jsonl I/O:detail行从detailsByPid缓存读,contextByPid同理,缓存由后台 store 30s 刷。单测直接构造 fixture 调MenuBuilder.build,不需要起 AppDelegate / NSApp。 - NotificationOrchestrator 是 detector + reminder + 通知派发的唯一聚合点。 关键不变量(详见类顶部注释):detector 调用顺序 = transition → completion → exit;exit 触发的
abandonAll必须在 transition 通知派发之前;isNotificationsEnabled()只 gate 派发,不 gate detector tick(否则关掉再开会把已有 waiting 的 session 当 baseline 漏报)。Orchestrator 通过WaitingNotifying/PermissionPromptGating两个最小 protocol 注入依赖,单测用内存 stub 即可。 - Pure-static aggregators.
LiveUsageAggregator,SessionDetailsReader,SessionContextReader,RecentConversationsReader,RollingWindowAggregator,SessionWatcher.readSessions(from:)are stateless functions that take a URL/Data and return decoded values. Tests construct a temp directory, write fixtures, and call the static method directly. Keep new file-format readers in this shape. - Stores cache reader output for the menu hot path.
SessionContextStore缓存recentPrompt + lastTool,SessionDetailsStore缓存model + usage,RecentConversationsStore缓存 fresh session 行的「恢复上次会话」候选([RecentConversation],reader 调用时 excluding 当前 sessionId)。三者形态完全平行:30s timer 全量刷,updateSessions(_:)由 AppDelegate 在SessionStore.$sessionssink 上推入(新增 pid 立即扫,删除 pid 立即清)。scanAndPublish走 utility workQueue,只在 main 上写@Published。MenuBuilder永远从这几份字典读,绝不直接调 reader —— 主线程同步读几十 MB jsonl 会卡菜单弹出。新加 jsonl 派生字段时按这个模式新建 store,不要让 builder 触发 I/O。 - Liveness filter.
SessionWatcher.readSessionsdrops any session whose pid is dead (kill(pid, 0)viaProcessLiveness.isAlive). Stale.jsonfiles left behind by crashed CLIs are silently ignored — don't add a delete step, that's the CLI's job. - Edge-triggered notifications.
WaitingTransitionDetectorandTaskCompletionDetectorare bothmutating structs that remember the previous pid set (waiting / busy respectively) so we only notify on the transition, not every refresh tick. Keep them stateful — making them pure would re-fire on every scan.TaskCompletionDetectoradditionally absorbs its first call as a baseline so app start doesn't fire spurious "任务完成" for sessions that were already idle. - Session-exit dismissal of permission panels.
PermissionPromptSessionExitDetectoris a fourthmutating structdetector but it does not feedWaitingNotifier. It tracks the previous waiting-sessionId set; on each tick it returns the sessionIds that left the set (status moved to busy/idle, or the session disappeared entirely) andNotificationOrchestrator.sessionsDidChangecallspermissionStore.abandonAll(sessionId:)for each. This is the canonical "user answered in the terminal" signal — the helper-disconnect EOF path documented under Resolved signal below does not fire on a normal terminal-race-win (the CLI doesn't kill the helper, see the architecture paragraph above), so without this detector panels would just sit until their 5-min timeout. Like the other detectors it absorbs its first call as a baseline.abandonAll(sessionId:)skips entries whosesessionIdisnil(rare hook payloads with no sessionId fall back to the 5-min timeout — degraded but not worse than before). - Reminder tracker is timer-driven.
WaitingReminderTracker.tick(sessions:now:)由NotificationOrchestrator.tickReminder包装,5s 间隔的DispatchSourceTimer在 AppDelegate 起、每次回调都调进 orchestrator。It re-fires the waiting notification up toconfig.maxReminderstimes, withinitialDelayandintervaltaken fromSettingsStore.reminderInterval(nil = disabled, in which casemaxReminders = 0andtickis a no-op). When the user changes the interval in preferences, the tracker is rebuilt viaorchestrator.rebuildReminderTracker(interval:)— losing in-flight per-pid state is intentional. - Notifications gated by settings + active panels. All
WaitingNotifierposts (transition, reminder, completion) are gated bySettingsStore.notificationsEnabledinsideNotificationOrchestrator(via theisNotificationsEnabledclosure injected at init). On top of that, waiting notifications (transition + reminder) are also suppressed for any session whose sessionId is inpermissionStore.pendingSessionIds()— the floating panel already owns that user-attention event, so a system banner would just double up. Completion notifications don't get this filter (busy → idle isn't a permission state). Detectors still run unconditionally so their internal state stays consistent across toggles; only the post is suppressed. - cwd → projects directory encoding.
SessionDetailsReader.encodeProjectPathreplaces every non-alphanumeric character with-. This must match the encoding the Claude Code CLI uses when writing under~/.claude/projects/. If lookup starts failing, that's the first thing to verify. - Context-window table.
SessionDetails.contextWindow(forModel:)is the source of truth formodel → window size. When a new model class ships, edit only this method.usageRatiodeliberately doesn't cap at 1.0 — values >100% are the signal that the table is stale. - AppKit isolation, with documented exceptions. Most of
Services/and all ofModels/import onlyFoundation/Combine/Darwin/CoreServices/UserNotifications/ServiceManagement, and the test target relies on that. The known exceptions (each justified by what they wrap) are:SettingsStoreimportsAppKitforNSColor;GlobalHotkeyimportsCocoa+Carbon.HIToolboxforRegisterEventHotKey;PermissionPromptPanelManagerimportsCocoa+Carbon.HIToolboxfor window placement and key constants;TerminalActivatorimportsCocoaforNSRunningApplication/NSWorkspace/NSSound(整个职责就是激活 GUI app)。Services/NotificationOrchestrator故意只 import Foundation —— 单测装配时不应引入 AppKit。Don't add new AppKit imports toServices/without a similarly hard reason. - Single UN delegate.
NotificationDispatcheris the onlyUNUserNotificationCenterDelegate.WaitingNotifieris post-only. Permission prompts go throughPermissionPromptPanelManager+PermissionPromptPanel(NSPanel) and don't touch UN at all. - Resolved signal.
PermissionPromptStoreexposes bothincoming(new request) andresolved(any reason an entry leaves: explicit allow / always-allow / deny, abandon via panel ✕, abandonAll via the session-exit detector, timeout, or helper-disconnect). The panel manager subscribes to both —incomingto spawn a panel,resolvedto dismiss whatever panel was showing for that id. The terminal-answered-the-prompt path goes through the session-exit detector, not the helper-disconnect path (see Session-exit dismissal above):session.statusleaveswaiting→ detector emits the sessionId →permissionStore.abandonAll(sessionId:)→ each entry's reply is invoked withnilandresolvedfires per id. The helper-disconnect path (PermissionPromptListenerinstalls aDispatchSource.makeReadSourceon the accepted client FD; on EOF callsstore.resolveDeny(message: "Settled by terminal prompt")) is kept as a defense-in-depth fallback for actual helper death — user kills the parent terminal, helper crashes, etc. — and is intentionally a no-op during a normal terminal-race-win because the CLI does not kill the helper there. - Abandon path (✕ on panel).
Store.Replyis(Decision?) -> Void; passingnilis the abandon signal.Store.abandon(id:)invokesreply(nil)and firesresolved. The listener checks for nil and closes the client fd without writing any response — the helper'sSocketClient.requestResponsethen sees EOF on read, returns nil,HookProcessorreturns nil, the helper exits 0 with no stdout, and the CLI's terminal prompt wins the race. Intent: ✕ means "I'll answer in the terminal", not "deny". Don't change ✕ to fireresolveDeny; that would silently reject the tool call. - Panel-internal
Outcomeis decoupled from wireBehavior.PermissionPromptPanel.Outcome(.selected(PromptOption.Action) / .abandon) is panel-only.PermissionPromptDecision.Behavior(allow / deny) is the app→helper wire enum.abandon故意不进 wire enum —— ✕ 关闭浮窗时不写 socket 让 helper 静默 exit,这不是一种 decision。Manager translatesOutcome→ store calls explicitly:.selected(.decision(...))走resolveAllow/resolveDeny,.selected(.abandon)与.abandon都走store.abandon。 - Helper / app duplication of wire types.
PermissionPromptRequest/Decisionlive inClaudeStatusBar(SwiftCodable). The hook helper does not import them — it round-trips the same JSON viaJSONSerializationdictionaries inHookProcessor. If the wire format changes, update both sides; there is no shared module by design (kept the SPM tree flat). Wire schema:Request 含id / toolName / input / cwd? / sessionId? / kind? / permissionSuggestions?;Decision 含id / behavior(allow|deny) / updatedInput? / message? / updatedPermissions?。 - Hook output schema is
PermissionRequest-specific. The CLI accepts{hookSpecificOutput: {hookEventName: "PermissionRequest", decision: {behavior: "allow"|"deny", updatedInput?, updatedPermissions?, message?}}}— notedecision: {behavior}, not thepermissionDecision: "allow"string used byPreToolUse. The two events have separate Zod schemas inside the CLI; do not copy fields between them.updatedPermissions是一个PermissionUpdate[]数组(addRules / replaceRules / removeRules / setMode / addDirectories / removeDirectories),CLI 用setToolPermissionContext把它们应用到当前会话。helper 把 socket response 的updatedPermissions原样转发,不再 hand-roll session rule —— rule 来源由 app 端PromptOptionsBuilder决定(permission_suggestions透传 / ExitPlanMode setMode 推导)。Scope 在 builder 里固定"session",与 CLI 终端的「Yes, and don't ask again this session」对齐;绝不写"userSettings"/"projectSettings",那相当于擅自改用户的settings.json。 - Permission-panel hotkeys are scoped to panel visibility.
PermissionPromptPanelManagerregisters Ctrl+Shift+Y / Ctrl+Shift+N viaGlobalHotkeyonly while ≥1 panel is visible, and unregisters when the last one resolves. The hotkey resolves the most recent panel (entries.last), matching what "the latest 气泡" means visually. Don't make these hotkeys always-on — they'd silently swallow keystrokes the rest of the time. Y/N 实际触发的是「最新浮窗里 role=primary / role=destructive 的那个按钮」,所以即使 ExitPlanMode 浮窗按钮文案完全不同,Y 仍然语义合理("默认正向选项")、N 仍然语义合理("退出 / 让出")。secondary 按钮(「一直允许」/「同意,手动审批」)没有全局热键 — 是个"记住决策"的动作,需要用户看着面板再点。 - Panel buttons are driven by
PromptOptionsBuilder, not hardcoded in the panel.Models/PermissionPromptOption.swift是按钮唯一来源:输入PermissionPromptRequest(含 hook 透传的permissionSuggestions),输出[PromptOption],每个 option 携带 label / role / action(.decision(behavior, updatedPermissions, message)或.abandon)。三个分支:ExitPlanMode 专属 3 按钮、默认+非空 suggestions 3 按钮、默认+空/nil suggestions 2 按钮。PermissionPromptPanel只负责按 role 上 keyEquivalent + Tab nextKeyView,不持有按钮逻辑。新场景(比如未来某个工具需要不同选项)只改 builder,不改 panel/manager/store。ExitPlanMode 浮窗的「提交反馈」按钮是这条约定的唯一例外:option.action 仍是静态的.abandon(由 builder 决定),但PermissionPromptPanel.resolveAction(for:)在按钮被触发的瞬间读feedbackTextView,若 textarea 非空就把 action 改写成.decision(.deny, nil, message: <textarea 文本>)。鼠标点按钮、裸 N 键、⌃⇧N 全局热键三条路径都经 resolveAction,行为一致。✕ 关闭按钮(windowShouldClose)不经 resolveAction,始终发.abandon,语义是「我没决定」。 Behavior.allowAlways已从 wire 协议删除。 App→helper 现在只发behavior: allow|deny+ 可选的updatedPermissions: [JSONValue](原样转发自 hook 的permission_suggestions或PromptOptionsBuilder的setMode推导)。helper 端不再 hand-roll session rule,直接把updatedPermissions透传到hookSpecificOutput.decision。这意味着:老版 CLI(不发permission_suggestions的)看不到「一直允许」按钮 —— 这是有意为之的不兼容,与「不替用户猜规则」的设计一致。- 「Tell Claude what to change」= abandon, 不要改成 deny+默认消息。 ExitPlanMode 浮窗的「回终端反馈」按钮 action=abandon,关掉浮窗让 CLI 终端 prompt 接管,用户在终端输入反馈。如果改成
.decision(behavior:.deny, message:"..."),终端 prompt 不会再出现,用户没机会输入具体反馈,模型只收到一条死的拒绝理由。in-panel 文本输入框已在后续 PR 落地(详见上一段 ExitPlanMode 浮窗的「提交反馈」按钮说明):textarea 非空时按钮的 wire action 自动改写成 deny+message,空时仍走 abandon 让 CLI 终端 prompt 接管,语义跟「让用户在终端打反馈」一致,但少切一次焦点。 - All floating panels share
FloatingPanelStack. 权限浮窗、AskUserQuestion 浮窗(以及未来再加的浮窗)都注册到同一个FloatingPanelStack实例 —— stack 只管几何排版(右上向下、x 右对齐、y 累加 + stackGap),panel 实例和 Outcome / abandon 语义仍归各自的 manager。每个 manager 在present(_:)调stack.register(panel, owner:),在dismiss(_:)调stack.unregister(panel)—— stack 内部自动 relayout。不要再让 manager 自己手算 y 起点(以前 askq 写死 -80px 偏移,permission 浮窗多起来会撞上去),所有 panel 共用一条队列才能保证「等待你处理」类目按出现顺序排列。 - Some tool requests get a different panel.
PermissionPromptPanelManager.toolsRoutedAwayFromPanel(currently["AskUserQuestion"]) is the canonical list — the regular permission manager'spresent(_:)early-returns for these. In parallel,AskUserQuestionPanelManagersubscribes to the sameincomingstream, filters bytoolName == "AskUserQuestion", and spawns a separate floating panel (AskUserQuestionPanel) showing the full question text + all options + a "跳回终端答" button. Rationale: AskUserQuestion is a structured multi-choice prompt — "allow / deny" buttons are meaningless, the CLI doesn't expose an external answer channel, but the user benefits from seeing the full question / options without切回终端. The panel's ✕ and "跳回终端答" both callstore.abandon(id:), letting the CLI's terminalselectprompt win the race. To add another tool that needs its own panel, write a new manager — don't growtoolsRoutedAwayFromPanelinto an N-way switch. - No SIGPIPE on accepted sockets.
PermissionPromptListener.acceptLoopsetsSO_NOSIGPIPEon every accepted client FD. Without it, writing the response back after the helper has already disconnected (helper crashed, parent terminal got killed, or the 5-min timeout fired right as the helper exited) raises SIGPIPE process-wide and tears down the app. We always checkwrite's return value, so EPIPE is fine — we just need the kernel to not signal us. (The common "user answered in terminal" case doesn't reachwriteat all —abandonAllrepliesniland the listener closes the fd without writing.) - Rolling 5-hour window.
RollingWindowAggregator.currentWindow(now:projectsRoot:)reverse-scans every*.jsonlunder~/.claude/projects/for assistant entries with an ISO-8601timestampwithinnow − 5h. The window'sstartedAtis the earliest qualifying entry,resetsAt = startedAt + 5h, andRollingWindow.remaining(now:)powers the "重置 Xh Ym 后" menu line. Returnsnilwhen there's no recent activity (rendered as "(无活动)"). Pure-static like the other aggregators.
The session/project files under ~/.claude/ are written by the Claude Code CLI; this app never writes to them.
~/.claude/sessions/{pid}.json— one file per live CLI session. Schema inModels/Session.swift(pid, sessionId, cwd, version, kind, entrypoint, startedAt, updatedAt, status, waitingFor?).startedAt/updatedAtare milliseconds since epoch. Files are written non-atomically; partial-read JSON decode failures are expected and skipped (FSEvents will fire again).~/.claude/projects/{encoded-cwd}/{sessionId}.jsonl(or.../{sessionId}/*.jsonl) — append-only event log. Lines withtype == "assistant"carrymessage.modelandmessage.usage.{input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens}.LiveUsageAggregatorsums across all of them;SessionDetailsReaderreverse-scans for the most recent one.
App-owned, writable: ~/Library/Application Support/ClaudeStatusBar/prompt.sock — Unix domain socket the listener binds to (perms 0700 on the directory, 0600 on the socket). Helper subprocesses dial in here per tools/call. Both ends use newline-delimited JSON; one connection = one request + one reply, then close.
OctopusIconrasterises a hard-coded 12×12 grid into anNSImage. Colour is parameterised;isTemplate=truefor idle (AppKit auto-inverts for dark/light menu bars),isTemplate=falsefor the "working" / "needs attention" states so the colour survives. The two non-idle colours come fromSettingsStore.workingColor/attentionColor— user-customisable in the Appearance preferences pane, defaulting to orange / system yellow.SettingsWindowControlleris a 3-tab window (通用 / 外观 / 关于) opened via the menu's "偏好设置..." item (⌘,). The General tab toggles notifications, picks a reminder interval, and surfaces launch-at-login throughLoginItemController(hidden when running unbundled, sinceSMAppService.mainApprequires a code-signed bundle). Appearance edits the icon colors. About is static metadata.- Menu strings are Chinese; match the existing tone when adding entries.