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/@Publishedstate management - Concurrency:
@MainActorisolation, 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
LocalPlannerModelIdentifierdefault isgoogle/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; onscripts/eval-planners.pyit 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.mdfor the model knob. Pure-knowledge text-only turns preferAppleFoundationModelsPlannerClientwhen 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 nopace.planner.tier.UserDefaults state at all,PacePlannerTierStore.loadConfiguration()checksSystemLanguageModel.default.availabilityand resolves to.appleFoundationModelswhen Apple Intelligence is available,.localotherwise. 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.localtier (or any other persisted value) loads exactly as before. - Planner (
BuddyPlannerClient): the active planner is chosen by the user in Settings → Planner viaPacePlannerTier(Local / CLI bridge / CLI direct / Direct API / Apple FM). Default is.local(LM Studio + theLocalPlannerModelIdentifiermodel,google/gemma-3-12bby default) — existing users see zero behavior change on upgrade.BuddyPlannerClientFactory.makeDefault()dispatches on tier:LocalPlannerClientfor.localand as a fallback for missing/invalid configs;AppleFoundationModelsPlannerClientfor.appleFoundationModels(also the preferred fast pure-knowledge answer planner when Apple Intelligence is available);CloudBridgePlannerClient(existing) for.cliBridgewith the same NSAlert consent + 24-hour soak gate;PaceLocalCLIPlannerClientfor.cliDirect;DirectAPIPlannerClientfor.directAPIBYO-key turns. The sameCompanionSystemPromptflows through every tier so persona and tool dialect stay byte-identical. Direct-API keys live in macOS Keychain viaPaceKeychainStore— never UserDefaults, never a plist, never a log line. While ANY non-Local tier is serving a turn, the menu-bar capsule tints amber viaisOffDeviceTurnInFlight. Failures fail loud; thedirectAPIFallsBackToLocalOnCloudFailureopt-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 viaresponse_format: json_schema(LocalPlannerClient.v10ResponseFormat):payload.callsis typed as[{name,args}]for multi-step turns,Draw.annotation/Clear.annotationsare in the documented action vocabulary, and a structured stream whose final text is not valid JSON is retried withoutcache_prompt(auditoutcome=invalid_structured_json). Draw actions are DRAINED out of the parse result and rendered byPaceAnnotationActionDrainerbefore 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 inevals/fm-fixtures-actions/scored on DECODED actions viaevals/pace_v10.pymirrors..cliDirectgeneral brain (OpenSpecopenspec/changes/archive/2026-07-12-codex-general-brain): a user-selectable tier that direct-spawns the user’s already-authenticatedcodex(default) orclaudeCLI — no Node bridge, only the CLI on PATH — as the brain for ALL turns (the samePaceLocalCLIPlannerClientthe.researchlane already uses). Upstream sub-selection persists viaPacePlannerTierStorekeypace.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.cliDirectturn tints the capsule amber (isOffDeviceTurnInFlight, keyed onplannerClientForThisTurn is PaceLocalCLIPlannerClient), writes aPaceAPIAuditLogentry (subsystem: "planner.cliDirect",target: <codex|claude>) so the Privacy dashboard flips off “0 bytes → X KB to codex”, and FAILS LOUD viaPaceFailureNarratoron CLI error (no silent local fallback beyond the existing opt-in). Missing-binary preflight (PaceLocalCLIPlannerClient.isUpstreamBinaryOnPath) surfaces a plain-language “needscodexon PATH” hint before the turn. Codex is the phone-a-friend default: the.researchlane 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+LifecycleexecuteTaskCallback) route through a Codex direct-spawn brain ONLY when direct-spawn consent + 24h soak are satisfied (BuddyPlannerClientFactory.cronTaskBrainDecision, sharingcliDirectDispatchDecision) — 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 intoPaceModelMemoryBudget’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 sharedCompanionManager.selectPlannerTierWithConsent.
Voice, vision, TTS
- Speech-to-Text: Apple
SFSpeechRecognizer(on-device,requiresOnDeviceRecognition=true) is the default active provider — instant, no model download.TranscriptionProvider=whisperKitis 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 isLocalServerTTSClient— Kokoro-82M served by a loopback OpenAI-compatible/v1/audio/speechsidecar (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 toLocalTTSClient(AVSpeechSynthesizer, Premium > Enhanced > compactLocalTTSVoiceIdentifier), with a 30 s outage memo so a missing sidecar costs nothing.TTSProvider=appleopts out entirely. Cloud TTS has been removed. - Local Vision-Language Model (optional): LM Studio at
http://localhost:1234/v1(OpenAI-compatible). WhenUseLocalVLMForScreenContext=true, the cursor-screen screenshot is sent to the local VLM (UI-Venus-1.5-2B by default, perLocalVLMModelIdentifierin Info.plist) and its structured element map is prepended to the local planner prompt. Planner/VLM HTTP roots are guarded byPaceLocalEndpointGuardand must be loopback-only; remote or LAN hosts fall back to localhost instead of sending data off-machine. VLM-skip heuristic inPaceTagParsers.transcriptIsLikelyScreenReferentialbypasses the call for pure-Q&A transcripts; override viaAlwaysRunLocalVLMRegardlessOfTranscript=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 viaPaceThreadSummarizer; the user-facing path never blocks on summarization. Race-safety lives in a monotonicsummaryVersionsnapshot captured before each detached call, so out-of-order arrivals are dropped atapplySummaryUpdate. 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 andPaceThreadMemoryStorepersists 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 topaceHistory(only session id + lifecycle events are). Durable facts go through episodic memory instead. - Always-On Companion Mode (default off): typed provenance-bearing
PaceWorldObservationevidence uses bounded atomic persistence, current-state/last-seen/change/presence queries, confidence decay, correction supersession, deterministic memory promotion, and a dedicated retrieval source.PaceCompanionModeControllerexposes off/starting/observing/interpreting/paused/degraded/privacy-blocked;PaceCompanionInterventionPolicydefaults 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 labelshey_paceandbackground); 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 manualCmd+Rrisk without representing those checks or documented thresholds as passed. Seedocs/product/companion-mode-privacy.md,docs/product/companion-mode-dogfood.md, anddocs/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 inPaceToolRegistryso 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].PaceActionTagParserextracts them;CompanionManagerasks for user approval only for higher-risk non-undoable or external actions when theApprove Risky Actionspreference 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.PaceActionApprovalkeeps the allow/cancel decision testable;PaceActionExecutorposts events after TTS playback starts. Single-clicks tryPaceAXTargeterfirst (AX-tree press), falling back to CGEvent. AX set-value edits are logged in a session-local mutation log and can be restored withUndo.last/ “undo that”. App launch/URLs useNSWorkspace; 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_filedownloads a user-named http(s) URL into~/Downloadswith URL validation, filename sanitization, and Finder-style collision suffixes — always approval-gated. Gated byEnableActions=truein Info.plist. Runtime smoke hooks are disabled unlessPACE_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,openMessageswith body, and genericmcp) raises a 5-second floatingPaceUndoBannernext to the cursor; tapping it submitsUndo.lastthrough 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 byPaceFailureNarrator(no LLM call) and routed throughPaceRestraintGate.decide(...)so failure speech stays silent during a Zoom/active call. Reversibility uses the same set asPaceActionApprovalPolicy.actionIsReversibleMutationreturns true for, and the click-missed narrator is wired to the all-fail click observation inPaceActionExecutor. - Recipe library: Pace ships a small library of pre-built
PaceRecordedFlowdefinitions (“recipes”) underResources/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 intoPaceFlowStoreso the existingrun_flowtool can execute it.PaceRecipeLibraryvalidates bundled JSON at startup viaPaceToolRegistry.validateForAppStartup;PaceRecipeCommandParserroutes install/uninstall/list voice commands before the planner. Recipes that need user state (e.g. preferred focus playlist) declarerequiredPreferencesand refuse install with a clear “set this preference first” message. - Teachable skills:
.skill.mdskills (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 inResources/skills/<slug>.skill.md; user skills in~/Library/Application Support/Pace/skills/. Teachable by telling (PRDdocs/product/prds/teachable-skills.md): “teach a skill …” (parsed byPaceSkillCommandParser.create, checked before list/install/run) or Settings → Skills → “Teach a skill” form. Voice descriptions are structured into aPaceSkillFileby the privacy-pinned LOCAL planner (makeLocalOnlyPlannerForPrivacyPinnedFeatures(), same on-device pin as meeting notes) viaskillStructuringSystemPrompt+skillFromStructuredJSON, withstructureSkillDeterministicallyas a no-model fallback so teaching never hard-fails; the typed form usesskillFromForm(deterministic).PaceSkillLoader.save/deleteUserSkill/listUserSkillsuse the atomic temp-file+rename pattern and reusePaceFlowStore.slug(for:).PaceSkillsViewsurfaces 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.
PaceMCPClientloads stdio server definitions from~/.config/pace/mcp-servers.jsonor~/.pace/mcp-servers.json, accepts commonmcpServersconfig shape, runs MCPinitialize+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 byPaceToolPreflightbefore approval. The bridge speaks newline-delimited JSON-RPC and is validated end-to-end byPaceMCPClientIntegrationTestsagainst the in-reposcripts/mcp-fixture-server.py;mcp-servers.example.jsoncurates popular OSS servers (filesystem, fetch, github, applescript) as ready-made connectors. Bundled one-tap catalog (Settings → MCP):PaceMCPServerCatalogships 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 intomcp-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:
PaceScreenWatchModeControlleris the explicit watch-loop primitive. It samples screenshots, usesPaceScreenImageDiffer, and emits typed events only when a screen has meaningful visual change (majorScreenChange,contentUpdate,focusedRegionChange). The companion panel exposes aWatch Modetoggle, and explicit voice commands such as “watch my screen” / “stop watching” route throughPaceWatchModeCommandParserbefore the planner. Watch events also journal into the local retrieval index (PaceScreenWatchJournal, sourcescreenWatchHistory) 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, sourceappUsageHistory) 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 strictlyisResearchTurn-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(inPaceSystemAudioCapture.swift) captures mic + system audio as two separate tracks viaPaceMeetingAudioRecorder, segments them into attributed turns viaPaceMeetingTurnSegmenter(Accelerate RMS + hysteresis + echo trimming), transcribes each turn on-device viaPaceAudioFileTranscriber.transcribeAudioFileSegmented(WhisperKit → Apple Speech fallback), synthesizes structured notes (summary + action items + decisions) viaPaceMeetingNotesBuilderusing 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 viaPaceMeetingNotesJournalinto themeetingNotesretrieval 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 PRDdocs/product/prds/on-device-meeting-notes.mdand ADRdecisions/0001-meeting-audio-capture.md. Adaptive note profiles (meetily-informed, Pace-native — seeopenspec/changes/archive/2026-07-12-adaptive-meeting-notes): notes are shaped by a selectablePaceMeetingNoteProfile(bundledResources/meeting-note-profiles/<slug>.json— general/standup/one-on-one — plus user overrides in~/Library/Application Support/Pace/meeting-note-profiles/, loaded/validated byPaceMeetingNoteProfileLibrary). Thegeneralprofile 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 —PaceMeetingModeCommandParserroutes the profile name before the recording starts. - Deeplinks (
pace://): external launchers (Raycast, Shortcuts) can trigger Pace viapace://listen,pace://chat?text=...,pace://watch?enabled=true|false, andpace://panel.PaceDeepLinkParseris 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 viaCFBundleURLTypes; handled inCompanionAppDelegate.application(_:open:)with pre-launch buffering.
Loop, routing, latency
- Intent routing:
PaceIntentClassifieris 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.sendTranscriptToPlannerWithScreenshotruns 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 hitsAgentMaxSteps(default 8). - Speculative planner race (first step only): on a screen-action / screen-description FIRST step, when
CompanionManager.speculativeRaceShouldFirepasses (race toggle on, local VLM configured, Apple FM available),performFirstStepSpeculativePlannerRacerunsPaceSpeculativePlannerRace.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 (prepareForSupersedingStreamWithinTurnresets 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 soflushFinalcan’t double-speak.bothFailedroutes toPaceFailureNarrator. 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):
PaceAvatarOverlaypaints a small SwiftUI character at the bottom of the cursor screen in its own tinyNSPanel, 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.