Imported from mindstone/agent-menubar (
AGENTS.md). Install upstream withnpx skills add mindstone/agent-menubar. Copyright stays with the author.
AGENTS.md
Context for AI agents working in this repo. See README.md first for the project overview, architecture diagram, hook list, make targets, and layout — this file only captures the non-obvious things you need to know that the README doesn't.
Scope and portability
This menu bar app currently supports Factory's Droid CLI, OpenAI Codex CLI, Cursor's cursor-agent CLI, and Anthropic's Claude Code running inside supported macOS terminals. The vendor coupling lives in adapter-level files:
hooks/{factory,codex,cursor,claude-code}-event-bridge.sh+hooks/agent-event-bridge.sh— hook source wrappers and shared socket forwarding. The Cursor wrapper also normalises Cursor-specific field names (conversation_id→session_id,workspace_roots[0]→cwd,prompt/text→prompt) before the shared bridge runs; the Factory, Codex, and Claude Code wrappers are thin because those CLIs already emit the field namesHookEventdecodes.Domain/HookEvent.swift+Domain/AgentEventAdapter.swift— the decoded hook payload and per-agent state transitions.Focus/*Focuser.swift+Focus/*Inventory.swift— terminal focus and live-tab inventory.
Everything else (Store/, UI/, IPC/, the rest of Domain/) is vendor-neutral. Keep it that way; don't push vendor-specific branches inward.
Using this on an unsupported stack today
You can't, beyond seeing an empty popover. Rows will be unclickable if no supported terminal id can be captured, and no events will reach the app unless that CLI has a hook wrapper registered.
Extension sketch for future support (not implemented)
- Other coding agents (Cursor, Claude Code, …): add one shell wrapper per agent that invokes
agent-event-bridge.sh <agent-kind>, add anAgentKind, and add oneAgentEventAdapterimplementation for that CLI's event semantics. The state machine (running→waitingForInput→finished) maps cleanly onto any prompt-driven agent loop. - Other terminals (Terminal.app, Warp, …): add terminal-specific inventory/focus adapters under
Sources/AgentMenuBar/Focus/, then route fromDroidSession.hostApp. Terminal.app is easiest (AppleScript-controllable); Warp has no stable public scripting interface today.
Keep the abstraction at the adapter level — one Swift type per concrete terminal/agent. Don't try to generalise HookEvent keys or invent a plugin runtime; the surface is small enough that thin protocols are enough.
Dev loop
The right iteration command is make install (builds release, packages the .app, copies to /Applications, relaunches). make run runs the raw SwiftPM binary, which on macOS 26 has unreliable status-item visibility — only useful for the very first compile check.
After editing Swift code: make install. After editing the hook bridge or its registration: also make install-hooks (Factory + Codex + Claude Code) and/or make install-cursor-hooks (Cursor is a separate target, see below). Factory's ~/.factory/settings.json, Codex's ~/.codex/hooks.json, Claude Code's ~/.claude/settings.json, and Cursor's ~/.cursor/hooks.json are the hook sources of truth. Existing Droid sessions need restart because Droid snapshots hooks at startup; Codex and Claude Code sessions may also need /hooks review after the hook definition changes; Cursor reloads hooks.json automatically but in-flight cursor-agent sessions should be restarted to pick up new hooks.
Where state and logs live
~/Library/Application Support/AgentMenuBar/sessions.json— persisted session store (atomic rename)~/Library/Application Support/AgentMenuBar/sock— Unix domain socket the bridge writes to~/Library/Logs/AgentMenuBar/events.log— every augmented hook payload, appended even when the app is offline. First place to look when behaviour is wrong.~/.factory/settings.json— whereinstall-factory-hooksregisters the Droid bridge~/.codex/hooks.json— whereinstall-codex-hooksregisters the Codex bridge
Status state machine (more nuanced than the README table)
Factory and Codex Stop hooks are turn-scoped, not necessarily process/session end. From the user's POV, between turns the agent is idle ⇒ rendered as DONE. Concretely:
| Event | Status transition | Notes |
|---|---|---|
SessionStart |
→ idle | session alive, no prompt submitted yet — distinct from running so a freshly-opened tab doesn't read as actively working |
UserPromptSubmit |
→ running | user typed a new prompt |
Notification |
→ waitingForInput | permission prompt or 60s-idle alert |
PermissionRequest |
→ waitingForInput | Codex approval prompt |
PreToolUse matcher AskUser |
→ waitingForInput | Factory interactive choice picker; sound plays but no Notification fires for these |
PreToolUse matcher request_user_input |
→ waitingForInput | Codex Plan-mode/user-input picker; this is not a PermissionRequest |
PostToolUse |
→ running | Factory AskUser answered, Codex user-input picker answered, or Codex completed a tool after approval |
Stop |
→ finished | "current turn done, idle" — not session-end |
SessionEnd |
→ finished | actually done |
Cursor sessionStart / beforeSubmitPrompt |
→ running | camelCase; prompt captured from prompt (or text) |
Cursor beforeShellExecution / beforeMCPExecution |
→ waitingForInput | approval gate — the only hook-observable "needs you" moment in cursor-agent |
Cursor afterShellExecution / afterMCPExecution / afterFileEdit / postToolUse |
→ running | command/tool ran, agent working again |
Cursor stop |
→ finished | turn-scoped; status ∈ completed/aborted/error drives the row text |
Cursor sessionEnd |
→ finished | actually done |
Claude SessionStart |
→ idle | PascalCase; payload already matches HookEvent fields |
Claude UserPromptSubmit |
→ running | |
Claude Notification(permission_prompt|elicitation_dialog) / PermissionRequest |
→ waitingForInput | Notification is scoped to the genuine needs-you types so auth_success etc. don't flip the row |
Claude Notification(idle_prompt) |
→ (ignored) | Fires ~60s after Stop when the user hasn't replied; the turn is already DONE. Treating it as waiting repaints every finished turn as a "question" — so it's dropped from the matcher and no-op'd in the adapter (notification_type guard) for safety against stale settings. |
Claude PostToolUse |
→ running | tool ran after approval — flips waiting or awaitingBackgroundWork back to running |
Claude Stop |
→ awaitingBackgroundWork | turn ended, but doesn't finish outright — a same-tab sub-agent or native background task (run_in_background shell, Monitor watcher) may still be running. SessionStore.resolvePendingFinishes promotes it to .finished once TranscriptBackgroundScan/hasLiveRelatedSubAgent report nothing outstanding (see turnEndedAt). |
Claude SubagentStop |
→ (no-op) | Fires when a Task-tool subagent completes. No status change needed — SessionStore.apply already re-runs resolvePendingFinishes after every event, so registering this hook just makes that recheck happen sooner than the 5s inventory-timer fallback. |
Claude SessionEnd |
→ finished | the CLI process is exiting, so nothing is left to spawn more background work — finish immediately rather than deferring |
The store keeps the literal .finished value across Stops (for Factory/Codex/Cursor, whose Stop finishes outright); the popover treats .finished as DONE and the next UserPromptSubmit flips it back to .running. .idle (session started, no prompt yet) and .awaitingBackgroundWork (Claude's deferred-finish state) are both "live" for visibleSessions/staleness purposes — see SessionStatus.swift — but neither is running or waitingForInput.
visibleSessions collapses many historical agent runs sharing the same terminal tab into one row (most recent by lastEventAt) and drops sessions whose terminal tab is no longer open. This is driven by terminal inventory polling on a 5-second timer. Don't filter sessions for display anywhere else — use store.visibleSessions.
macOS / Tahoe gotchas (don't regress these)
- The app must ship as a proper
.appwithLSUIElement=YES. macOS 26 silently dropsNSStatusItems registered by un-bundled SwiftPM binaries (the bundle ID comes through asNULL). Themake installpath handles this;make rundoes not. - Use plain emoji text (e.g.
🤖,❓,🟦) for the status item label viaNSAttributedString. SF Symbols render with insufficient prominence on Tahoe. SeeAppDelegate.applyTitle(for:flashOn:). NSPopover.behavioris.applicationDefined(not.transient). In anLSUIElementapp, transient popovers sometimes never become key, which causes SwiftUIButtontaps to silently disappear. On show we also callNSApp.activate(ignoringOtherApps: true)andview.window?.makeKey(). Touching this is how you'll break clicks again.- Rows use
.onTapGesturenotButtonfor the same key-window reason. - iTerm focus across separate-Space displays uses
AXRaiseviaSystem Events(ITermFocuser.swift). Plainactivateonly brings iTerm frontmost on the display where its window already lives. TheAXRaisecall requires Accessibility permission (separate from the Automation permission for talking to iTerm). The whole nudge is wrapped intryblocks so missing permission degrades gracefully.
Hook registration
make install-hooks uses a jq merge that:
- Removes any prior entry whose
commandmatches our bridge path before adding (idempotent) - Backs up
~/.factory/settings.json,~/.codex/hooks.json, and~/.claude/settings.jsonwith timestamped.bakfiles first - Factory: registers bare-event hooks (
SessionStart,SessionEnd,Notification,Stop,UserPromptSubmit) and matcher hooks (PreToolUse/PostToolUsewithmatcher: "AskUser") - Codex: registers
SessionStart,UserPromptSubmit,Notification,PermissionRequest,PreToolUsewithmatcher: "request_user_input",PostToolUse, andStop - Claude Code: registers
SessionStart,UserPromptSubmit,PermissionRequest,PostToolUse,SubagentStop,Stop,SessionEnd, andNotificationwithmatcher: "permission_prompt|elicitation_dialog"(intentionally notidle_prompt— see the status table). Uses the same nested{matcher, hooks:[{type,command}]}shape as Factory/Codex, and only touches the top-levelhookskey so unrelated settings (model, permissions, …) survive.
make install-cursor-hooks is separate and intentionally not part of install-hooks. ~/.cursor/hooks.json is shared with the Cursor IDE, so bundling it into the default target would make every IDE agent session spawn an (unfocusable, terminal-less) row. The Cursor merge uses a different shape from Factory/Codex: Cursor hook definitions are flat ({command, timeout}, no nested hooks array), event names are camelCase, and the file carries a top-level version: 1. It registers sessionStart, beforeSubmitPrompt, beforeShellExecution, afterShellExecution, beforeMCPExecution, afterMCPExecution, afterFileEdit, postToolUse, stop, and sessionEnd. Note that cursor-agent only fires a subset in interactive mode (and beforeSubmitPrompt/stop don't fire in headless -p), but registering the full set is harmless.
If you add a new hook, mirror it in both the install_hook and remove_hook jq call lists for that CLI so uninstall stays clean. Use install_matcher_hook (Factory) when the event requires a matcher; Cursor matchers, when needed, are plain strings on the flat definition.
Things that are not bugs
Stopevents outnumber every other event by ~3:1 inevents.log. Each model turn produces one; treat it as the chat heartbeat, not as "task complete".- The sessions store can hold dozens of historical entries per tab.
visibleSessionsdeals with that — don't try to prune the store automatically.Clear finishedis the user-facing escape hatch. - The first
make installafter acp -Rinvalidates prior TCC grants because ad-hoc re-signing changes the code-signing identity. The user has to re-approve iTerm Automation and (if AXRaise is wanted) Accessibility. osascript display notificationshows up under "Script Editor" in System Settings → Notifications. That's by design — avoids requiring a signed bundle forUNUserNotificationCenter.
Diagnostics quick recipes
- "Did my click reach SwiftUI?" → Add a temporary
NSLog/ file write inSessionRowView.onTapGestureand watch~/Library/Logs/AgentMenuBar/. Don't trustlog showfor app-levelNSLogs under newer macOS — they're often redacted as<private>. - "Why doesn't the popover show this session?" → It's almost certainly the iTerm inventory filter. Run the script in
ITermInventory.fetchAliveUUIDs()'s body manually viaosascriptand compare againstaliveItermUUIDs. - "Hook didn't fire" → Tail
events.logwhile triggering. If nothing appears the bridge wasn't called → check~/.factory/settings.jsonor~/.codex/hooks.jsonfor the registration. For Codex, also open/hooksand confirm the hook was trusted. If an event appears but the app didn't react, the socket forwarding failed → check the app is running andsockexists.
Coding conventions
- Swift, no third-party dependencies. Don't add SwiftPM packages without a strong reason.
- All UI lives under
Sources/AgentMenuBar/UI. Status decisions live inStore/SessionStore.swift. Don't move status logic into views. - AppleScript is consolidated under
Sources/AgentMenuBar/Focus/. Keep it there. - Comments only when the why is non-obvious (constraints, macOS quirks, hook semantics). Don't narrate the code.