Skip to content
Pace
Esc
navigateopen⌘Jpreview
On this page

Pace — architecture (systems reference)

Pace — architecture (systems reference)

This is the canonical detailed architecture reference for Pace — the per-system bullet list that previously lived inline in the repository AGENTS.md. It was extracted to keep AGENTS.md a concise agent bootloader. The doctrine and the high-level constellation diagram live in overview.md; per-file responsibilities live in ../development/key-files.md.

App shape

  • App Type: Menu bar-only (LSUIElement=true), no dock icon or main window
  • Framework: SwiftUI (macOS native) with AppKit bridging for menu-bar panel, notch capsule overlay, and cursor overlay
  • Pattern: MVVM with @StateObject / @Published state management
  • Concurrency: @MainActor isolation, async/await throughout
  • Analytics: local-only no-op/timing-safe hooks via PaceAnalytics.swift; no cloud analytics SDK is linked.

Planner

  • Planner: main screen/action planning uses the local OpenAI-compatible LM Studio reasoner. The shipped LocalPlannerModelIdentifier default is google/gemma-3-12b (qat-4bit, ~8 GB) — per the 2026-06-12 drilldown it was the only ≤14B model beating the 4B baseline on clarify + out-of-scope + destructive-confirm. qwen/qwen3-30b-a3b (MoE, ~18.6 GB at Q4 but only 3B active params — runs roughly as fast as a dense 3B while reasoning at 30B scale) is the recommended stronger swap on 48 GB machines; on scripts/eval-planners.py it scored 15/15 on the FM-fixture set at 925ms mean, beating Apple Foundation Models (13/15, 1555ms) and qwen3-14b (15/15, 2156ms). See ../development/info-plist-switches.md for the model knob. Pure-knowledge text-only turns prefer AppleFoundationModelsPlannerClient when Apple Intelligence is available, falling back to LM Studio otherwise, because short answers are latency-sensitive and do not need the larger action planner. Pair the LM Studio planner with the VLM in max-loaded-models=2 setting so neither evicts the other. <think>…</think> blocks stripped before TTS and action-tag parsing. No cloud LLM. First-launch tier default: on a brand-new install with no pace.planner.tier. UserDefaults state at all, PacePlannerTierStore.loadConfiguration() checks SystemLanguageModel.default.availability and resolves to .appleFoundationModels when Apple Intelligence is available, .local otherwise. Apple FM is preferred for first-launch because it ships in-process with zero external install — the user can talk to Pace immediately without LM Studio. Existing users (anyone who has ever opened Settings → Planner) see ZERO behavior change: hasAnyPlannerTierUserDefaultsState() gates the new Apple-FM-first default behind a fresh-install check, so their pinned .local tier (or any other persisted value) loads exactly as before.
  • Planner (BuddyPlannerClient): the active planner is chosen by the user in Settings → Planner via PacePlannerTier (Local / CLI bridge / CLI direct / Direct API / Apple FM). Default is .local (LM Studio + the LocalPlannerModelIdentifier model, google/gemma-3-12b by default) — existing users see zero behavior change on upgrade. BuddyPlannerClientFactory.makeDefault() dispatches on tier: LocalPlannerClient for .local and as a fallback for missing/invalid configs; AppleFoundationModelsPlannerClient for .appleFoundationModels (also the preferred fast pure-knowledge answer planner when Apple Intelligence is available); CloudBridgePlannerClient (existing) for .cliBridge with the same NSAlert consent + 24-hour soak gate; PaceLocalCLIPlannerClient for .cliDirect; DirectAPIPlannerClient for .directAPI BYO-key turns. The same CompanionSystemPrompt flows through every tier so persona and tool dialect stay byte-identical. Direct-API keys live in macOS Keychain via PaceKeychainStore — never UserDefaults, never a plist, never a log line. While ANY non-Local tier is serving a turn, the menu-bar capsule tints amber via isOffDeviceTurnInFlight. Failures fail loud; the directAPIFallsBackToLocalOnCloudFailure opt-in toggle (default off) is the one path to silent fallback. The main (action) planner is DECODE-CONSTRAINED to the v10 {spokenText,intent,payload} envelope via response_format: json_schema (LocalPlannerClient.v10ResponseFormat): payload.calls is typed as [{name,args}] for multi-step turns, Draw.annotation/Clear.annotations are in the documented action vocabulary, and a structured stream whose final text is not valid JSON is retried without cache_prompt (audit outcome=invalid_structured_json). Draw actions are DRAINED out of the parse result and rendered by PaceAnnotationActionDrainer before dispatch — so a draw-only turn’s trace reads “no actions parsed — spoken-only turn” BY DESIGN; in mascot mode the annotation presence-callback transiently reveals the (otherwise suppressed) cursor overlay window that hosts the annotation layer. Eval coverage for these paths lives in evals/fm-fixtures-actions/ scored on DECODED actions via evals/pace_v10.py mirrors.
    • .cliDirect general brain (OpenSpec openspec/changes/archive/2026-07-12-codex-general-brain): a user-selectable tier that direct-spawns the user’s already-authenticated codex (default) or claude CLI — no Node bridge, only the CLI on PATH — as the brain for ALL turns (the same PaceLocalCLIPlannerClient the .research lane already uses). Upstream sub-selection persists via PacePlannerTierStore key pace.planner.tier.cliDirect.upstream (PaceLocalCLIUpstream, default .codex). Off-device, so it inherits the FULL cloud-bridge safety contract via a separate transport-aware consent: PaceCloudBridgeConsent.hasAcceptedDirectSpawnConsent() + a 24-hour direct-spawn soak (canRunDirectSpawnTurn(now:)) — accepting the Node-bridge consent does NOT auto-grant the direct-spawn path. A .cliDirect turn tints the capsule amber (isOffDeviceTurnInFlight, keyed on plannerClientForThisTurn is PaceLocalCLIPlannerClient), writes a PaceAPIAuditLog entry (subsystem: "planner.cliDirect", target: <codex|claude>) so the Privacy dashboard flips off “0 bytes → X KB to codex”, and FAILS LOUD via PaceFailureNarrator on CLI error (no silent local fallback beyond the existing opt-in). Missing-binary preflight (PaceLocalCLIPlannerClient.isUpstreamBinaryOnPath) surfaces a plain-language “needs codex on PATH” hint before the turn. Codex is the phone-a-friend default: the .research lane defaults to Codex on fresh installs (PaceResearchTierStore.defaultCLIBridgeUpstream = .codex, defaultCLIBridgeModel = "" so Codex uses its own authenticated model; persisted upstreams honored for existing users), and cron/scheduled fires (CompanionManager+Lifecycle executeTaskCallback) route through a Codex direct-spawn brain ONLY when direct-spawn consent + 24h soak are satisfied (BuddyPlannerClientFactory.cronTaskBrainDecision, sharing cliDirectDispatchDecision) — otherwise they fall back to the local planner, so an unattended background fire never silently goes off-device (amber forced for the Codex path). The planner brain is also selectable from Settings → Models via a RAM-aware brain picker folded into PaceModelMemoryBudget’s budget section (PaceBundledModelsSettingsTab.memoryBudgetSection): picking a cloud/Apple-FM brain frees the local-planner RAM and the section live-updates the per-model breakdown, fits verdict, and largest-VLM-that-fits recommendation. Both the Planner tab and the Models picker route through the shared CompanionManager.selectPlannerTierWithConsent.

