Pivot 2026-05-09 (afternoon, Jonah): the user installs one app — HeyBrian — and gets everything. No "parts and pieces." HeyBrian app on Mac is the host of every Brian-on-Mac function. mac.jonahtebaa.com is the same product viewed remotely. Hammerspoon-as-orchestrator retires. App closed → system down. App running → everything works.
This supersedes v3 (01_control_center_architecture.md) at the hosting layer. The v3 wire protocol (HMAC POSTs, JSON envelopes, capability registry) survives — only the process that hosts it changes.
Codex returned 10 critiques in 20s. Adopted in full:
WKURLSchemeHandler to load bundled HTML + inject a short-lived in-memory token via WKUserScript / message handler. Bind to 127.0.0.1 only. Set no-cache headers. Require token even on cpanel state read.KeepAlive restarts. Distinguish by sentinel file (~/.brian/heybrian_quit_intent). Plus Hetzner watchdog showing "Mac offline / HeyBrian down" on extended outage — no SIP, just LOGS.~/.brian_mac_secret and ~/.brian/cpanel_token migrate to macOS Keychain. capabilities.json moves to Application Support (canonical). Keep one-version compat reads from ~/.brian* for migration window.file.read / file.write (whitelist prefixes)"I installed HeyBrian on my Mac. It put a Brian icon in my menubar. I can talk to it ('Hey Brian'). I can open Settings to see and toggle every capability. I can open mac.jonahtebaa.com on my phone and see the same thing. When I quit the app, Brian stops doing anything on my Mac."
That's the whole story. Border, orb, voice, settings, audit, capabilities — one app, one menubar icon, one settings panel, one remote view.
| Surface | What lives there | Talks to |
|---|---|---|
| HeyBrian Mac app (native Swift) | wake-word, orb, voice, screen pixels, Step A/B/C, breathing border, pill, status box, HTTP server :8765 (the only chokepoint), capability registry, executors, audit log, settings webview |
Hetzner agent-api (out), itself (everything in) |
| HeyBrian Settings (inside the app) | cpanel UI (WKWebView serving the HTML I built, OR native SwiftUI port) | localhost loopback only |
| mac.jonahtebaa.com (PWA) | same cpanel UI, phone-first re-skin; chat with Brian; remote action send | Hetzner agent-api → HeyBrian over Tailscale |
Single source of truth: HeyBrian's own state. Everything else reads/writes through its :8765 HTTP API.
NSWindow overlay, transparent, click-through, on every space)NSPanel with NSButton chooser)NSWindow overlay):8765 (replaces Hammerspoon's, takes over the port)~/.brian/capabilities.json)~/Library/Logs/Brian/control_center.jsonl)~/.brian/cpanel_token)~/.brian_mac_secret)NSWindow overlays auto-dismiss (OS cleanup):8765 releasesMacAccessUnreachable → no Mac actions fireThis is the "app closed = system down" property Jonah asked for.
brian_controller_ui_v2.lua — its job moves to HeyBrian's HTTPServer.swift + new overlay code. File deleted at cutover; init.lua loses the require.brian_panel.lua + brian_prompt.lua (⌃⌥Space, ⌃⌥V) — deferred. Keep in Hammerspoon for v4 phase 1. Port to HeyBrian native input later.File Watcher Proactivity) — deferred. Keep until later phase.Result of v4 phase 1: Hammerspoon still runs (because of brian_panel/brian_prompt), but it no longer hosts the control center. Eventually we can pull the rest into HeyBrian and uninstall Hammerspoon entirely.
From the existing v3 Lua module (~1300 LOC), these jobs move to Swift:
HTTP routes (extend HTTPServer.swift):
- POST /v2 — session lifecycle (HMAC verified)
- POST /v2/action — action dispatch (HMAC verified)
- GET /v2/caps — registry dump (no auth)
- GET /cpanel — serves the HTML (already written) from app bundle
- GET /cpanel/api/state — token-authed JSON
- POST /cpanel/api/{toggle,source-allow,stop-session} — token-authed mutations
- existing: /notify, /activate, /status, /ask_screen, /start_loop, /cancel_loop, /health — keep
- port 9100 retires; everything moves to 8765
HMAC + nonce + replay protection — port verbatim from Lua.
Capability registry — JSON read/write with atomic temp+rename. Same schema.
12 executors — reimplement in Swift:
- run → Process with /bin/sh -c
- click → shell out to /opt/homebrew/bin/cliclick OR CGEvent directly
- screencap → shell out to /usr/sbin/screencapture (existing ScreenCapture.swift already does this)
- type → CGEvent keystrokes
- keystroke → CGEvent.keyboardEvent
- chrome.eval / chrome.url → NSAppleScript
- file.read / file.write → FileManager + Data
- applescript → NSAppleScript
- notify → UNUserNotificationCenter (already used)
- say → Process shell out to /usr/bin/say, gated by ~/.brian/no_speech flag
Audit log + size rotation + in-memory ring — port verbatim semantics.
Cpanel — HTML stays as-is. Settings menu in HeyBrian opens a WKWebView pointing at http://127.0.0.1:8765/cpanel with token pre-injected. Same HTML works on the phone via mac.jonahtebaa.com.
Breathing border + pill + status box:
- Border: full-screen NSWindow with custom CALayer for the 4-layer cyan stroke + breathing animation
- Pill: NSPanel floating in top-right, click → NSMenu chooser (Pause/Resume/Stop)
- Status box: NSPanel at bottom-center, translucent NSVisualEffectView, two-line text
- Glass / Submarine sounds: NSSound(named:)
Heartbeat watchdog — Swift Timer runs every 2s; if last_heartbeat older than 30s, dismount + post watchdog_kill callback.
Jonah's requirement (clarified 2026-05-09): the control center accessed inside the HeyBrian app must ALSO be accessible at mac.jonahtebaa.com. No separate phone UI. The cpanel is the truth; the phone is just a different window onto it.
Implementation: one HTML file, two delivery paths, same state.
┌────────────────────────────────┐
│ cpanel HTML (single source) │
│ detects origin, picks auth │
└────────────────────────────────┘
│ │
localhost │ │ mac.jonahtebaa.com
(Mac browser │ │ (phone, other Mac, anywhere)
OR HeyBrian │ │
WKWebView) ▼ ▼
┌─────────────────┐ ┌─────────────────────┐
│ HeyBrian app │ │ Caddy gate_auth │
│ :8765/cpanel │ │ ↓ │
│ token auth │ │ Hetzner │
│ │ │ mac_control_routes │
│ │ │ ↓ (HMAC over TS) │
│ │ │ HeyBrian │
│ │ │ :8765/v2/action │
└─────────────────┘ └─────────────────────┘
│
(proxy adds <100ms)
The HTML reads window.location.hostname:
- 127.0.0.1 / localhost → call /cpanel/api/* directly with X-Brian-Cpanel-Token
- mac.jonahtebaa.com → call /api/mac/cpanel/* (Caddy-gate-protected); Hetzner-side handler signs HMAC and forwards to HeyBrian
One HTML to maintain. One state. No drift between the in-app Settings and the remote PWA.
| Channel | Auth |
|---|---|
Hetzner → HeyBrian /v2, /v2/action |
HMAC over body, shared secret in ~/.brian_mac_secret |
Mac local browser → HeyBrian /cpanel |
Token in ~/.brian/cpanel_token (mode 0600) |
| Phone → mac.jonahtebaa.com → Hetzner | Caddy gate (existing gate_auth) |
| Hetzner → HeyBrian (proxying for phone) | HMAC (same as Hetzner direct) |
When HeyBrian app is the only running process, it's also the only place where the secrets live and the only place where actions can fire. Stop the app → no path to anything.
:8765. Hammerspoon's lua needs to release it first. Cleanest sequence?NSWindow is cleaner but more code. Worth it?1.1. Extend HTTPServer.swift: add HMAC verifier + nonce LRU + /v2, /v2/action, /v2/caps routes. Port stays 9100 for this phase — Hammerspoon keeps :8765. Test against Hetzner gateway pointed at :9100.
1.2. Port the 12 executors to Swift. Each gets a unit test that fires the same JSON the Lua got and gets the same shape back.
1.3. Port capability registry + audit log + in-memory metrics + cpanel routes to Swift. Cpanel HTML served from app bundle.
1.4. Add WKWebView Settings window, opens http://127.0.0.1:9100/cpanel with ?t=<token>.
1.5. Cutover: flip Hetzner gateway to :9100, retire Hammerspoon brian_controller_ui_v2.lua, then HeyBrian binds :8765 (and :9100 for backcompat for a week).
2.1. Replace Hammerspoon canvases for border/pill/status with native NSWindow overlays inside HeyBrian.
2.2. Border breathing animation in CABasicAnimation.
2.3. Pill chooser → NSMenu.
3.1. Hetzner mac_control_routes.py proxies /api/mac/cpanel/* to HeyBrian.
3.2. PWA shell + cpanel HTML render the same Lua-era UI on phone.
3.3. Phone-first CSS responsive tweaks.
4.1. Port brian_panel.lua / brian_prompt.lua to HeyBrian native input window.
4.2. Port file watcher.
4.3. Uninstall Hammerspoon at the end.
Phase 1.1: HeyBrian HTTPServer.swift gains HMAC + /v2/caps GET route. Hetzner gateway's _probe_reachable switches to point at :9100/v2/caps (or stays at :8765 and the Hammerspoon module continues to answer that probe — both ports answer the same JSON, soft transition). One test: python3 -c 'from mac_access_gateway import _probe_reachable; print(_probe_reachable())' returns ok against HeyBrian.
After 1.1 lands, every subsequent phase is incremental — capability migration one at a time, with both Lua and Swift answering until Lua retires.
| v3 piece | v4 fate |
|---|---|
brian_controller_ui_v2.lua (1300 LOC) |
retires after Swift port lands; reference implementation |
~/.brian/capabilities.json |
unchanged — same file, HeyBrian reads/writes |
~/.brian_mac_secret |
unchanged |
~/.brian/cpanel_token |
unchanged |
| Cpanel HTML (~250 LOC vanilla JS) | unchanged — served from HeyBrian app bundle |
Hetzner mac_access_gateway.py thin client |
unchanged — endpoints same |
| HMAC envelope shape | unchanged |
| Audit log path / format | unchanged |
In other words: Section 1 + Section 2 of v3 are the spec. v4 phase 1 is implementing them in Swift instead of Lua. No throwaway work.