Skip to content

Latest commit

 

History

History
102 lines (81 loc) · 26.4 KB

File metadata and controls

102 lines (81 loc) · 26.4 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

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).

Build / test

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 test

Notifications 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.

Architecture

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.$sessions sink 上推入(新增 pid 立即扫,删除 pid 立即清)。scanAndPublish 走 utility workQueue,只在 main 上写 @PublishedMenuBuilder 永远从这几份字典读,绝不直接调 reader —— 主线程同步读几十 MB jsonl 会卡菜单弹出。新加 jsonl 派生字段时按这个模式新建 store,不要让 builder 触发 I/O。
  • Liveness filter. SessionWatcher.readSessions drops any session whose pid is dead (kill(pid, 0) via ProcessLiveness.isAlive). Stale .json files left behind by crashed CLIs are silently ignored — don't add a delete step, that's the CLI's job.
  • Edge-triggered notifications. WaitingTransitionDetector and TaskCompletionDetector are both mutating 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. TaskCompletionDetector additionally 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. PermissionPromptSessionExitDetector is a fourth mutating struct detector but it does not feed WaitingNotifier. 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) and NotificationOrchestrator.sessionsDidChange calls permissionStore.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 whose sessionId is nil (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 to config.maxReminders times, with initialDelay and interval taken from SettingsStore.reminderInterval (nil = disabled, in which case maxReminders = 0 and tick is a no-op). When the user changes the interval in preferences, the tracker is rebuilt via orchestrator.rebuildReminderTracker(interval:) — losing in-flight per-pid state is intentional.
  • Notifications gated by settings + active panels. All WaitingNotifier posts (transition, reminder, completion) are gated by SettingsStore.notificationsEnabled inside NotificationOrchestrator(via the isNotificationsEnabled closure injected at init). On top of that, waiting notifications (transition + reminder) are also suppressed for any session whose sessionId is in permissionStore.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.encodeProjectPath replaces 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 for model → window size. When a new model class ships, edit only this method. usageRatio deliberately 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 of Models/ import only Foundation/Combine/Darwin/CoreServices/UserNotifications/ServiceManagement, and the test target relies on that. The known exceptions (each justified by what they wrap) are: SettingsStore imports AppKit for NSColor; GlobalHotkey imports Cocoa + Carbon.HIToolbox for RegisterEventHotKey; PermissionPromptPanelManager imports Cocoa + Carbon.HIToolbox for window placement and key constants; TerminalActivator imports Cocoa for NSRunningApplication / NSWorkspace / NSSound(整个职责就是激活 GUI app)。Services/NotificationOrchestrator 故意只 import Foundation —— 单测装配时不应引入 AppKit。Don't add new AppKit imports to Services/ without a similarly hard reason.
  • Single UN delegate. NotificationDispatcher is the only UNUserNotificationCenterDelegate. WaitingNotifier is post-only. Permission prompts go through PermissionPromptPanelManager + PermissionPromptPanel (NSPanel) and don't touch UN at all.
  • Resolved signal. PermissionPromptStore exposes both incoming (new request) and resolved (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 — incoming to spawn a panel, resolved to 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.status leaves waiting → detector emits the sessionId → permissionStore.abandonAll(sessionId:) → each entry's reply is invoked with nil and resolved fires per id. The helper-disconnect path (PermissionPromptListener installs a DispatchSource.makeReadSource on the accepted client FD; on EOF calls store.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.Reply is (Decision?) -> Void; passing nil is the abandon signal. Store.abandon(id:) invokes reply(nil) and fires resolved. The listener checks for nil and closes the client fd without writing any response — the helper's SocketClient.requestResponse then sees EOF on read, returns nil, HookProcessor returns 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 fire resolveDeny; that would silently reject the tool call.
  • Panel-internal Outcome is decoupled from wire Behavior. 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 translates Outcome → store calls explicitly:.selected(.decision(...))resolveAllow/resolveDeny,.selected(.abandon).abandon 都走 store.abandon
  • Helper / app duplication of wire types. PermissionPromptRequest/Decision live in ClaudeStatusBar (Swift Codable). The hook helper does not import them — it round-trips the same JSON via JSONSerialization dictionaries in HookProcessor. 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?}}} — note decision: {behavior}, not the permissionDecision: "allow" string used by PreToolUse. 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. PermissionPromptPanelManager registers Ctrl+Shift+Y / Ctrl+Shift+N via GlobalHotkey only 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_suggestionsPromptOptionsBuildersetMode 推导)。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's present(_:) early-returns for these. In parallel, AskUserQuestionPanelManager subscribes to the same incoming stream, filters by toolName == "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 call store.abandon(id:), letting the CLI's terminal select prompt win the race. To add another tool that needs its own panel, write a new manager — don't grow toolsRoutedAwayFromPanel into an N-way switch.
  • No SIGPIPE on accepted sockets. PermissionPromptListener.acceptLoop sets SO_NOSIGPIPE on 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 check write'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 reach write at all — abandonAll replies nil and the listener closes the fd without writing.)
  • Rolling 5-hour window. RollingWindowAggregator.currentWindow(now:projectsRoot:) reverse-scans every *.jsonl under ~/.claude/projects/ for assistant entries with an ISO-8601 timestamp within now − 5h. The window's startedAt is the earliest qualifying entry, resetsAt = startedAt + 5h, and RollingWindow.remaining(now:) powers the "重置 Xh Ym 后" menu line. Returns nil when there's no recent activity (rendered as "(无活动)"). Pure-static like the other aggregators.

External file contracts (read-only, mostly)

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 in Models/Session.swift (pid, sessionId, cwd, version, kind, entrypoint, startedAt, updatedAt, status, waitingFor?). startedAt/updatedAt are 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 with type == "assistant" carry message.model and message.usage.{input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens}. LiveUsageAggregator sums across all of them; SessionDetailsReader reverse-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.

UI

  • OctopusIcon rasterises a hard-coded 12×12 grid into an NSImage. Colour is parameterised; isTemplate=true for idle (AppKit auto-inverts for dark/light menu bars), isTemplate=false for the "working" / "needs attention" states so the colour survives. The two non-idle colours come from SettingsStore.workingColor / attentionColor — user-customisable in the Appearance preferences pane, defaulting to orange / system yellow.
  • SettingsWindowController is a 3-tab window (通用 / 外观 / 关于) opened via the menu's "偏好设置..." item (⌘,). The General tab toggles notifications, picks a reminder interval, and surfaces launch-at-login through LoginItemController (hidden when running unbundled, since SMAppService.mainApp requires a code-signed bundle). Appearance edits the icon colors. About is static metadata.
  • Menu strings are Chinese; match the existing tone when adding entries.