Voice, vision, TTS

  • Speech-to-Text: Apple SFSpeechRecognizer (on-device, requiresOnDeviceRecognition=true) is the default active provider — instant, no model download. TranscriptionProvider=whisperKit is accepted as a scaffolded local provider selection, but falls back to Apple Speech until the real WhisperKit streaming bridge lands. All cloud STT providers have been removed.
  • Text-to-Speech: two on-device conformers of BuddyTTSClient. Default runtime path is LocalServerTTSClient — Kokoro-82M served by a loopback OpenAI-compatible /v1/audio/speech sidecar (scripts/start-tts-server.sh, mlx-audio on port 8880; ~150 ms warm synthesis per sentence; sentence N+1 renders while N plays). Endpoint is loopback-guarded; ANY sidecar failure falls back per-utterance to LocalTTSClient (AVSpeechSynthesizer, Premium > Enhanced > compact LocalTTSVoiceIdentifier), with a 30 s outage memo so a missing sidecar costs nothing. TTSProvider=apple opts out entirely. Cloud TTS has been removed.
  • Local Vision-Language Model (optional): LM Studio at http://localhost:1234/v1 (OpenAI-compatible). When UseLocalVLMForScreenContext=true, the cursor-screen screenshot is sent to the local VLM (UI-Venus-1.5-2B by default, per LocalVLMModelIdentifier in Info.plist) and its structured element map is prepended to the local planner prompt. Planner/VLM HTTP roots are guarded by PaceLocalEndpointGuard and must be loopback-only; remote or LAN hosts fall back to localhost instead of sending data off-machine. VLM-skip heuristic in PaceTagParsers.transcriptIsLikelyScreenReferential bypasses the call for pure-Q&A transcripts; override via AlwaysRunLocalVLMRegardlessOfTranscript=true.
  • Screen Capture: ScreenCaptureKit (macOS 14.2+), multi-monitor support
  • Voice Input: Push-to-talk via AVAudioEngine + pluggable transcription-provider layer. System-wide keyboard shortcut via listen-only CGEvent tap.
  • Voice Input UI: Right-end sound animation in PaceMenuBarOverlay.swift; active voice turns show audio-reactive bars in the right icon slot, or static active-state bars when macOS Reduce Motion is enabled. The old cursor-local voice pill is retained as a reusable view but is not the active conversation surface.
  • Cursor: Codex-style arrow (CodexArrowShape) with linear-gradient fill, white highlight stroke, dual shadow.
  • Element Pointing: the planner embeds [POINT:x,y:label:screenN] tags in its response. The overlay parses these, maps coordinates to the correct monitor, and animates the cursor along a bezier arc to the target.

