Architecture map¶
DictationViewModel (@MainActor) is the facade the views bind to. It builds
the parts below from the collaborators the app shares, and forwards the
session state the views read. The dictation itself lives in
DictationSessionController (#432 step 8c):
DictationSessionController.swift: session state, start and stop, the realtime clients, the stop's inputs to the commitDictationSessionController+Session.swift: managed-backend wait, connect and its timeout, stop-finalization, connection-failure handlingDictationSessionController+StopCommit.swift: the stop-commit, meaning each output path's finish, the overlay commit and its polish task (drivingStopCommitCoordinator), and the session recordDictationSessionController+RealtimeEvents.swift: realtime event routingDictationSessionController+Reconnect.swift: the bounded retry run behind a socket that drops mid-dictation
These jobs moved out of the view model (#432 steps 1–8). Each is reached through it or called as a pure step:
EnginesModel.swift: the Engines pane behindviewModel.engines. Backend modes, the Mistral key check and model catalog, managed warmup/shutdown, download controls (no session path)ShortcutController.swift:viewModel.shortcuts, hotkey registration and the push-to-talk gesturePermissionsCoordinator.swift:viewModel.permissions, accessibility and microphone authorization, and the startup probeSessionContextResolver.swift:viewModel.context, what the session may capture (screen, Claude join, socket pane) and the gates on eachPolishContextGatherer.swift+RepoVocabularyGrounding.swift: everything the commit gathers before it builds the request. Budgets, preparations, the repository vocabulary and the cross-source mergePolishRequestAssembler.swift: the request itself. Sections, pre-application, prompts, context blocks, provenancePolishOutcomeClassifier.swift(inlocalvoxtralCore): what a reply means for the commit. Placeholder integrity, and the failure copy for each errorStopCommitCoordinator.swift: everything in the stop-commit that decides what reaches the polisher. The transcript's preparation, the profile and templates, the pre-task sample (clipboard, screen, join, pane), the gather-assemble-send step, the overlay commit, the diagnostic recordTranscriptAccumulator.swift(inlocalvoxtralCore): the transcript the realtime events build. Partials, finals, the live insertion a final still owes, promotionSessionAudioPipeline.swift:viewModel.audio, meaning capture, the send and commit loops, ducking, the input device selection
Sources/localvoxtralCore (#432 step 9) holds what the app computes without
AppKit:
TranscriptAccumulator,TextMergingAlgorithms, the overlay text assembler andOverlayBufferStateMachine,PolishTokenGuard,ClipboardPayloadMacro,PolishOutcomeClassifier, the connection-failure classifier,SessionClock,TranscriptDiff- the vocabulary matching:
RepoVocabulary,RepoIndexing,RepoVocabularyMatcher,ClipboardVocabulary,DoubleMetaphone MistralStreamHealth,AudioChunkBuffer,ClaudeStatuslineCombine,FirstChunkPreprocessor,LaunchWindowPolicy,AppWindowOpener,POSIXPipeRead,PipeLineReader,OverlayStableLineWrapper,PolishContextExcerptSelector,PolishContextPreparation,LiveTerminalNewlineGuard,TUIAutocompleteTrailingSpace- the clipboard reader's rules (
PolishContextClipboardReader; its pasteboard half stays in the app) - the model catalogs (
BackendCatalog,SpeechModelCatalog,PolishModelCatalog) - which app an insertion targets (
PreferredTextInsertionTargetPolicy);TextInsertionServicestays in the app - a managed backend's status (
ManagedBackendStatus,ModelDownloadProgress) and the onboarding wizard's download items built from it (OnboardingBootstrapDriving);BackendManagerand the live driver stay in the app - the diagnostics report (
DiagnosticsSnapshot,DiagnosticsExporter); the snapshot it is built from (makeSnapshot, which reads settings and keys) stays in the app - the Settings sidebar's status dots (
SettingsStatusDot,IntegrationsSidebarStatus); their rendering stays in the app - the Claude session snapshot and its reducer (
ClaudeSessionState) - the overlay's dragged-position geometry (
OverlayManualPlacement,OverlayManualPlacementResolver); theNSScreenenumeration stays in the app DictationOutputMode, the dogfood control socket's grammar and the WAV source the e2e check dictates from (Dogfood/); the socket stays in the app- the config store (
AppConfigStore,BundledConfigDefaultHistory,SpeakerTerms). The app hands it the resource bundle, and on Linux it hashes withPortableSHA256instead of CryptoKit - the live replacement rewriters (
LiveReplacementCorrector,LiveHoldBackReplacementStream) - the learned terms:
LearnedTermStoreand the learner that fills it from the user's fixes (CorrectionLearning); the one-line notice it shows stays in the app - quick capture (
QuickCapture/, #725, #918): the project descriptions a classifier reads, the polishing model's routing mode and the Jev client, the rule that sends a low or tied answer to the catch-all, the context a first draft reads (QuickCaptureContext), the polishing model's first draft sorted by kind (QuickCaptureFirstDraft), the agent's check of an issue (QuickCaptureDrafter), and the Inbox (QuickCaptureInboxModel); the voice memo folder watcher and its ledger (#925), andRealtimeFileTranscriber, which streams a recorded file through a realtime client. Decoding the memo (AVFoundation) stays in the app - the Claude socket guard (
ClaudeSocketGuard:getpeereidandLOCAL_PEERPIDon Darwin,SO_PEERCREDon Linux), with the SHA-256 and HMAC helpers the Claude code hashes through - the ducking fade (
AudioDuckingController) and theSystemOutputVolumeControllingprotocol it writes through; the CoreAudio control stays in the app - the realtime clients: the
RealtimeClientprotocol, its event types and both websocket clients (#637). On Linux they speak throughFoundationNetworking, whose upgrade and cancel differ from Apple's; the base client's comments say how. Both report the audio each socket sent throughRealtimeUsageRecording, which the usage ledger implements. - the usage ledger (
UsageLedger, #837): one line per model call, charged to the feature that asked and the backend that answered, with Mistral's price table.
Sources/localvoxtralCore/ClaudeContext holds the part of the Claude context
path that needs no AppKit (#591): the join resolver and its arms, the session
registry and store, the broker and the remote listener, the herdr and cmux
clients, the ssh forward and enrollment, the repository collector and its
selection, the context blocks, and the plugin, statusline, opencode, Vibe and
Codex installers. The settings model, the forward coordinator and supervisor
(@Observable) and the --probe-surface command stay in
Sources/localvoxtral/ClaudeContext.
The core builds and tests on Linux (scripts/core-tests-linux.sh), and the
app re-exports it. Test doubles that need only the core live in
Tests/localvoxtralTestSupport, a library both test targets depend on. A
Linux test process that links it ignores SIGPIPE; Darwin suppresses SIGPIPE
per descriptor instead.
Key subsystems:
- Audio:
MicrophoneCaptureService(raw CoreAudio AUHAL → 16kHz PCM16),AudioChunkBuffer(Mutex),AudioCaptureHealthMonitor(device changes),AudioDuckingController+SystemOutputVolumeControl(fades other audio down for the session and back up on every path that ends it) - Realtime clients:
RealtimeClientprotocol;RealtimeAPIWebSocketClient(managed speechd / vLLM / any OpenAI-Realtime server) andMistralRealtimeWebSocketClient(Mistral API mode), both overBaseRealtimeWebSocketClient.DictationSessionController.activeRealtimeClientlatches one of them per session fromsettings.dictationBackendMode. When a socket drops on its own mid-dictation, the session retries it on a bounded backoff (RealtimeReconnectPolicy) against the endpoint/key/model snapshot it started on, and holds the gap's audio inAudioChunkBufferfor replay. agent/invariants.md lists what the retry may and may not touch - Text merge:
TextMergingAlgorithms(pure functions: overlap merge, word-boundary stabilization, punctuation spacing),FirstChunkPreprocessor - Insertion:
TextInsertionService(AX replace, falling back to Unicode CGEvents, then Cmd+V). Live Auto-Paste replacements run throughLiveHoldBackReplacementStreambefore typing; agent/invariants.md gives the latency it costs - Overlay:
OverlayBufferSessionCoordinator(session + hold-before-dismiss timing),OverlayBufferStateMachine,DictationOverlayController(NSPanel),OverlayManualPlacement(the dragged position, stored per display and re-validated against the attached ones before use) - Backend modes:
BackendModeis per engine:managedLocal,externalURL,mistralAPI. Only the managed mode runs a supervised helper. The two hosted modes are pure configuration (SettingsStore.resolvedWebSocketURL/llmPolishingConfigurationresolve endpoint, model, key and request shape) - Backends:
BackendManagerlazily prepares pinned Hugging Face snapshots and starts the bundled Swift helpers:localvoxtral-speechdfor ASR on port 8471 andlocalvoxtral-polishdfor polishing on port 8472. Supervisors spawn, health-check, and stop both managed processes. At launch, a cleanup removes retired app-managed backend artifacts from existing installs. The user-facing backend copy (pinned models, fork optimizations, vLLM example) lives in under-the-hood.md; keep it in sync when pins change. - Settings/config:
SettingsStore(UserDefaults, plus aSecretStoringseam,KeychainSecretStore, that keeps the three API keys out of the plist and in the login Keychain),AppConfigStore(TOML at~/Library/Application Support/localvoxtral/config) - Hotkey:
HotKeyManager(Carbon, single global hotkey) - Claude Code session context (
Sources/ClaudeContext*,Sources/localvoxtral*/ClaudeContext/,integrations/claude-code/): off-screen context for dictation into Claude Code. One marketplace holds two plugins. They are structurally separate, never modes of each other. Both declare hooks only (no skill/command/agent/statusLine, nothing that spends the user's tokens). The OPT-IN connection indicator for Claude Code's status line is user-wired, never plugin-declared. Locally, the user points their ownstatusLinesetting at the publisher binary's--statuslinemode. Remotely, they point it at a copy of the remote plugin'sstatusline.sh, which renders the outcome post.sh stamped for the last hook dial and never dials anything itself. - Local (
localvoxtral): each hook runslocalvoxtral-claude-hookas a CHILD, never throughexec. The shim must outlive a publisher that cannot start; withexec, the failure would become the hook's exit code and fail-open would stop being open. The hook publishes one bounded NDJSON line to a private AF_UNIX socket and fails open (silent exit 0) whenever the app is absent. In the app,ClaudeContextBrokerverifies the peer UID before reading. It only ever unlinks a socket it has PROVED stale by connect-probe, because a second live instance owns its socket legitimately. Thelocalvoxtralcommand (Sources/localvoxtral-cli, #721) asks on the same socket;AgentCLIServiceanswers from the history store, the learned terms and Settings (AgentCLIAppDataSource). -
Remote (
localvoxtral-remote, installed on the REMOTE host): command hooks run the bundled POSIX-sh shimhooks/post.sh, which curls the event JSON to127.0.0.1:<port>/v1/hook/<Event>through an OpenSSHRemoteForward. That host needs no localvoxtral binary and nojq/nc/Node, but it does needshandcurl(fail-open when absent).The remote port is PER-MAC (
ClaudeRemoteForwardPort: 28473–30472). It derives from a per-install identity persisted in a 0600 file beside the host registry, not in UserDefaults, so a preferences reset cannot move an enrolled host's port. The shim reads the port fromCLAUDE_PLUGIN_OPTION_PORT, validates it, and falls back to the legacy 8473 so pre-existing enrollments keep working. Two Macs asking one host for the same bind is not a tie. The FIRST connection keeps the forward, and the second silently delivers that host's events, and its bearer token, to the first Mac. The first Mac 401s them, and the shim reads that as a completed exchange (issue #215). Distinct ports make that state unreachable. What remains, stated in the enrollment notes, is that one host stores ONEport, so it talks to exactly one Mac. The Mac-side listener stays on 8473.The body stays Claude's verbatim JSON (there is no
jqto rewrite it with), so the allowlisted env enrichment rides asX-Lvx-Env-*HEADERS. It carries herdr/cmux/tmux/screen/zellij/bridge handles;SSH_TTY;SSH_CONNECTION(re-joined with commas, since space is outside the charset);LC_LVX_TTY; the shim's$PPID; and the basename of the session's repository's main checkout (the learned-terms key, #652).LC_LVX_TTYis the CLIENT's tty, which the user's shell exports and ssh'sSendEnv/AcceptEnv LC_*carries. It is the one value here that describes the Mac.The shim writes them into the same 0600 header file as the token, and charset-whitelists them (
[A-Za-z0-9._:/@+,=%-], ≤200 bytes) before a byte is written, so CR/LF injection is impossible by construction. The listener re-validates them and stores them asClaudeRemoteSessionEnvironment, NEVER inClaudeSessionSnapshot.process. agent/invariants.md explains the remote-opacity tradeoff.A remote project's terms (#641) are the one thing the Mac asks a host to run.
RemoteProjectTermRequestsmarks a joined session, the next hook's reply carriesX-Lvx-Terms: wanted, and the shim startshooks/terms.shdetached. Its answer comes back onPOST /v1/termsand is filed under the project the Mac recorded. Quick capture (#745) asks the same way (RemoteQuickCaptureRequests):X-Lvx-Readmefor a project's README summary andX-Lvx-Draftfor a routed capture's draft, both run byhooks/capture.shand answered on/v1/readme,/v1/draft/words,/v1/draft/context,/v1/draft/check(#918; an older shim uses/v1/draft/prompt) and/v1/draft.A per-host opt-in (
ClaudeRemoteForwardSupervisor+ClaudeRemoteForwardCoordinator, default off) lets the app hold that forward itself with a supervisedssh -N -R. It serves sessions a harness spawns on the host (t3 code,claude remote-control) and Claude Desktop, whose ssh clears every forward; none of these has an interactive terminal to hold the forward. That process usesExitOnForwardFailure=no, like the user's config block, and acts only on a refusal that names its own port. It inherits everyRemoteForwardthe alias declares, and a refusal of another one must not cost its own tunnel. It never setsClearAllForwardings: that clears the command-line-Rtoo, so the tunnel is never created (measured withssh -G). It forcesForkAfterAuthentication=no,ControlPath=noneandPermitLocalCommand=noso the user's own ssh config cannot detach, multiplex, or run a local command underneath it.A refused bind never enters the restart backoff, so there is no retry storm against a port somebody else holds. The forward parks and re-dials every 5 minutes, and first asks whether the port is OURS. A refused bind means only "somebody holds it". On a host the user actually ssh's to, that somebody is normally their own session carrying the same
RemoteForwardout of~/.ssh/config, which is the working tunnel.ClaudeRemoteForwardOwnershipChecktells the two apart. It sends a fresh nonce down the disputed port from the remote host and asks whether this Mac's own listener saw it arrive (ClaudeRemoteForwardProbeWitness). A status code would prove nothing, since a stranger can reproduce ours and a SECOND Mac answers an honest 401 of its own. Proof givesexternallyForwarded, which is not a failure; it is re-proved on a long park so a session that ends is noticed. Anything unproved isportUnavailable, on the same park. An ordinary drop backs off exponentially, and a run that stays up long enough to settle clears the failure count. On wake and on a network-path change (ClaudeRemoteForwardRecoveryTriggers) every failed or parked forward starts over. The listener always binds first and the forwards start second; stopping runs in the reverse order.After a transport-level failure the shim backs off for 5 minutes (epoch stamp under
$XDG_RUNTIME_DIR/~/.cache) for every event exceptUserPromptSubmit,SessionStartandSessionEnd. Each dial against a live forward with no app behind it makes the Mac-side ssh client printconnect_to …: failed.onto the user's terminal, as stderr the remote side can never redirect. Any completed HTTP exchange (even a 401) clears the backoff.The hooks were
type: "http"until 2026-07-27. Claude Code expands http-hook header${VAR}s from the process environment only and never injects plugin userConfig options there (verified on 2.1.220), so every hook authenticated asBearerand was 401'd. Command hooks are the only hook type that receivesCLAUDE_PLUGIN_OPTION_TOKEN. The shim keeps the token out of every argv (curl --header @tempfile, 0600, heredoc-written).The shim's stdout FAILS CLOSED, the mirror image of delivery failing open. It prints a 200 body only when it matches exactly the one body the listener can emit (
hookResponseBody, the constant{"suppressOutput":true}), on one line, size-capped. Anything else prints nothing. Claude Code appends command-hook stdout to the user's prompt when it is not control JSON (and toadditionalContextwhen it is), so whatever answers on 8473 must never be able to put a byte into the prompt (owner rule 2026-07-27).ClaudeRemoteContextListener(loopback-bound POSIX, dedicated port 8473; 8471/8472 remain the managed backends) authenticates the Bearer token before retaining a body againstClaudeRemoteHostRegistry(0600 atomic file, token hashes only, constant-time compare, immediate revoke/rotate). With no enrolled host, no port is bound.ClaudeRemoteEnrollmentServicegenerates the ssh-config snippet and theclaude plugincommands. Settings can apply either only after a second, explicit confirmation that repeats the exact text. - Shared:ClaudeSessionRegistry(Mutex, injected clock) holds the prior prompt, cwd, recent files and remote snippets, keyed by session id, which is the only handle there is. A hook reply is a RECEIPT (v+accepted) and the remote listener's body is a constant, so neither can put a byte on a terminal. The window-title marker that used to travel over the PTY back into Ghostty was removed on 2026-09-05 (see agent/invariants.md).The plain-ssh join arm (
ClaudeSessionJoinMechanism.remoteSSHConnection) replaced the one session shape that marker alone served. The focused surface's foreground ssh process has an established TCP socket (SSHProcessSocketReaderreads it out of this Mac's own kernel). That socket must name the same client port, server address and server port that the remote session reports through$SSH_CONNECTION. The session must also have been registered by a hook authenticated from the very host that ssh goes to. The arm authorizes session and repo context, never a screen read. It abstains on-J, on any multiplexer label (a tmux/screen/zellij/herdr server keeps the FIRST connection's$SSH_CONNECTION), on a ControlMaster-shaped neighbour, and on any ambiguity..remoteLocalTTYis tried BEFORE it and is the arm that serves the configs people have. The surface's tty must equal the$LC_LVX_TTYthe session reports. That value travels in ssh per SESSION CHANNEL, so it passes through ProxyJump and ControlMaster alike, and the arm needs no socket at all. agent/invariants.md lists what is deliberately not wired up yet. - LLM polish:LLMPolishingService(chat/completions client). In managed mode it talks to the bundledlocalvoxtral-polishdhelper (PolishHelper/package: MLX Swift inference + a minimal loopback OpenAI server + parent-pid watchdog), supervised like any managed backend on port 8472. It replaced the former mlx-lm helper after upstream became unmaintained (2026-07).