Overview
lil agents is an open-source native macOS app with a menu-bar view and floating overlay for Claude Code, Codex CLI, and Grok CLI sessions. It turns CLI lifecycle events into three visible states: working, idle, and waiting for approval. I can see that status without checking the terminal, then select a session row to return to the tab, pane, or split that owns it. The same codebase polls each provider’s usage limits and ships a companion iPhone app with home-screen widgets.
Parallel agent sessions are useful, but their state quickly gets scattered across terminal windows, tabs, and panes. I didn’t want to check every terminal to find the session that finished or needed a permission decision. The app shows that state globally and gets me back to the right terminal target in one action.
Architecture
Native macOS surface: The repo is a multi-platform SwiftPM package (.macOS(.v26), .iOS(.v18)) written in Swift 6.2, with four targets: the AgentDeck macOS executable, a UsageCore library that is iOS-safe and dependency-free by rule, and two swift-testing test targets. SwiftUI owns the MenuBarExtra and Settings scenes, while AppKit supplies the non-activating NSPanel used by the floating overlay. Its root view carries a single Liquid Glass layer, .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 12)) rather than .ultraThinMaterial; the codebase enforces exactly one glass layer at a time. Collection behavior is [.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary, .stationary] at the .screenSaver window level, reasserted when the overlay is shown and again on Space change. That keeps the panel above other Spaces and full-screen apps, including through Mission Control, without taking key focus from the terminal underneath. The frame is re-derived on every present, so a display change or Space switch never leaves the overlay in a stale position. A separate XcodeGen-generated Xcode project, iOS/LilUsage.xcodeproj, builds the iPhone app and its widget extension against UsageCore.
Hooks, listener, and UI: The app installs lifecycle hooks for Claude Code, Codex CLI, and Grok CLI into their existing per-user JSON configuration. The generated forwarder, a shell script paired with a Python merger, reads the hook’s JSON from stdin and adds the tool, event, controlling TTY, best-effort terminal metadata, the owning CLI’s PID, and a headless flag. It strips any agent_pid or headless the CLI payload itself supplied, so only the local forwarder can assert process ownership. It posts the result to 127.0.0.1:54173/event, a fixed constant in Sources/AgentDeck/Constants.swift chosen from the IANA dynamic port range and baked into the forwarder at install time. An embedded Network.framework NWListener is pinned to 127.0.0.1. It accepts only POST /event, checks the per-install bearer token, decodes the event, and hands it to the main-actor session store. That store maps lifecycle events to working, idle, or waitingApproval, then drives the menu bar and overlay. Background and headless runs (codex exec, claude -p, CI, editor-hosted agents) are read from that flag and hidden by default, with an opt-in toggle to show them. Each CLI carries its own logomark, LogoClaude.svg, LogoCodex.svg, LogoGrok.svg, shipped as template images and used everywhere the app identifies which CLI a session belongs to. They live in Contents/Resources specifically so codesign can seal them into the app bundle.
Attribution: The app attributes each event to the nearest ancestor process that is actually the CLI, or to the nearest node, bun, or deno for interpreter-backed installs. Start-time fingerprinting keeps PID reuse from misattributing a session.
Terminal targeting: A session row carries the terminal identity and TTY captured at hook time. A single jump router dispatches the action off the main thread to the appropriate adapter:
- iTerm2 and Terminal.app: AppleScript matches the controlling TTY, selects the right session or tab, raises its window, and activates the app.
- WezTerm:
wezterm cli activate-panefocuses the captured pane, with a TTY-based lookup when an ID is unavailable. - tmux: The app selects the session’s pane and window, switches the attached client when needed, and raises the detected host terminal.
- Ghostty: AppleScript searches windows, tabs, and terminals by TTY first, then by working directory. Activating the app is the safe fallback.
The overlay does more than report that work is waiting. It preserves enough terminal context to return to that work.
Overlay information design: Idle and needs-input rows stay quiet at rest. Once a session has waited past a minute, the row shows how long, “5m” or “1h 15m.” Working rows show nothing at rest, because a duration there would only restate that the tool is running. Hovering a row swaps in the tool name and its elapsed time, so the same 180pt-wide surface carries two levels of detail without changing size. Row height is pinned, so revealing that hover detail never reflows the list. Each row also carries a remove control that appears on hover; dismissing a session suppresses its ID for a bounded horizon, so a session that keeps emitting events does not reappear in the list.
Config safety: Hook installation is idempotent and self-healing for Claude Code and Codex CLI. The installer merges existing Claude (~/.claude/settings.json) and Codex (~/.codex/hooks.json) hook entries instead of replacing them. It removes stale entries that reference an older forwarder, adds the canonical command once, and leaves unrelated hooks untouched, backing up Claude’s original settings once with atomic writes throughout. Grok works differently: ~/.grok/hooks/agentdeck.json is fully owned by the app, rewritten on every launch and deleted whole on uninstall. Grok’s own hook set covers nine events, and it separately scans ~/.claude/settings.json, so a Grok session dual-fires the Claude hooks. The forwarder retags those via a GROK_HOOK_EVENT environment variable to tell them apart. The test suite covers double-install de-duplication, preservation of foreign hooks during install and uninstall, and repair of a stale forwarder entry.
Usage tracking
The three providers: Three independent toggles, all off by default, let the app poll each CLI’s own usage endpoint: Claude against api.anthropic.com/api/oauth/usage using ~/.claude/.credentials.json, falling back to the Claude Code-credentials Keychain item; Codex against chatgpt.com/backend-api/wham/usage using ~/.codex/auth.json; and Grok against cli-chat-proxy.grok.com/v1/billing using ~/.grok/auth.json. The Claude Keychain read shells out to /usr/bin/security find-generic-password rather than calling SecItemCopyMatching in-process, because the CLI writes that item with an apple-tool:-only partition list and an in-process read pops a Keychain password prompt at the user. macOS usage is strictly read only. It never writes credentials and never refreshes tokens, so an expired token surfaces as “run the CLI to sign in.”
Where the numbers show: The menu-bar icon rasterizes weekly percentages for up to two providers to a bitmap, because MenuBarExtra labels flatten SwiftUI. A row above the overlay’s session list shows Claude’s 5-hour window alongside Codex’s and Grok’s weekly ones. The dropdown gives each provider a gauge per window plus a reset caption like “resets Fri 9 AM.” The app classifies urgency colours on the rounded percent, so the colour can never disagree with the printed number: monochrome under 75%, amber at 75%, red at 90%. Stale or failing data dims rather than disappearing, so the last known number stays readable, and polling is deduplicated per provider, throttled, and honours HTTP 429’s Retry-After.
lil usage (iPhone)
Independent of the Mac: The phone fetches usage itself, straight from Anthropic, OpenAI, and xAI, using tokens that live in its own Keychain. No session state ever reaches it, and there is no server of mine anywhere in the path. The README titles the project “lil agents · lil usage” because the iPhone half is a product in its own right.
Getting tokens onto the phone: Sign in directly, with PKCE and a pasted code for Claude and device-code flows for Codex and Grok. The alternative is the optional Mac handoff, which copies the CLI’s credential blobs into synchronizable iCloud Keychain items under a shared access group. UsageCore is the shared library holding the platform-neutral half on both sides: the three fetchers, the OAuth flows, actor-gated token refresh, the Keychain store, and the snapshot store. The one asymmetry is that the iOS side refreshes tokens and the Mac side never does.
Widgets: Two kinds ship in one bundle. The provider widget is built on App Intents’ AppIntentConfiguration with a SelectProviderIntent picker, in small and medium families. The stack widget shows every enabled provider at once, medium only. Neither one fetches; they read a snapshot. The host app and a BGAppRefreshTask write an atomic usage-snapshot.json into a shared App Group, and that snapshot never contains a token. WidgetKit refreshes each timeline at the earliest per-provider eligibility, floored at a minute out.
iOS 18 is the floor, and the app builds from source. It is not on the App Store.
Privacy boundary
Session events use loopback-only transport, with no path for exporting analytics or telemetry. The listener stores its bearer token in a per-install file with restrictive permissions. The other two paths are opt-in and off by default. With usage tracking enabled, the app makes outbound HTTPS calls to Anthropic, OpenAI, and xAI to poll each provider’s limits. With the iPhone handoff toggle on, CLI credential blobs are copied into iCloud Keychain, so tokens leave the machine through Apple’s own sync.
Distribution
Sparkle owns the in-app updater, with automatic checks enabled through the app’s feed configuration. Tag-triggered GitHub Actions CI imports a Developer ID certificate, builds and verifies the app signature, submits the app and DMG to Apple’s notarization service, staples and validates the DMG, signs the Sparkle update archive, updates the appcast, and publishes both the .dmg and update .zip as release artifacts. That pipeline covers macOS only; the iPhone app has no equivalent. Sparkle 2.9.4 is the project’s only third-party dependency.
The app’s Settings window includes a General tab with a toggle, backed by SMAppService, for launching at login. When the login item requires the user’s approval, the toggle displays “macOS is waiting on your approval” next to an “Open Login Items…” button that jumps straight to the right system pane. Uninstalling the app removes the login item along with it.
The full source is available on GitHub, so the hook contract, local listener, terminal adapters, and release tooling are inspectable and reusable.