Memory

  • Two-tier in-context memory: every planner call carries the last K=4 turns verbatim (PaceThreadMemory.verbatimWindow()) AND a rolling summary of everything older (injectionPrefix(), rendered into the system prompt as <conversation_so_far>...</conversation_so_far>). The summary is refreshed by a detached Apple FM call after each turn via PaceThreadSummarizer; the user-facing path never blocks on summarization. Race-safety lives in a monotonic summaryVersion snapshot captured before each detached call, so out-of-order arrivals are dropped at applySummaryUpdate. In-session a 20-minute idle threshold still drops the live state (or Settings → Memory → Reset thread). The conversation now SURVIVES quit/relaunch: PaceThreadMemory.snapshot(now:)/restore(from:) are pure value-type accessors and PaceThreadMemoryStore persists a single on-device JSON (~/Library/Application Support/Pace/thread-memory.json, atomic write) after every turn/summary/reset; CompanionManager.start() rehydrates it. Policy is “resume always, until reset” — no staleness expiry; the file is wiped only on an explicit thread reset or when thread memory is disabled. The summary is still never journaled to paceHistory (only session id + lifecycle events are). Durable facts go through episodic memory instead.
  • Always-On Companion Mode (default off): typed provenance-bearing PaceWorldObservation evidence uses bounded atomic persistence, current-state/last-seen/change/presence queries, confidence decay, correction supersession, deterministic memory promotion, and a dedicated retrieval source. PaceCompanionModeController exposes off/starting/observing/interpreting/paused/degraded/privacy-blocked; PaceCompanionInterventionPolicy defaults to silent memory and keeps card/speech opt-ins separate. Settings → Companion and menu-bar indicators expose source consent, active capture, retention/storage, readiness, pause, and clear. App lifecycle starts explicitly enabled ambient/watch sources plus a production low-rate AVFoundation/Vision camera client that emits only non-identifying ephemeral person tracks and conservatively matches user-taught, locally persisted Vision feature prints across coarse camera zones. Teaching is an explicit centered-object capture; no photo is persisted. Ambient voice uses a bundled local pre-STT Core ML gate (PaceWakeWordClassifier, exact labels hey_pace and background); missing or malformed assets fail closed before Speech.framework is reachable. Accepted wakes hand off to the bounded push-to-talk conversation path. Silent cards and spoken interventions are separately default-off and wired through the intervention policy; speech additionally passes the existing live restraint/cooldown path. Routine promotion requires at least three unique supporting observations. On 2026-07-13 the owner directed “push through,” accepting the remaining unmeasured live/hardware and manual Cmd+R risk without representing those checks or documented thresholds as passed. See docs/product/companion-mode-privacy.md, docs/product/companion-mode-dogfood.md, and docs/product/pace-wake-word-classifier.md.

Actions, tools, integrations

  • Action Layer (agent mode): the planner should prefer <tool_calls> JSON blocks where the outer array is sequential steps and each inner array is a parallel group. Tool metadata lives in PaceToolRegistry so prompt docs, aliases, and risk labels share one source of truth; the local registry validates at app startup so schema examples, aliases, and enum coverage cannot silently drift. Legacy tags are still accepted: [CLICK:x,y], [DOUBLE_CLICK:x,y], [TYPE:text], [KEY:cmd+s], [SCROLL:up:3], [OPEN_APP:Safari], [OPEN_URL:https://example.com], [MUSIC:play], [VOLUME:up:2], [BRIGHTNESS:down:3], [CALENDAR:today], and [REMINDER:send invoice]. PaceActionTagParser extracts them; CompanionManager asks for user approval only for higher-risk non-undoable or external actions when the Approve Risky Actions preference is on, and the approval alert defaults to Cancel. Routine local actions such as click, scroll, window snap, app/URL open, media, volume, brightness, clipboard read, and undo can execute without the popup; blocking preflight issues still surface before execution. PaceActionApproval keeps the allow/cancel decision testable; PaceActionExecutor posts events after TTS playback starts. Single-clicks try PaceAXTargeter first (AX-tree press), falling back to CGEvent. AX set-value edits are logged in a session-local mutation log and can be restored with Undo.last / “undo that”. App launch/URLs use NSWorkspace; volume, brightness, media, and clipboard reads use local macOS APIs; Calendar/Reminders use EventKit for reads and creation. Mail recipient names can resolve through Contacts before composing a draft. Finder/Notes/Mail/Things/Shortcuts/Messages have first-pass local integrations. download_file downloads a user-named http(s) URL into ~/Downloads with URL validation, filename sanitization, and Finder-style collision suffixes — always approval-gated. Gated by EnableActions=true in Info.plist. Runtime smoke hooks are disabled unless PACE_ENABLE_SMOKE_HOOKS=1.
  • Trust surfaces (visible undo + reply replay + failure narration): every reversible mutation (createNote, appendNote, createReminder, createCalendarEvent, composeMail, createThingsToDo, runShortcut, downloadFile, recordFlow/runFlow, setTextValue/editSelectedText, openMessages with body, and generic mcp) raises a 5-second floating PaceUndoBanner next to the cursor; tapping it submits Undo.last through the executor. After every assistant turn finishes speaking, the notch panel renders a 30-second reply-replay button driven by the same post-processed string that flowed through TTS — replay does NOT re-stream the planner. Plain-language failures (planner offline, missing permission, click missed, sidecar TTS offline, MCP server not configured, cloud-bridge upstream error) are composed deterministically by PaceFailureNarrator (no LLM call) and routed through PaceRestraintGate.decide(...) so failure speech stays silent during a Zoom/active call. Reversibility uses the same set as PaceActionApprovalPolicy.actionIsReversibleMutation returns true for, and the click-missed narrator is wired to the all-fail click observation in PaceActionExecutor.
  • Recipe library: Pace ships a small library of pre-built PaceRecordedFlow definitions (“recipes”) under Resources/recipes/<slug>.json — morning-standup-setup, weekly-review-draft, email-zero, focus-mode-on, end-of-day-shutdown. Voice commands (“install the morning standup recipe”) or the Settings → Flows tab copy a recipe into PaceFlowStore so the existing run_flow tool can execute it. PaceRecipeLibrary validates bundled JSON at startup via PaceToolRegistry.validateForAppStartup; PaceRecipeCommandParser routes install/uninstall/list voice commands before the planner. Recipes that need user state (e.g. preferred focus playlist) declare requiredPreferences and refuse install with a clear “set this preference first” message.
  • Teachable skills: .skill.md skills (PaceSkillLoader) are the natural-language sibling of recipes/flows — a numbered step list the planner re-grounds each run (intent tier), rather than replaying recorded AX steps verbatim (flows, pixel tier). Bundled skills live in Resources/skills/<slug>.skill.md; user skills in ~/Library/Application Support/Pace/skills/. Teachable by telling (PRD docs/product/prds/teachable-skills.md): “teach a skill …” (parsed by PaceSkillCommandParser.create, checked before list/install/run) or Settings → Skills → “Teach a skill” form. Voice descriptions are structured into a PaceSkillFile by the privacy-pinned LOCAL planner (makeLocalOnlyPlannerForPrivacyPinnedFeatures(), same on-device pin as meeting notes) via skillStructuringSystemPrompt + skillFromStructuredJSON, with structureSkillDeterministically as a no-model fallback so teaching never hard-fails; the typed form uses skillFromForm (deterministic). PaceSkillLoader.save/deleteUserSkill/listUserSkills use the atomic temp-file+rename pattern and reuse PaceFlowStore.slug(for:). PaceSkillsView surfaces taught skills in a “Your skills” section. Fully on-device — a taught skill never leaves the Mac.
  • MCP Integration Layer: broad external integrations should use OSS Model Context Protocol servers instead of being rebuilt inside Pace. PaceMCPClient loads stdio server definitions from ~/.config/pace/mcp-servers.json or ~/.pace/mcp-servers.json, accepts common mcpServers config shape, runs MCP initialize + tools/call, and returns observations through the same approval/result loop. Planner MCP calls use either {"tool":"mcp","server":"altic","name":"notes_create","arguments":{...}} or {"tool":"notes_search","server":"altic","query":"..."} for server-native names. Missing configured server names are surfaced by PaceToolPreflight before approval. The bridge speaks newline-delimited JSON-RPC and is validated end-to-end by PaceMCPClientIntegrationTests against the in-repo scripts/mcp-fixture-server.py; mcp-servers.example.json curates popular OSS servers (filesystem, fetch, github, applescript) as ready-made connectors. Bundled one-tap catalog (Settings → MCP): PaceMCPServerCatalog ships a fixed four-server list (filesystem, fetch, applescript, composio) installable with a click; github/slack/linear now route through the composio OAuth bridge (PaceMCPServerCatalog.supersededBySlug). PaceMCPCatalogInstaller.install/uninstall(_:into:) performs an atomic JSON merge into mcp-servers.json — temp file + rename — that preserves every user-added entry. Catalog is bundled with each Pace release; no remote fetch of the server list at runtime.

Observation, journals, proactivity

  • Watch mode: PaceScreenWatchModeController is the explicit watch-loop primitive. It samples screenshots, uses PaceScreenImageDiffer, and emits typed events only when a screen has meaningful visual change (majorScreenChange, contentUpdate, focusedRegionChange). The companion panel exposes a Watch Mode toggle, and explicit voice commands such as “watch my screen” / “stop watching” route through PaceWatchModeCommandParser before the planner. Watch events also journal into the local retrieval index (PaceScreenWatchJournal, source screenWatchHistory) so “what did I do today?” questions answer from local history.
  • Time understanding (journals): two passive retrieval sources power Dayflow-style recall. The screen watch journal records watch-mode events (timestamp, change category, frontmost app, cached VLM description when fresh) into day-per-screen documents; the app usage journal (PaceAppUsageTracker + PaceAppUsageJournal, source appUsageHistory) tracks foreground app minutes and switch counts through permission-free NSWorkspace notifications even when watch mode is off. Both keep 7 days, dedupe/cap entries, rehydrate across restarts, and honor per-source enable/clear in Settings. A third recall source, researchHistory (PaceResearchJournal), captures each research-lane turn as a day-bucketed {question, answer} document (30-day/100-entry retention, same-day dedupe) — recorded by a strictly isResearchTurn-guarded, non-blocking, fire-once hook at the agent-loop turn finalization (question = original transcript, answer = final spoken text) — so “what did I research about X?” answers from history and the turns are browsable in Settings → Memory → Past research.
  • Proactive features (opt-in): the menu-bar app exposes a small set of proactive surfaces gated by PaceRestraintGate (active-call check, proactive cooldown, intent confidence). Posture watch (PacePostureMonitor), focus-fatigue nudges, calendar pre-meeting nudges, watch-mode observation nudges, and the daily weekday morning brief (PaceMorningTriageScheduler + PaceMorningBriefBuilder) all default OFF and become inert until the user enables them in Settings. When restraint says “stay quiet” the morning brief is parked on a panel card so the user never misses it.
  • On-device meeting notes: PaceMeetingModeController (in PaceSystemAudioCapture.swift) captures mic + system audio as two separate tracks via PaceMeetingAudioRecorder, segments them into attributed turns via PaceMeetingTurnSegmenter (Accelerate RMS + hysteresis + echo trimming), transcribes each turn on-device via PaceAudioFileTranscriber.transcribeAudioFileSegmented (WhisperKit → Apple Speech fallback), synthesizes structured notes (summary + action items + decisions) via PaceMeetingNotesBuilder using a privacy-pinned LOCAL planner (BuddyPlannerClientFactory.makeLocalOnlyPlannerForPrivacyPinnedFeatures() — never the CLI bridge or Direct API tier, regardless of the user’s tier selection), and journals them via PaceMeetingNotesJournal into the meetingNotes retrieval source so “what did we decide in standup?” answers from local history. Fully on-device — no cloud STT, no cloud LLM. The wedge against Granola/Otter/Fireflies (all cloud). Recording files use .part → atomic rename with RIFF-header crash repair. See PRD docs/product/prds/on-device-meeting-notes.md and ADR decisions/0001-meeting-audio-capture.md. Adaptive note profiles (meetily-informed, Pace-native — see openspec/changes/archive/2026-07-12-adaptive-meeting-notes): notes are shaped by a selectable PaceMeetingNoteProfile (bundled Resources/meeting-note-profiles/<slug>.json — general/standup/one-on-one — plus user overrides in ~/Library/Application Support/Pace/meeting-note-profiles/, loaded/validated by PaceMeetingNoteProfileLibrary). The general profile renders byte-for-byte identical to the legacy prompt (compat anchor, default). Selection precedence: explicit selection (voice command or per-meeting panel pick) → non-general default preference → optional local inference (a silent on-device classify call, default OFF) → general. Voice-triggered: “start my one-on-one recording” / “record this standup” starts a meeting with that profile pinned — PaceMeetingModeCommandParser routes the profile name before the recording starts.
  • Deeplinks (pace://): external launchers (Raycast, Shortcuts) can trigger Pace via pace://listen, pace://chat?text=..., pace://watch?enabled=true|false, and pace://panel. PaceDeepLinkParser is a pure reject-on-ambiguity parser (500-char chat cap); commands are dropped unless the voice state is idle, and chat/listen turns run the normal intent/approval pipeline, so a deeplink can do nothing the user’s own voice couldn’t. URL types registered via CFBundleURLTypes; handled in CompanionAppDelegate.application(_:open:) with pre-launch buffering.

Loop, routing, latency

  • Intent routing: PaceIntentClassifier is a tiny rule-based local classifier. It routes chitchat to a canned response, pure-knowledge to a text-only planner path, and labels screen-read, tool-action, phone-large-model, and unknown turns for the full pipeline.
  • Plan-act-observe loop: CompanionManager.sendTranscriptToPlannerWithScreenshot runs a multi-step loop. Each step re-screenshots, re-invokes the VLM (heuristic permitting) and the planner, executes grouped tool calls/actions, returns tool observations to the next planner step, and continues until the planner emits [DONE], emits no tool calls/action tags, or hits AgentMaxSteps (default 8).
  • Speculative planner race (first step only): on a screen-action / screen-description FIRST step, when CompanionManager.speculativeRaceShouldFire passes (race toggle on, local VLM configured, Apple FM available), performFirstStepSpeculativePlannerRace runs PaceSpeculativePlannerRace.raceSpeculative: the in-process Apple FM “lite” planner (transcript + thread-memory only, NO VLM) races the full VLM-fed planner. The full path’s VLM+OCR+AX prep is deferred into the race’s lazy builder so it runs concurrently with lite — lite produces audio in ~150 ms while a cold VLM (2–3 s) is still resolving. Whichever streams first wins TTS; the full path supersedes the lite stream if its first token lands within 500 ms / 60 spoken chars (prepareForSupersedingStreamWithinTurn resets the pipeline so the new stream replays cleanly). Action parsing ALWAYS uses the full planner’s complete text, never lite — the lite path is spoken-feedback only and can’t emit a real action; when lite wins the audio, the lite text becomes the spoken/bubble/memory string so flushFinal can’t double-speak. bothFailed routes to PaceFailureNarrator. Gate false (and every multi-step turn) keeps the single-planner path byte-identical. Toggle: PaceUserPreferencesStore.enableSpeculativePlannerRace (default ON, opt-out in Settings → Planner).
  • Walking avatar (optional): PaceAvatarOverlay paints a small SwiftUI character at the bottom of the cursor screen in its own tiny NSPanel, but it is not attached at app launch. The default always-visible surface is the menu-bar/notch capsule.

Key architecture decisions

Menu Bar Panel Pattern: PaceMenuBarOverlayManager owns the visible black menu-bar/notch capsule in a top-level non-activating NSPanel. MenuBarPanelManager owns the floating companion panel and anchors it to the notch overlay frame, with a centered top-of-screen fallback for launch-time onboarding. No visible NSStatusItem is created. This gives full control over appearance and avoids the standard macOS menu/popover chrome. The panel is non-activating so it doesn’t steal focus. A global event monitor auto-dismisses it on outside clicks.

Settings Window Pattern: PaceSettingsWindowManager owns a normal titled NSWindow for configuration that has outgrown the notch panel: MCP server config, permissions, voice, preferences, memory (incl. a Past-research history list), scheduled tasks (the Tasks tab lists PaceCronScheduler recurring tasks with last-run + delete), and action history. The notch panel keeps a gear button that opens this window. Pace still runs as LSUIElement=true; the settings window is AppKit-managed and explicitly activates the app when shown.

Cursor Overlay: A full-screen transparent NSPanel hosts the blue cursor companion. It’s non-activating, joins all Spaces, and never steals focus. Cursor flight, response text placement, and pointing animations render in this overlay via SwiftUI through NSHostingView; listening/thinking animation lives in the notch bar.

Global Push-To-Talk Shortcut: Background push-to-talk uses a listen-only CGEvent tap instead of an AppKit global monitor so modifier-based shortcuts like ctrl + option are detected more reliably while the app is running in the background.

Transient Cursor Mode: When “Show Pace” is off, pressing the hotkey fades in the cursor overlay for the duration of the interaction (recording → response → TTS → optional pointing), then fades it out automatically after 1 second of inactivity.

Local-mode setup

See SETUP_LOCAL.md for the full recipe. The complete Info.plist switch reference (local VLM / planner / TTS-sidecar / transcription-provider knobs and their defaults) lives in ../development/info-plist-switches.md.

Was this page helpful?