Skip to content

Latest commit

 

History

History
1839 lines (1675 loc) · 119 KB

README.md

File metadata and controls

1839 lines (1675 loc) · 119 KB

Hex Feature Map

What can the user do, how do they reach it, and what would prove it works? This maps existing behavior. ROADMAP.md owns future work; AGENTS.md owns implementation invariants.

Initial source baseline: September 1, 2026, 9e9da53aa9ed. Public macOS release: 2.3.1, with custom-domain downloads, a GitHub DMG mirror, and model-download recovery guidance. The initial map was source-only. Listed checks are locators unless an executed result is explicitly recorded, as in the keyboard-layout verification.

Product Map

Hex                                      // macOS release
├── Get ready                            // install, permissions, local model
├── Dictate                              // hold OR double-tap lock
├── Use the result                       // paste, paste-last, History
├── Adjust behavior                      // shortcut, microphone, recording preferences
├── Process text                         // Output tab + context-selected Modes
├── Opt in                               // Meetings: off by default
├── Recover                              // prove the next attempt works
└── Maintain the app                     // menu bar, login, updates, quit

Dictation, Meetings, and Recovery have detailed maps. Agents can read retained transcripts and control the running app with the release CLI. Labs is one switch, on by default, for every unfinished feature. Its Restore shortcut default toggle (off by default) puts a Restore default button beside Paste last dictation's shortcut (#613). The vocabulary biases transcription toward names you list. Annotate (macOS, Linux, and Windows) records the screen while you draw and talk and sends it to an agent. The other capabilities stay here until they need their own verification recipe.

Get Ready

Install the signed macOS app with brew install --cask anomalyco/tap/hex or the manual DMG. The cask uses a versioned, checksum-pinned DMG, requires Apple silicon/macOS 15+, and leaves updates to Hex's own updater. It does not require OpenCode, launch the app automatically, migrate the legacy Swift app, or forcibly replace an existing app destination. Homebrew uninstall leaves runtime settings, models, and retained data intact.

Download Hex                              // setup.download-recovery
├── anoma.ly/hex names the visitor's platform  // setup.download-button
│   ├── Mac -> DMG | Intel Mac -> disabled, "needs Apple silicon"
│   ├── Windows x64 / ARM64 (UA-CH) -> hex-setup-<version>-windows-<arch>.exe + SmartScreen note
│   ├── Linux -> copyable `curl … install-linux.sh | sh` + Arch/glibc requirement
│   ├── Phone -> Send yourself the link
│   └── Other platforms -> every build, Homebrew, the GitHub release
├── Website, README, installers, apps, feeds, appcast -> anoma.ly/hex/files -> R2 bytes (Worker hex-production-site)
├── Releases up to 2.3.1 -> downloads.hex.kitlangton.dev (Kit's frozen bucket) -> the same bytes
├── GitHub v<version> (app-v through 2.3.1) -> identical DMG, Setup.exe, and Linux bytes
└── Older app cannot reach its update feed -> manually install the DMG

Install on Windows                        // setup.windows-installer
├── Setup.exe, per user, no admin -> managed versions\<version>\hex.exe
│   ├── x64 without vulkan-1.dll -> stop with driver guidance
│   ├── another version installed (e.g. self-updated) -> install this one over it
│   └── finish -> Open Hex -> first-run tour downloads a model
└── Settings › Apps -> uninstall every version, shortcut, Launch at login; keep data

Install on Linux                          // setup.linux-installer
└── curl … | sh -> signed feed -> verified artifact -> launcher + hex.service
    └── cannot start -> name the glibc floor or the missing Arch packages

Prepare local models                      // separate network dependency
└── Dictation -> huggingface.co and its download CDN
    └── Transfer fails -> host-specific guidance -> restore access -> Retry

The website shows the pinned macOS version and platform requirements beside its download links. App mirrors use explicit v… release tags (app-v… through 2.3.1) rather than GitHub's repository-wide latest release, which may refer to the SDK. Builds on the release service update from https://anoma.ly/release (docs/releasing.md); the 2.3.1 feeds, appcast, and the legacy r2.dev endpoint stay frozen for installed clients. Hosting does not guarantee access through every corporate policy.

The site's hero is an animated, optional show: an idle head and an ⌥ keycap until the visitor presses it, then a voiced demo of messages, OpenCode prompts and fixes (WebGPU, with a Canvas 2D fallback). Audio loads only after that press; the website has no microphone button or microphone access. Reduced motion keeps a still until the press and then plays only the calm stage. See site authoring. Headless Chromium renders of the WebGPU, Canvas 2D, reduced-motion and phone paths showed no console errors; a deterministic 60 fps capture was reviewed frame by frame. These checks do not exercise real speakers or a physical phone.

The Windows installer is silently installed, reinstalled, installed over another version, and uninstalled on x64 and ARM64 runners by test-windows-installer.ps1; no interactive install, SmartScreen prompt, or first launch on a real PC has been observed. The Linux library diagnosis has no automated check. The site's platform states were captured once in headless Chromium with ?platform=.

Sources: website, installer, release publisher, mirror publisher, macOS release publisher, Linux installer, and updater. Model transfer failures in transcription_models.rs explain the source host, retry, and separate prerequisites. These messages do not diagnose a particular firewall or establish a successful subsequent model download. Executed hosting checks and deployment details are recorded in Download Hosting. In-app feed defaults and recovery copy ship with 2.1.17; the initial 2.1.16 mirror preserves that release's original bytes.

Launch Hex, setup incomplete              // setup.tour; full-window, own column
├── Upgrading from 2.3.1 or earlier (same `hex` folder)     // setup.upgrade; macOS
│   ├── `anomaly.hex` is a new identity: macOS kept none of the old grants
│   ├── Settings without `anomaly-hex-identity` in `migrations` -> checked once at launch
│   │   ├── Permissions or the selected model not ready -> the old completion
│   │   │   no longer counts -> the tour opens at Welcome
│   │   └── Everything ready -> no tour; nothing but the ID changes
│   └── Saved settings are kept; the ID is recorded either way
├── Tour opens -> speech model download starts at once      // setup.background-download
│   ├── Model -> the selection recommended for the system's first preferred
│   │            language (English -> Parakeet Unified English; e.g. French ->
│   │            Parakeet v3, Japanese -> Qwen3-ASR); no picker in the tour
│   ├── Progress sits in the top corner on every step (under the practice
│   │   field while Give it a try waits for it); failure -> Retry
│   └── Change or delete the model later in Settings      // setup.delete-model
├── macOS: opens centred at one fixed size, so tiling managers float it
├── Quit part-way -> next launch resumes at the saved step  // setup.resume
│   └── Permissions are read again at launch: a setup they now finish (macOS's
│       Quit & Reopen after a grant) is passed; past setup, it waits until they do
├── Welcome -> Get started                // setup.welcome-hero
│   ├── The Hex glyph backlit by a cool corona, drawn live on the GPU
│   │   (`rune_ui/hero.rs`); core, middle ring, outer ring light in turn,
│   │   then the lamp breathes. Light theme: ink rings over a pale blue glow
│   └── Reduce Motion -> the settled frame, lamp at rest
├── Setup: one screen, every permission   // setup.checklist; skipped when granted
│   ├── Each row leads with its glyph: mic, text cursor, sparkle (model)
│   ├── No Input Monitoring row: Accessibility also lets the shortcut taps listen
│   ├── Every row -> Allow; asked -> pulse + Allow; granted -> Allowed  // setup.permission-control
│   │   └── Grant: the pill narrows into the green label, the dot shrinks into
│   │       the check as it draws on (0.3 s, no bounce; Reduce Motion: no travel)
│   ├── Microphone -> macOS prompt; refused -> Privacy pane + "switch Hex on" guide
│   ├── Accessibility -> Privacy pane + drag guide
│   │   ├── The app tile lifts out of the row and flies, blurred, into the guide
│   │   └── macOS 27 names the list "Device Control and Data Access"; row and guide follow
│   ├── Rows re-read every 400 ms and flip to a check as macOS grants
│   │   └── Accessibility flips live (`AXIsProcessTrusted`); its stale preflight never asks for a restart
│   ├── On in System Settings, unusable until relaunch -> "Turns on when Hex restarts"
│   │   └── Every permission on -> primary "Restart Hex" -> relaunch at setup  // setup.restart
│   ├── The first unfinished row carries the screen's one accent
│   └── Last grant -> 0.7 s beat -> Give it a try (Continue also works)
├── Give it a try                         // setup.practice; every OS (rune_ui/practice.rs)
│   ├── Model still downloading -> "Almost ready" + "Hex starts listening once…";
│   │   progress (or Download stopped + Retry) under the field, not in the corner;
│   │   model ready -> the normal Give it a try heading, body, and hint
│   │   // check: try_it_waits_for_the_model_until_it_is_ready
│   ├── Focused practice field; hold the shortcut, speak, release
│   ├── Started -> "Listening…" ; Transcribing -> "Transcribing…"
│   ├── Discarded before transcribing -> quick-tap hint
│   ├── Discarded after transcribing -> nothing-heard hint (mic, distance)
│   ├── Microphone line under the hint: input in use + live level while recording
│   │   ├── Click -> General's microphone menu; a choice saves the same setting
│   │   ├── 1.5 s of silence while recording -> "No sound. Pick another mic?"
│   │   └── Lid closed, no other input -> "The lid is closed, so the built-in mic is off."
│   ├── Pasted -> words land in the field + success -> Continue
│   └── Skip -> Done                      // practice is never required
├── Listener cannot start after setup     // setup.relaunch; event tap refused
│   ├── First time -> Hex records a marker, reopens itself, resumes at Give it a try
│   └── Within 2 min of that relaunch -> "Reopen Hex" button; dev binaries ask to reopen
└── Done -> paste last, Modes tips -> Start using Hex
    // Windows and Linux: Welcome -> Setup (the model row only) -> Give it a try -> Done

General > Speech model or Language
  -> Prepare candidate
     ├── Success -> Save selection
     └── Failure -> Preserve previous selection and show error

Permissions revoked later
  -> General > Permissions needed: the same rows and Allow control as the tour
     └── On, waiting for a relaunch -> "Restart Hex"

Apple silicon, macOS 15+. Choices come from the compiled model/language catalog; Apple Speech is currently excluded from the desktop picker.

General > Dictation > Speech model, then Language below it   // setup.language-select; every desktop root
├── Speech model -> Picker lists every available model; never changes the language
│   ├── Saved language's recommendations first with their tags, then the
│   │   models that support it, then those that do not
│   ├── Model without the saved language -> Card: "Doesn't support Japanese;
│   │   switches the language to English"
│   └── Choose it -> ChooseModel(model, model's default: English, else its first)
│       └── Saved -> Language row: "<Model> doesn't support Japanese, so the
│           language changed to English" until the next choice
├── Language -> Select of the saved model's languages (catalog order, Auto first
│   when it detects); one language -> the field shows it, disabled
│   └── Choose -> ChooseModel(same model, language): the same preparation as a
│       model choice, so per-language assets (Apple Speech locales) install
└── The saved TranscriptionSelection is always a pair the model supports

Sources: shared picker (choose, choose_language, language_row) and picker_models, language_choices, language_for in transcription_models.rs. Checks: the_picker_lists_every_model_with_the_language_s_recommendations_first, a_model_switch_keeps_a_supported_language_or_falls_back_to_the_model_s_default, each_model_offers_exactly_the_languages_it_supports, and the Rune tests a_model_without_the_saved_language_falls_back_to_its_own_visibly, browsing_the_picker_never_changes_the_language, changing_only_the_language_prepares_the_same_model_again, and settings_opens_the_picker_and_names_the_model_and_language_apart. Scenes: picker-japanese, language-fallback, language-menu. Fixture renders and recorded requests only; a live model switch with a fallback has not been observed. Apple Speech stays excluded, so its per-locale install through the Language row is unexercised.

Choose a supported language              // setup.language-conditioning; macOS + Linux picker
├── Parakeet v3 -> Detect language from audio; selected language does not constrain output
├── Whisper -> Use selected language to guide decoding; accuracy is not guaranteed
│   └── Recognition hints -> initial prompt; output that only repeats them is dropped
├── Qwen3-ASR 0.6B, QwenCleo-ASR 1.7B -> Recognition hints -> context prompt in the
│   system message; output that only repeats them is dropped
└── Bangla -> Whisper only, with the `bn` hint; Auto lets Whisper guess per clip

Whisper reads the recognition hints as its initial prompt and Qwen3-ASR (including QwenCleo) as the context prompt in its system message (#129); apply_recognition_hints in gguf_session.rs picks the slot on every OS, so dictation, meetings, and the local API on the listener's model all get them. Live streams run without them. Observed on apollo (Vulkan, 2026-10-31, LibriSpeech test-clean): with Qwen3-ASR 0.6B, hints "Kaffar, Rodolfo, Bragelonne, …" fixed Caffer, Rudolpho, Redolfo, Rudolph, and Bragolon in 10 clips without changing the others checked. Both families, on silence or noise, often return the hints verbatim (Qwen3-ASR 0.6B did on 3 s of silence and of noise). A segment whose text equals the hints after normalization (case, ASCII punctuation, spelled-out digits; commas, semicolons and line breaks separate words) is removed, in dictation, meetings, and the local API alike. The filter needs at least two hints, so dictating the one listed name still pastes it; speech that contains a hint among other words is kept, and so is a repeat of only some hints. Checks: prompt_echo_is_dropped, speech_containing_the_hints_is_kept, only_the_echo_segment_is_removed, single_hint_dictation_is_kept, and hints_without_spaces_after_commas_are_matched in gguf_session.rs.

Whisper, Qwen3-ASR 0.6B, and QwenCleo-ASR 1.7B take recognition hints, and Settings shows the field only for them. Saved hints for a model that can't use them (for example Parakeet Unified English, set by hand in settings.json) are cleared with a logged warning when settings load, rather than failing the whole load and leaving the listener off (#448). Check: loading_clears_recognition_hints_the_model_does_not_support in app_settings.rs.

Settings picker or setup > model card     // setup.model-card; shared by every desktop root
├── Title row -> Name, recommendation tag, "Streams" tag    // at most two tags
│   ├── Recommendation -> For the saved language from the catalog (English: Unified English first)
│   └── Streams -> Catalog `streams` (pinned artifact advertises streaming)
├── Detail line -> "Transcribes while you speak" (non-macOS: "… on a GPU"),
│                  coverage, benchmark, timestamps, Installed
├── Saved language unsupported -> Warning line naming the language it switches to
├── Trailing edge -> Active as dot + label; Cancel while preparing; else Use/Download
├── Preparing -> Progress bar with its "Downloading 37%" / "Loading model" label
└── Speed · Size · Accuracy -> 100% minus the benchmark word error rate

Only Parakeet Unified English is marked streams (only_the_buffered_parakeet_artifact_is_marked_streaming). The mark describes the artifact; dictation still streams only after the load probe succeeds on a GPU device (Streaming Finish). The menu bar lists names only and shows no tags. Scenes: hex app -- --snap OUT --scene picker (also picker-downloading, picker-japanese).

Parakeet v3 remains available for Portuguese and its other existing language choices. Its card explains the limitation and suggests Whisper for language-guided transcription. This does not change saved selections or expand the Auto picker choices. Multilingual audio support is not language conditioning: the pinned v3 GGUF has no stt.parakeet.prompt.num_prompts metadata, which gates language prompting in transcribe-cpp-sys 0.1.3.

Sources: model catalog, shared picker, and the runtime options in parakeet.rs and local_transcriber.rs. The regression parakeet_v3_supports_portuguese_without_language_conditioning checks that the Portuguese selection remains valid and available, sends no runtime language hint, and contrasts Whisper's pt hint. It does not run inference. The regression was observed failing with the old metadata and passing after the correction. The picker's layout is checked by Rune goldens (rune-picker-*), not on a Mac screen.

Unreproduced recognition report: #68 reports Portuguese speech misrecognized as English. Source and pinned-model metadata inspection establish the prompting limitation, not the cause or repair of those transcripts. No v3 audio reproduction or transcription-quality fix has been demonstrated.

Bangla (bn) is offered because Whisper large-v3-turbo was trained on it; no other catalog model supports it, so Whisper is its only, recommended choice and a Mac whose first preferred language is Bangla starts on Whisper. Auto is not a substitute: in a manual test, Whisper's per-clip detection labeled Bangla speech as Arabic, Turkish, and Spanish. bangla_is_transcribed_by_whisper_with_its_language_hint checks the recommendation, the bn hint, and that Qwen3-ASR rejects it. It runs no inference, and Bangla accuracy has not been measured.

Checks start in onboarding.rs, transcription.rs, and transcription_models.rs. Setup/picker previews prove rendering, not clean-account permission grants or model preparation.

The tour is the pure onboarding::Flow (steps, row states, background download, practice hints, one-relaunch guard) drawn by rune_ui/onboarding.rs; permission_control in rune_ui/lists.rs is the one Allow / dot / Allowed control Settings and the tour share (a_grant_gathers_then_releases_into_allowed, reduce_motion_swaps_allow_for_allowed_at_once; scene meetings-allow). The welcome hero is a compositor pass after the UI, so goldens show its stage empty; hex app -- --scene onboarding-hero (-still for Reduce Motion) runs it live, and --video records it. Its springs passed motion-lint as level channels; native visual review is still by eye. The guide beside System Settings is permission_guide.m. On Windows the setup step adds a Microphone row only while the microphone switch in Settings › Privacy & security is off (device, user, or desktop apps; CapabilityAccessManager\ConsentStore\microphone says Deny). Allow opens ms-settings:privacy-microphone, the row describes the "Let desktop apps access your microphone" switch, and setup finishes once it is on (a_switched_off_windows_microphone_adds_its_row_until_setup_ends; the registry read is cross-compiled only, not run on a Windows PC). Unit tests (onboarding::tests) cover step order, the download starting on the welcome screen and retrying from any step, permissions-only setup with the model waiting for practice, the auto-advance beat, live row flips, a refused microphone, platforms without permissions or practice, practice hints, resume, the relaunch guard and resume marker, resuming past a setup the relaunch finished (resuming_at_setup_after_a_relaunch_passes_grants_that_are_now_real), grants waiting for a restart (a_grant_the_process_cannot_use_yet_waits_for_a_restart, setup_offers_a_restart_once_every_permission_is_on), an Accessibility grant advancing setup without one (an_accessibility_grant_applies_without_a_restart), and the saved step (the_tour_step_is_saved_for_the_next_launch), and the upgrade check (a_fresh_install_onboards_without_the_identity_check, an_upgrader_without_permissions_gets_the_tour_once, an_upgrader_whose_setup_is_ready_skips_the_tour; a real 2.3.1 to anomaly.hex upgrade on a Mac is not yet observed). Rune tests drive the tour (the_mac_tour_asks_each_permission_then_practices, a_listener_that_will_not_start_relaunches_once_then_asks_to_reopen, a_refused_microphone_points_to_system_settings, a_grant_waiting_for_a_restart_makes_setup_restart_hex, onboarding_downloads_the_model_through_the_service), and every macOS step is a scene (mac-onboarding, -setup, -progress, -denied, -restart, -download-failed, -try, -listening, -landed, -reopen, -done). Reaching the last step in a live tour plays the onboarding success sound (Classic: its stop sound) (remember_step; fixtures stay silent), checked by reading the code. The default model comes from desktop_i18n::preferred_language (macOS: NSLocale.preferredLanguages) through recommended_selection; existing settings keep their saved model. Not yet observed: a clean-account run with real grants, a real practice paste into the field, or a real self-relaunch after an event-tap refusal. Windows and Linux show the model row only and skip the practice step.

Menu bar > Transcription Model            // setup.quick-model-switch; macOS
├── Downloaded models -> Last selected language shown beside each model
├── Choose -> Verify installed artifact -> Prewarm -> Save selection
│   ├── Success -> Checkmark moves; Settings reflects the saved selection
│   └── Failure -> Previous selection remains; open Settings for the error
├── Preparing -> Cancel Model Switch      // never cancels dictation/output jobs
└── Manage Models… -> Settings picker      // downloads; languages in the Language row

The menu does not open Settings merely to switch models, download a missing model, or replace a corrupt artifact. Each model retains one last-used language/hints selection in transcription_recents; the current selection takes precedence. Older settings start with no remembered alternatives. Select an alternative's language in Settings' Language row once to establish it. No additional model is loaded at startup just to populate the menu, and the checkmark means selected, not live-worker readiness.

transcription_preparation.rs is shared by Settings and the menu bar: one worker plus one replaceable pending choice. Closing Settings does not abandon preparation. A superseded/cancelled completion cannot save, and delivered menu controls are drained before accepting completions. The desktop root commits through an existing editor to retain unrelated unsaved settings, or through persisted settings when no window exists.

Sources: status_item.rs and macos_rune.rs. Key checks are preparation_has_one_worker_and_only_keeps_the_latest_pending_choice, cancelling_a_completed_unpolled_preparation_never_commits_it, model_switches_remember_language_and_hints_without_changing_other_settings, and failed_model_selection_save_preserves_selection_recents_and_unsaved_edits in the preparation owner and app_settings.rs. Additional checks cover worker failure/retry, installed-only checksum verification, and menu choice/tag projection. These do not establish a physical menu click or an installed-app switch. Remembering a language does not change the language conditioning limitations described above.

Observed September 4, 2026 (2.1.16), existing model picker: all 455 Rust tests passed in debug and release, with ten opt-in tests skipped per profile. All twelve keyboard-layout child scenarios passed per profile; formatting, strict all-target/all-feature Clippy in both profiles, and the release build passed. The Cohere fixture inference check was run separately; see dictation model windows. Native menu interaction, installed-app switching, and Linux runtime behavior were not exercised.

Switch models with keyboard language

Labs > Switch model with keyboard language      // setup.input-source-model; macOS, Windows, Linux X11; off by default; General shows the mappings while on
├── Each enabled keyboard input source -> Don't switch, or a downloaded model and language
│   ├── New mapping -> The source's own language when the model has it
│   ├── One-language model -> Its language shown fixed
│   └── New/unmapped source -> Don't switch; keep the current model
├── Source changes (shortcut, Input menu, automatic app/document switch)
│   └── 300 ms after the last change -> Existing installed-only picker preparation
│       ├── Same model and language already selected -> Nothing loads
│       ├── Success -> Save the selection, keeping the model's remembered hints
│       └── Failure -> Existing picker error; no download or guessed fallback
├── Manual model choice -> Supersede pending automatic work; do not reapply at recording start
├── Enable -> Apply the current source's mapping once it is observed
├── Edit the active mapping -> Apply now
├── Disable -> Cancel automatic work; stop observing; leave the selected model alone
└── Relaunch -> Observe current source without applying it; preserve the manual choice

Mappings are keyed by native input-source IDs (macOS input-source IDs, Windows keyboard layout handles, X11 XKB layouts such as de(neo)), not inferred speech languages or physical keyboard keys, and store a model with a language it supports. Only downloaded models from the picker are offered (Apple Speech is never mapped); downloads stay in the picker. A mapped model that is no longer downloaded is labeled Not installed and can be cleared; a mapping to a model or language Hex no longer offers is dropped when settings load. Removing a source hides its row without erasing its saved mapping.

Hex reads input sources only while the setting is on: the macOS main-thread TIS distributed-notification observer, the Windows runtime's poll of the foreground window's layout, and the Linux host's XKB connection (polled every 250 ms) exist only then and work without a Settings window; the window lists enabled sources only then. Windows lists the language bar's layouts, named by language and keyboard. On Linux the row appears only in X11 sessions: Wayland compositors keep the active layout to themselves, with no general protocol (IBus and Fcitx report input-method engines, not the compositor's layout), so Wayland is unavailable. Source changes cancel obsolete automatic work but keep manual preparation until a mapped choice is due. An unmapped source cancels only automatic work. This reuses the shared model selection, recording/stream fallback, and meeting-engine behavior; it adds no recording-time routing, model cache, or notifications.

Sources: observers for macOS, Windows, and X11, switch policy, runtimes (macOS, Windows, Linux), and settings UI. Checks: the switch policy and settings/CLI validation tests, the runtime scheduling tests in macos_rune/tests.rs (macOS), the settings interaction tests in keyboard_model_tests.rs, and the mac-input-source-models golden, and the XKB symbols-name tests in the X11 observer. No check delivers native input-source notifications or switches sources through the Input menu or a shortcut.

Observed October 30, 2026, X11 observer: on a headless Xvnc with setxkbmap -layout us,ru,de -variant ,,neo, Hex listed English (US), Russian, and German (Neo 2) as us, ru, de(neo) with languages en, ru, de, and reported us, then ru after an XKB group lock. The Windows observer and both new runtime paths compile (x64 clippy) but were not run on Windows or a live X11 desktop.

Delete downloaded models

Settings picker > Downloaded model card   // setup.delete-model; macOS
├── Delete -> "Delete <size>?" -> Delete again
│   ├── Success -> Model file, receipt, and partial download removed; card offers Download
│   └── Download in progress -> Nothing removed; "Model could not be deleted." with reason
├── Selected dictation model (any language) -> No Delete
├── Preparing or Apple Speech model -> No Delete
└── Model choice or closing the picker -> Confirmation cleared

Deletion takes the shared model-directory download lock without waiting, so it cannot race a Settings, menu-bar, or local API download. The menu bar drops a deleted model because it reads installation state when it builds the menu. Linux Settings does not offer deletion: the service owns model files and has no delete request yet.

Sources: shared picker and remove_installed in transcription_models.rs. The check is removing_a_model_deletes_its_artifacts_unless_a_download_is_active (real files and lock); no Rune test covers the picker's two-step confirmation yet.

Known gap: is_installed checks size, not checksum. Not every native-load path requires verification. Picker Active means selected/installed, not live worker readiness; a later worker-load failure does not roll back saved selection.

Dictate

Saved shortcut                           // dictation; Option by default; Windows and X11 accept mouse buttons, macOS with Labs > Mouse shortcuts
├── Hold -> Speak -> Release
├── Tap, tap -> Speak hands-free -> Press again   // Double-tap to lock enabled
└── Escape
    ├── Capture active -> Cancel that capture
    └── Otherwise -> Cancel newest cancellable job

General > Advanced > Double-tap only     // optional for key bindings
  -> Disable single-hold activation
  -> Keep the double-tap hands-free path

Labs > Second dictation shortcut (macOS, off)   // labs.md; issue 627
  -> General shows "Second dictation shortcut" under Shortcut (unbound, clearable)
  -> it dictates like the shortcut: Hold/Toggle, double-tap lock, Escape, release
  -> the shortcut that starts a capture finishes it; the other stands down meanwhile

Labs > Unload model when idle (off; 5/15/30/60 min, default 15)   // labs.md; transcription_engine
  -> nothing used the model that long and nothing records -> the model unloads
  -> next press, meeting chunk, or API request -> reloads first; audio waits, nothing lost

The detailed map covers timing, successive captures, output commitment, named checks, and native-proof gaps. The macOS CLI entry is hex listen, with an optional authoritative --device. Do not start it beside an existing listener.

Use The Result

Completed output                         // output
├── Paste -> Current focus, in submission order
│   ├── Previous clipboard restored 500 ms later, unless something newer replaced it
│   ├── Labs > Keep dictation on clipboard (macOS, off) -> the dictation stays on the clipboard (labs.md)
│   └── Labs > Copy dictation only (macOS, off) -> clipboard only: no paste, typing, or restore (labs.md)
├── Paste Last Dictation                  // menu or Option-Shift-V / saved binding
│   └── Last completed dictation
└── History
    ├── Inspect / search / copy (row hover or the card) / delete
    ├── Retention ("Keep: 7 days" menu) -> Seven days by default, always hard-capped
    ├── Off -> Stop new entries; preserve existing entries

Checks in paste.rs, parakeet.rs, and history.rs cover output and retention boundaries. They do not prove that an arbitrary target consumed its clipboard contents. Send is not an atomic target-app transaction. Chromium 155 and 156 get the text typed instead of pasted for a Chrome paste bug.

Easy to misread: History Off does not stop diagnostic logging. Diagnostic logs are separate and currently unbounded on disk. See the privacy guide.

The shared EventReader streams a file-length snapshot in 16 KiB chunks rather than copying the entire log into memory. It retains up to 1,024 event rows and the latest context, even when that context predates the retained rows. Diagnostic records over 1 MiB are skipped through their terminating newline; the original log and dictation output remain intact. Partial UTF-8 records carry over to the next refresh, and truncation or file replacement resets the reader.

Checks: the reader_* regressions in events.rs. These exercise synthetic files, not an installed app. The GPUI window's on-demand-loading regression went with GPUI; when the Rune window reads the log is not covered by a test. A first Commands/Activity refresh still scans the full log snapshot; disk rotation and background parsing remain separate work.

Observed September 26, 2026: the reader and pane-loading regressions passed. An optimized isolated harness using the production reader and a synthetic 64 MiB log reduced peak process RSS from about 204 MB to 2.4 MB; refresh took 254 ms before and 222 ms after. This is reader evidence, not a native Settings latency measurement.

Adjust Behavior

Sidebar                                  // settings.nav
├── General, Output, History, then each feature that is on: Meetings and
│   Voice Filter (Labs toggles), Annotate, Playground (Labs, an item)
└── Labs -> one flat page of toggles, each off by default (labs.md)
    // Command-1…9 follow the panes' order (labs::order)

General                                  // settings
├── Basics card -> Shortcut, Speech model, Language, Microphone, Launch at login
│   ├── Each row leads with an icon; choices are inline dropdowns, not segments
│   ├── Language -> fixed text for a one-language model, else a dropdown
│   └── A row that hides or shows (Advanced, a microphone error, Double-tap only)
│       -> Folds under a clip as the rows below slide over it; no fade in place
├── Microphone -> Value names the input in use, with live level bars // settings.microphone
│   ├── "System default · Kit Pods", or a saved device
│   ├── Level bars -> peak of the audio Hex receives, ~30 fps while General shows them above rest // input_level
│   │   ├── Read from one atomic the audio owner stores per chunk; never opens or blocks the mic
│   │   ├── Quiet room (bars at rest) -> no 30 fps frames; the window's 250 ms tick notices a rise.
│   │   │   An open General window with a warm mic woke 34.5 times a second in a quiet room before
│   │   │   (dev-364, headless sway on apollo, Oct 31) and 4.1 after; macOS was reasoned about only
│   │   ├── No audio for 250 ms (released while idle, failed, not allowed) -> flat, dimmed
│   │   └── Scenes: mac-settings-mic-level (a recorded WAV, `--video`), mac-settings-mic-released
│   │       // check: the_microphone_level_follows_the_voice (Linux)
│   ├── Re-listed on a device or default change while General shows (Windows: also every 3 s)
│   ├── System default -> The OS default input, followed live
│   │   ├── Default changes -> Switch once capture is idle; new stream opens before the old drops
│   │   ├── Default is Hex Microphone -> Built-in mic, else first real input; row says so
│   │   ├── Stand-ins never include virtual or aggregate inputs (macOS: e.g. Microsoft Teams Audio)
│   │   │   ├── Nothing real left -> "lid is closed" / "no microphone" error, not silence
│   │   │   └── A virtual default, saved device, or `--device` is still used as named
│   │   └── Default is Bluetooth (Advanced switch on) -> Built-in mic, else first wired/USB input
│   │       ├── Row says "KIT PODS is Bluetooth, so Hex listens to MacBook Pro Microphone"
│   │       ├── Windows: WASAPI `BTHENUM`, or a headset/handset endpoint of unknown bus (`BTHHFENUM`)
│   │       ├── Linux: a `bluez5` default source; Hex opens ALSA's default, then moves its own
│   │       │   stream to the PCI input, else the first other hardware source (pactl, PipeWire);
│   │       │   the stream is marked so WirePlumber never remembers the move. A Bluetooth
│   │       │   default chosen while Hex's stream is open is followed until the next open
│   │       ├── No other usable input (or lid closed, Bluetooth only) -> the Bluetooth default
│   │       └── A saved Bluetooth mic or `--device` is always used as named
│   ├── Saved device missing -> System default stands in, value names it
│   │   ├── Row says "SHURE MV7 isn't connected; using your Mac's input"
│   │   ├── Menu keeps "SHURE MV7" checked with a faint "not connected"
│   │   └── Device returns -> Switch back once idle; the setting never changes
│   ├── Menu lists real inputs only                                // never Hex Microphone
│   └── Open menu covers the controls beneath it; click elsewhere dismisses
├── Advanced ▸ -> one fold per pane, closed by default, even while a setting in it is in use
│   ├── Collapse -> drops a pending release confirmation
│   ├── Dictation -> Shortcut side, Shortcut mode (How it works), Double-tap to lock,
│   │   Double-tap only, Microphone mode, keyboard-language mappings (while its Labs toggle is on),
│   │   Use the Mac's microphone instead of Bluetooth (Linux, Windows: "Use another
│   │   microphone instead of Bluetooth"),
│   │   Recognition hints
│   ├── Output -> Paste last shortcut, While dictating, Dictation HUD, Terminal paste (Linux)
│   │   (Dictate without processing sits in the Output pane; see dictation.md)
│   └── Application -> Enable on launch, Show Dock icon, Sound volume, Sounds,
│       Interface language, Update channel (self-updating installs)
├── Use the Mac's / another microphone instead of Bluetooth (Advanced, on)
│   └── Off -> System default follows a Bluetooth default too; re-resolves once idle
├── Labs > Share dictation shortcut with other apps (Windows, off)
│   └── On -> the hook passes the shortcut's key-down/up on instead of consuming it
├── Terminal paste (Advanced on Linux, Labs on Windows; off)
│   └── On -> paste sends Ctrl+Shift+V instead of Ctrl+V; applies to the next paste
├── Microphone mode (Advanced)
│   ├── Keep ready (fast) -> Open while idle; pre-roll available
│   └── Release when idle -> Confirm -> Open on press; no pre-roll; startup delay
├── While dictating -> Mute / Pause media / Lower audio / Do nothing
│   ├── Lower audio -> Lower to slider, 0–100% of the previous volume (default 20%)
│   ├── Intentional capture only, not ordinary shortcut chords
│   ├── Pause/resume only players already reported running by macOS
│   ├── Linux restore -> Waits 300 ms; a new capture keeps it suppressed // double-tap lock
│   └── Labs > Also pause the browser (macOS, Pause media only; off by default); its permission row shows here while on
│       ├── Turning it on without System Audio Recording -> ask now, never while dictating
│       ├── On -> Screen & System Audio Recording row: Allow / waiting / Allowed
│       └── Dictation -> browser_media sequence below
├── Sound volume -> immediate feedback; zero suppresses tones
│   └── Default output changes or disappears (macOS) -> next tone reopens on the current default
├── Sounds -> Sheet: hear each set on Hex's real HUD, pick one
└── Linux / Windows -> listener card and desktop commands below the basics

Pause media + Also pause the browser     // browser_media
├── A browser uses the microphone (a call) -> leave it alone
├── No browser process has its audio output open -> leave it alone (no tap)
├── Listen to those processes' own output (private process tap), 3 windows of 100 ms
│   └── Any window at or below -60 dBFS, or no level -> leave it alone
├── Send the system pause command (MediaRemote 1; never the toggle)
├── Poll 5 windows of 100 ms
│   ├── 2 windows in a row below -90 dBFS -> Hex paused it
│   └── Otherwise (another tab, a sound that ended later, or the pause went elsewhere) -> no play later
└── Dictation ends and Hex paused it -> first, before the players: listen 300 ms to every browser process
    ├── Above -60 dBFS (you started it again) -> no play
    ├── No level, a browser runs and every one has closed its output -> still paused: play
    │   (a pause longer than about 10 s closes the output; a tap on it delivers nothing)
    ├── No level otherwise (browser quit, tap failed) -> no play
    └── Otherwise -> send the system play command (MediaRemote 0), then poll 20 windows of 100 ms
        ├── A window above -60 dBFS -> resumed
        ├── No level before the first level (the output is opening again) -> keep polling
        ├── No level after a level -> stop listening
        ├── No browser output open after 10 windows (tab closed: the play reached another app) -> send pause once
        ├── Never a level and a browser opened its output -> stop listening
        └── Silent throughout (tab closed or reloaded: the play reached another app) -> send pause once

Intentional recording                    // recording.environment, idle_sleep
├── Prevent idle sleep
│   ├── macOS -> IOKit no-idle-sleep assertion
│   ├── Linux -> logind `idle:sleep` inhibitor; `idle` only where polkit refuses a
│   │   process outside the seat session (Hex's user service) to block sleep
│   ├── Windows -> system-required power request (from the press, as its While dictating does)
│   └── Failure -> Warn and continue recording
└── Finish/cancel -> Release it
    // no caffeinate child process or executable-policy dialog

Feedback tones keep their output open for five idle minutes. On macOS the CoreAudio listeners on the default output, the device list, and an audio service restart advance an output route generation; the next tone drops an output opened under an older one and opens the current default at once, so unplugging a USB or Bluetooth output no longer silences tones until Hex restarts (#428). Check: a_route_change_reopens_the_output_on_the_current_default in feedback.rs, with injected opens; a real unplug has not been observed by a test.

Lower audio (recording.environment.duck) is an explicit fourth choice, off by default, on every OS. The slider appears only while selected and saves on release. It remembers its value across behavior changes. Intentional captures ramp the original output channels to that fraction over one second, preserving balance and silent channels, then restore on finish, cancel, or orderly shutdown. Changes apply to the next capture. The stop tone waits for restoration. A manually changed volume after settling is left alone; a newly selected output device is not lowered. Fixed-volume devices and outputs whose channel balance cannot be safely restored are left unchanged. Meetings do not acquire this dictation environment. Linux lowers the default sink's channels through pactl (one volume through wpctl without it), so Hex's own tones are lowered too and the stop tone waits, as on macOS. Windows lowers every other app's audio session instead, leaving Hex's tones at full volume; sessions that start while recording are left alone.

HEX snapshots the volumes and applies a smooth ramp in output_ducking.rs over the CoreAudio adapter in ducking.rs, the sink adapter in linux_audio_control.rs, and the session adapter in windows_audio_control.rs. The worker runs inside the app and cannot restore volume after a hard process crash or SIGKILL. Changes during the ramp itself cannot be distinguished from delayed Bluetooth acknowledgements. Fake-device tests cover relative volume, balance, silence, interrupted ramps, manual changes after settling, and partial-write rollback; settings/UI tests cover defaults, bounds, persistence, conditional visibility and platform gating. On 2026-10-25, the macOS logic/settings checks and strict Clippy passed. The seven reference images were regenerated on Ubuntu 24.04 ARM64 with Mesa lavapipe, using the unmodified Linux-only Rune test module and HEX_UPDATE_GOLDENS=1 scripts/check ducking. A subsequent scripts/check rune_ui --nocapture without the update flag passed all 113 tests, including the unchanged control goldens; no golden skipped for lack of a GPU adapter. The five existing images differ from the base branch above the existing 12-level channel tolerance only within the While dictating control; the sidebar, Quit Hex, and shortcut layouts remain unchanged. Dark and light ducking previews were visually inspected. The earlier macOS-generated references were replaced because these fixtures retain build-OS-dependent layout and defaults. This verifies headless Linux rendering, not audible playback, physical devices, crash recovery, or native Windows execution.

The Linux and Windows ports (2026-10-30) were checked by unit tests (pactl and wpctl volume parsing, the Linux Bluetooth stand-in choice), the Settings test that Lower audio and its slider appear on every OS, Linux Clippy, and Windows x86_64 cross-Clippy. On apollo (niri, PipeWire), the ignored native_idle_sleep_prevention_acquires_and_releases took a logind idle inhibitor (polkit refused idle:sleep over SSH), and a marked arecord stream moved with pactl move-source-output without WirePlumber storing the target. Not verified: audible Linux or Windows ducking, Windows idle-sleep and Bluetooth detection on hardware, a real Bluetooth headset on Linux, and Windows ARM64 (its cross-build stops in rquickjs-sys on this host).

Successful settings saves persist and project changes at safe runtime boundaries. Immediate settings controls commit a candidate before changing the saved UI choice or applying dependent native/history effects. A failed shortcut save stays in capture instead of showing Saved; failed retention saves keep the old choice and surface the error. Failed debounced text saves retain their drafts for a subsequent edit or close-time flush; no Rune test injects a save failure yet. Persistence, conflict, and ownership checks live in app_settings.rs, recording_environment.rs, and audio.rs. Settings previews do not prove physical device switching or native mute support; muting is best-effort, not universal. The recording-environment ownership checks cover overlapping acquisition and release, not a managed-device policy or native power assertion. The pause-script regression checks that inactive players are absent before AppleScript application resolution; when no supported player is running, acquisition skips AppleScript entirely. The script check does not prove subprocess avoidance or exercise Music, Spotify, or VLC through Automation. The browser pause runs after the AppleScript players and only when System Audio Recording is reported granted (an unknown answer skips it, because the tap would open the macOS dialog mid-dictation). macOS 15.4 and later no longer tell other apps what is playing, so Hex hears the browsers through a private process tap (aggregate anomaly.hex.browser-tap.<pid>, apart from the meeting tap) and sends play only after it saw the browser go silent right after its own pause. The sequence checks in browser_media.rs run on fakes: a_confirmed_pause_is_resumed, two_sounding_tabs_never_get_play, a_browser_already_playing_gets_no_play, a_call_is_never_paused, no_level_sends_nothing, a_play_the_browser_never_takes_is_paused_again, a_browser_that_closed_its_output_gets_play, a_closed_browser_that_never_opens_after_play_is_paused_again; the ignored native_tap_hears_a_playing_browser heard Brave at -25.4 dBFS on macOS 27.0.1 (2026-10-28). Which app receives the system command is macOS's Now Playing choice; Safari is heard only when its WebKit process reports Safari's name. An opt-in native assertion smoke exercises IOKit without starting microphone capture.

Changed after 2.3.1: Automatic was a compiled preference list (Universal Audio Thunderbolt, Studio Display Microphone), then the macOS default with a Bluetooth default swapped for the built-in microphone (the 2.1.22 fix for #106 and #88). That skipped a user's AirPods default, and Hex Microphone then passed the wrong microphone on to calls. Automatic is now System default: the OS default input, followed live. A Bluetooth default (CoreAudio transport Bluetooth or Bluetooth LE) is passed over for the built-in microphone, else the first other real input, so AirPods keep their music out of call quality while you dictate; it is kept when nothing else is usable. Only System default is affected: a saved Bluetooth microphone and --device are honoured, and General › Advanced › "Use the Mac's microphone instead of Bluetooth" (avoid_bluetooth_microphone, default on) turns it off. Hex Microphone carries whichever input Hex records from. Linux and Windows don't detect Bluetooth inputs yet and follow the default. Checks in audio.rs: system_default_passes_over_a_bluetooth_default, a_bluetooth_default_is_kept_when_nothing_else_is_usable, a_virtual_input_never_stands_in (CoreAudio transport Virtual, Aggregate, or AutoAggregate; #429), inputs_resolve_to_the_selection_then_the_system_default, device_override_wins_and_never_falls_back, a_default_change_switches_only_while_capture_is_idle, and a_missing_selection_uses_the_default_until_it_returns, with injected device lists and opens; scene mac-microphone-bluetooth shows the row and switch. Not verified: the CoreAudio listeners against a real default change, the transport read against real AirPods, or Windows' default-change report on a real PC.

Fixed after 2.1.20: through 2.1.20 the microphone menu was painted without occluding the panel, so hovering or clicking a device also reached the setting under the pointer (#94), and it only closed by choosing or reopening. The menu is now anchored to its button, occludes the controls beneath it, and closes on any outside mouse-down. In the Rune window the menu is a rune-kit Menu in the overlay layer (scene menus); the GPUI occlusion regression went with GPUI and has no Rune counterpart.

Easy to misread: an open microphone is not an active recording.

Process Text With Modes

Output (main sidebar, replaces the Modes editor) // rune_ui/output.rs; scenes output*
├── Edit with agent -> Copy a prompt naming the steps' keys and `hex settings`
├── Open file -> settings.json in the default editor
├── Every dictation -> Global's steps, numbered, read-only
├── Try -> Global's corrections and built-ins on a typed sentence
│   └── Scripts and rewriting run only when dictating
└── In some apps -> Each mode's apps and its steps (Only: …), opens to list them
    // the old Modes editor stays in the code for its scenes, unreachable

Foreground context -> Select processing
├── Matching website                     // macOS: scriptable browsers, then AXWebArea; Windows: UIA; Linux: AT-SPI
├── Otherwise matching application
└── Otherwise Global

Selected mode                            // replaces Global, does not layer over it
  -> Corrections
  -> OpenCode transformation             // optional provider-backed rewrite
  -> Transformations                     // ordered text chain
  -> Paste or Send

Stage fails -> Preserve its input        // failed chain discards partial results

Built-in text transformations run natively. Custom transformations come from ~/.config/hex/hex.config.ts and run in Hex's embedded script engine with a 1 s limit per call. OpenCode rewriting sends documented context to the configured provider; speech transcription stays local.

The built-ins are Lowercase, SpongeBob case, and No trailing punctuation. The last removes only the final contiguous run of sentence marks (including full-width marks), preserving internal punctuation, trailing whitespace, and closing quotes/brackets. Punctuation inside a final closing quote is preserved. Select it after Lowercase for lowercase messages without the final period. The no_trailing_punctuation_* and built_in_transformations_chain_in_selected_order tests in personal_commands.rs cover text behavior and native dispatch; transformations_chain_with_built_ins_and_failures_keep_nothing and runaway_scripts_stop_at_the_time_limit_and_the_config_keeps_working run real scripts. None is a live target-app paste.

Selection and stage checks start in dictation_processor.rs, text_replacements.rs, and personal_commands.rs. They do not prove live provider availability or real application/browser context changes.

Website context per OS: macOS asks the front tab of Safari, Orion, Chrome, Brave, Edge, Arc, Vivaldi, Opera, Helium, and Chromium over AppleScript (one Automation prompt per browser; a refusal keeps the application context) and reads any other app's focused AXWebArea AXURL (Firefox, Zen, web views). Linux matches the focused window from X11 or the Wayland foreign-toplevel list to an AT-SPI window by title and process and reads its showing web document's DocURL (Firefox) or URI (Chromium, only with --force-renderer-accessibility). linux_context_reads_the_focused_window (ignored, manual) printed google-chrome-stable with https://example.com/path under headless Sway and chrome with the page under its XWayland, and hex on niri (Oct 30); the macOS adapters are compiled on CI only and not run.

OpenCode transformation talks only to an already running OpenCode service, found through its registration file (opencode_service.rs); Hex runs no OpenCode executable and starts nothing. The catalog loads only while the Modes pane shows. Without a service, "OpenCode isn't running" shows beside the switch and the setup/retry notice below; unavailable_opencode_is_explained_beside_each_switch covers it headlessly, and the opencode_service tests cover registration, stale-PID, loopback, chunked, deadline, and cancellation handling against a local fixture, not a live provider.

ordinary_processing_snapshots_modes_before_stage_and_keeps_transformations in parakeet.rs checks processing-time profile acquisition, conditional Processing-stage emission, and retained transformation selection. It uses controlled profiles, not a live provider or custom transformation host.

modes_add_select_and_delete, mode_corrections_and_names_save_from_their_fields, and mode_transformations_add_reorder_and_remove in rune_ui/tests.rs cover the Rune mode editor and its debounced drafts headlessly. They do not exercise native typing or foreground mode activation.

Application activations compare the picker's bundle name with the foreground application's localized name. When Finder shows all filename extensions, the picker name arrives as Ghostty.app; context.rs strips that suffix on both sides and app_settings.rs rewrites already persisted activations on load, checked by application_matching_ignores_finder_bundle_extensions and loading_strips_finder_bundle_extensions_from_mode_applications.

Fixed in 2.1.14: through 2.1.13, a mode saved with that Finder preference enabled never activates and every dictation falls back to Global. Update to repair saved selections automatically. For older builds, the workaround is to quit Hex, remove the .app suffix from dictation_processing.modes[].applications in settings.json, then relaunch.

Record Meetings

Meetings (beta) maps the opt-in (the Meetings pane's card, then Meetings › Settings), call detection and the focus-free offer, menu-bar recording state, rolling transcription, echo suppression, and the Markdown copy in the user's transcript folder.

Experimental: Hex Microphone And Voice Filter

Labs › Voice Filter (experimental.hex_microphone = true), HEX_EXPERIMENTAL_MIC=1,
or the driver installed; off, the pane is hidden  // fun.hex-microphone; macOS, Linux
├── Sidebar > Voice Filter  // the only home of these controls
├── Hex Microphone card
│   ├── Linux -> no driver: a PipeWire virtual source (Audio/Source "Hex Microphone",
│   │   node hex_microphone) that Hex plays into through `pw-cat` while the Voice Filter is on
│   │   ├── Status -> Starting… / Available ("Choose Hex Microphone as your input…") / Unavailable + reason
│   │   ├── pw-cat missing or exits -> Unavailable with its last error line; retries every 2 s
│   │   ├── Off or Hex quits -> the source disappears (pw-cat ends with its input); no root, nothing installed
│   │   └── Session priority 1 -> WirePlumber never picks it as the default input Hex records
│   ├── macOS driver status -> Installed v<ver> / In use by <app> + Uninstall
│   │   ├── Version mismatch -> v<old> installed + Update + Uninstall
│   │   ├── Pre-Anomaly com.kitlangton driver at the same path -> Update replaces it in place
│   │   ├── Not installed -> Install -> macOS admin prompt -> copies bundled HexMicrophone.driver + restarts coreaudiod
│   │   └── Inline state -> Installing… / Uninstalling… / Cancelled / Failed with reason
│   ├── Mode while dictating -> Muffled (default), Muted, Quiet, Normal, Chipmunk, Robot, Walkie, Underwater, Far
│   ├── Key sliders -> Live mode tuning + Effect strength + Reset
│   └── Try it -> Example clip OR Record 5s (in-memory only)
│       ├── Dual waveform -> You vs Call hears with highlighted dictation span
│       └── Explicit playback -> Play what call hears | Play your voice
├── Live microphone capture -> Raw audio to Hex transcription (unmodified)
│   ├── Source -> Hex's dictation microphone (System default follows the OS default, never Hex Microphone itself), never a meeting's second stream
│   ├── Lock-free ring -> 48 kHz virtual sink (macOS IOProc; Linux 11 ms blocks into a one-page pipe) -> Raised-cosine fade on intentional hold
│   │   └── Not dictating -> bit-exact pass-through; non-48 kHz inputs resample continuously
│   └── Ring underflows/overflows -> process.log each minute while an app reads it, and on sink stop
└── Offline CLI -> hex dev voice-filter render <in.wav> --mode <m> --dictation <start>-<end> -o <out.wav>

Sources: voice_filter.rs, hex_microphone.rs, HexMicrophoneDriver.c, build-app.sh, and rune_ui/fun.rs. Checks: default_mode_is_muffled, all_nine_modes_render_finite_and_expected_levels, raised_cosine_fade_has_no_step_discontinuity, ring_buffer_transfers_samples_and_handles_underflow_and_overflow, a_meeting_stream_on_the_same_microphone_does_not_double_the_feed, a_callback_larger_than_the_latency_cap_passes_through_whole, resampling_is_continuous_across_callbacks, only_the_listeners_current_input_feeds_hex_microphone, driver_plugin_loopback_streams_at_48khz_under_20ms_latency, driver_plist_version_and_status_mismatch_are_detected, osascript_failure_classifies_user_cancel_and_errors, audio_service_restart_interrupts_stream_and_recovers, voice_filter_saves_the_mode_and_applies_the_tuning_live, try_it_records_and_plays_through_the_host, and voice_filter_needs_labs_or_hex_microphone. Scenes: mac-voice-filter, mac-voice-filter-muted, mac-voice-filter-driver-missing, voice-filter, voice-filter-unavailable (Linux). Linux observed (apollo, PipeWire 1.6): the source lists in pactl list sources and pw-record --target hex_microphone records the fed audio at 48 kHz; no automated check covers it, and no call app was tried. Proof limit: in-process dlopen loopback proves the compiled driver's 48 kHz WriteMix/ReadInput path at 5.33 ms (256 frames), not an interactive macOS admin authorization prompt or an active Slack/Zoom call. The pass-through checks simulate callback timing and sizes; clock drift between the microphone and the virtual device is not corrected, so a skip or zero-fill click every minute or two remains possible.

Recover And Maintain

Cancellation / interruption / failure    // recovery
  -> Scoped stop, fallback, or error
  -> Retry or fresh gesture
  -> Next capture succeeds               // this is the proof, not a cleared error

Menu bar and Settings                    // maintenance
├── Icon -> 18 pt monochrome Hex mark template (recording variant + elapsed time)
├── Settings / Paste Last -> Reachable with Dock icon hidden
├── Launch at Login -> macOS owns registration state
├── Check for Updates -> The updater (maintain.updates)
└── Quit -> Orderly shutdown
Launch Hex                               // maintain.startup
├── Setup incomplete / permissions or model missing -> Open setup or Settings
├── Show Dock icon on -> Open Settings
└── Show Dock icon off + usable menu-bar item -> No startup window
    ├── Dictation starts independently of the Settings window
    └── Menu-bar Settings / Finder or Spotlight reopen -> Open or focus the window

Menu-bar installation fails -> Show Dock icon and open the window // recovery access

Show Dock icon off                       // maintain.dock-policy
├── Settings window open -> Dock icon stays // hiding it would deactivate Hex
└── Settings window closes -> Menu-bar only
General > Launch at login                // maintain.login-item
├── Open -> Checking until macOS returns the initial state
├── Refresh / toggle / Open settings -> One persistent background worker
│   └── One active request; latest pending user action precedes the next refresh
├── Toggle -> Switch moves at once; macOS's answer settles it
│   ├── Answers behind a later click -> Switch keeps the last click
│   └── Settled answer differs -> Switch springs back
├── Failed toggle -> Show error and reconcile with actual macOS state when available
└── Close Settings -> Release channels without waiting for an active native call

login_item.rs serializes ServiceManagement calls on one window-owned worker, with bounded request and response channels and an autorelease pool per operation. Settings reads responses without blocking on its 250 ms tick; refreshes do not accumulate while macOS is busy. The toggle is unavailable until the first successful status lookup. A click is held in memory (PlatformState::login_item_requested) only until the worker is idle again, so the switch need not wait for SMAppService (about 120 ms per toggle, measured October 24, 2026, plus up to one tick). macOS owns registration state; no login Boolean is persisted. Scenes never start the worker or call macOS.

The worker tests in login_item.rs exercise thread reuse, nonblocking polling, latest-action coalescing, action priority, and closing during a blocked call. launch_at_login_asks_the_login_item_and_needs_approval_first in rune_ui/tests/panes.rs checks the Rune row's requests and the approval state, and the login_item_* tests there check the switch while macOS answers: confirmed, refused, a status answer behind a click, and rapid clicks. The GPUI regression for error rollback and external enablement went with GPUI. Native RequiresApproval and error transitions remain unverified; the simulated checks do not establish Developer ID signed distribution behavior.

Observed September 23, 2026, on d1930b5 after the final simplifications: the full debug suite passed 486 tests and the release suite passed 485, each with twelve opt-in tests ignored and all twelve keyboard-layout child scenarios passing. Formatting, strict debug all-target/all-feature Clippy, and diff whitespace checks passed.

Computer-use checks on an ad-hoc signed temporary bundle of the optimized build confirmed shortcut capture/cancellation, Settings/Modes navigation, registration and unregistration, the final state after three consecutive toggle clicks, and refresh after removing that bundle's login item in macOS Settings. The temporary item was left disabled/removed; no new microphone, input, or Accessibility grants were made. A five-second sample profile found SMAppService frames on the login-item worker and none on the main thread. This is sampled-stack evidence, not an input-to-frame latency measurement or a signed-release smoke test.

Closing Settings left the app process alive with zero login-item threads; reopening produced one new worker. An earlier apparent surviving worker came from computer-use observation reopening the window. Cmd+Q and Quit Hex each terminated the process. Local profiles are /tmp/hex-login-worker-current.sample.txt and /tmp/hex-login-cycle-{before,closed-no-observe,reobserved}.sample.txt. The tested temporary executable's SHA-256 is d1a3be1e219e7371726273937eeef6c7e16434637e4297ae183f9bdaf4d28b04. No Developer ID signing identity was available for a new distribution build.

The existing Show Dock icon preference controls quiet startup; there is no additional launch-window setting. This applies to normal and login launches, does not change login registration, and does not hide an already open window when the preference changes. Any open Settings window keeps the Dock icon: macOS deactivates an app asynchronously when it becomes menu-bar only, which dropped the window behind other applications (#103). Turning the preference off therefore takes effect when Settings closes.

Checks in meeting_watcher.rs: dockless_startup_stays_quiet_only_when_setup_and_menu_bar_are_ready and dock_visible_startup_always_opens_the_app cover the startup decision. The Dock/Finder reopen Apple event and the status item's OpenSettings bypass it. These checks do not establish signed-app login behavior or native Finder/Spotlight reopening; those still need an installed-app smoke test. This addresses the menu-bar-only case in #64, not an independent window preference for users keeping the Dock icon visible.

Executed September 3, 2026: 441 Rust tests passed (nine opt-in tests ignored), along with all twelve keyboard-layout child scenarios, formatting, and Clippy. The release Settings preview built and launched, but window screenshot capture failed; no visual or installed-app startup verification is claimed.

Hex checks the release service            // maintain.updates
├── 10 s after launch, hourly (Dev channel: every 10 min and on window focus), and Check for updates -> anoma.ly/release/hex/desktop/<channel>.json
│   ├── User-Agent: hex/<build channel>/<version> (hex/dev/0.0.0-dev-171, hex/stable/2.3.1); nothing else about the install
│   ├── Failed check -> backs off, never past hourly
│   └── <channel> -> updates.channel (General › Application › Update channel: Stable | Dev,
│       or `hex settings set updates.channel`), else the channel the build was published on
│       (HEX_BUILD_CHANNEL; older builds: dev for a dev version, else the receipt's);
│       package managers always stable
│       └── Choosing a channel -> checks now; a staged update stays staged
├── Versions never compared: channel's current release != running build -> take it
│   ├── Running build -> Linux/Windows: executable SHA-256 = target's; macOS: same version
│   ├── Another channel than the build's -> sidebar, tray, menu bar: Switch to Stable 2.3.1
│   │   └── Only after the user confirms -> download -> stage (never switches by itself)
│   ├── required -> install without waiting for the rollout
│   └── Rollout -> device-local bucket in <support>/update-bucket, never sent
├── Direct install -> download (resumable) -> size + SHA-256 -> code signature -> stage
│   ├── macOS -> unpack beside Hex.app -> Developer ID 5NZ4Q7NXJ4, anomaly.hex, notarized
│   ├── Windows -> Authenticode valid, signer AUTHENTICODE_SIGNER -> new version directory
│   ├── Linux -> SHA-256 only -> new version directory, `current` switched
│   ├── Staged -> checks continue; Restart to update stays offered meanwhile
│   │   ├── Another release this install takes (update, required) -> download -> replaces the staged one
│   │   └── Same release, rollout deferred, unconfirmed switch, rollback, failure -> keep the staged one
│   └── Sidebar Update, menu bar / tray: Restart to update
│       ├── One more check first (manifest within 15 s) -> Checking / Downloading -> newest staged
│       │   └── A release published after the relaunch -> the next launch's check
│       └── macOS -> renamex_np swap (also at quit) -> relaunch -> old bundle removed at launch
├── macOS location not replaceable (read-only, translocated, root-owned) -> Download update (DMG)
├── Package manager (pacman, Nix) -> its command, once it serves another version
├── Source build, test build -> never checks
└── Failure -> keep the running version, back off, Check for updates retries

DisableUpdates at launch                 // maintain.managed-updates
├── No checks, downloads, or installs; sidebar: Managed by your organization
├── macOS app menu -> Check for Updates omitted
└── macOS menu bar -> Disabled "Updates Managed by Your Organization"

Administrators set DisableUpdates on macOS as a Boolean in the anomaly.hex defaults domain, normally through a configuration profile (#100), and on Windows as a non-zero DWORD under SOFTWARE\Policies\Anomaly\Hex in HKLM or HKCU. Hex reads it once at launch, so a forced value wins and a change applies after relaunch. Speech-model downloads are unaffected. Releases through 2.3.1 read com.kitlangton.hex2; there is no fallback to it, so profiles must name anomaly.hex.

updater.rs owns the worker and the shared status the window, menu bar, and tray show; updater/protocol.rs passes the release service's shared test vectors (tests/fixtures/release-protocol), and the per-OS modules have tests for activation, the macOS bundle swap and signature checks (ad-hoc fixtures against the real requirement), the rollout bucket, receipts, the channel choice, never comparing versions (a legacy 2.3.2-dev.29 follows 0.0.0-dev-N; dev to stable is a switch), the switch waiting for confirmation, only a different self-taken release replacing a staged update (only_a_different_release_this_install_takes_replaces_a_staged_update), Restart to update's final check and one-time report (restart_to_update_checks_once_more_and_reports_once), Linux re-staging keeping the running version (replacing_a_staged_update_keeps_the_running_version), and Windows Authenticode (a Microsoft-signed system file). No test drives the worker through a real re-stage and restart; a channel rolled back while an update is staged still restarts into the staged release. The Update channel row shows only while this install updates itself (not test, source, managed, or package-manager installs); the mac-settings-dev-channel scene renders it, and rune_ui tests cover saving and hiding it. Not yet observed: a published dev channel release, a switch between channels on a real install, a real update on any OS from the release service, the macOS swap and relaunch of a Developer ID build, a Windows update of a signed build. Manifests are unsigned; on Linux a compromised HTTPS origin could serve any executable with a matching manifest, which Linux code signing cannot catch.

Fixed in 2.1.13: older releases through 2.1.12 used app-global dispatch from inside the sidebar click, losing the action while the active window was borrowed. The click regression passed in debug and optimized release builds. The menu-bar path was exercised on the installed local 2.1.11 build and opened the native Software Update window; no installation was performed. That path remains the workaround for older versions, including after Remind Me Later. A repaired installed-sidebar click remains unverified.

The historical release verification records publication evidence through 2.1.20, including signing, artifact checks, CI results, and skipped native checks. Those release-specific results do not verify the current working tree, installed-app behavior, or physical Linux compatibility; retain each entry's stated limits when using it as evidence.

Recovery separates supported recovery from known defects. login_item.rs, status_item.rs, updater.rs, the identity test, and app validation are maintenance evidence starting points. Preview badges do not prove installed-app login registration or a signed update from an older supported build.

Other Platforms And Consumers

Linux beta                               // not macOS feature parity
├── hex start -> One per-user Hex -> Alt-Space capture -> Stream (Vulkan GPU) -> Paste
│   └── CPU or a model that cannot stream -> Transcribe the whole clip after release
├── hex / hex app -> Window in the same process; closing it leaves Hex running
├── Recording sounds -> Shortcut press / capture stop / active cancellation
├── Shortcuts -> One state machine, three sources        // linux.shortcuts
│   ├── X11 -> Root-window key grabs; General records the chord
│   ├── Wayland + GlobalShortcuts portal -> Desktop grants the triggers
│   │   ├── General > Change shortcut -> Press chord (Esc cancels)
│   │   │   └── Saved -> New portal session asks for it as preferred_trigger
│   │   │       under an id named for it (`dictation-CTRL-ALT-space`), so a desktop
│   │   │       that keeps an id's stored trigger (GNOME, KDE) binds the new one (#575);
│   │   │       an unchanged shortcut keeps its id and any trigger set in the desktop
│   │   │       // observed on the niri fork: a new session gets it, a rebind in the same one fails
│   │   │       // #575 fix checked by a unit test only, not on a GNOME session
│   │   ├── Each row: "Your desktop granted <trigger>" (it may assign another);
│   │   │   an empty trigger (KDE's for one it keeps unbound) counts as refused
│   │   │   └── Hyprland (XDG_CURRENT_DESKTOP) -> its portal names no trigger, since
│   │   │       its config binds them: muted "Your desktop assigns this key itself,
│   │   │       so Hex can't confirm it", no refusal note, info log (#635)
│   │   │       // check: a_missing_trigger_is_unconfirmed_where_the_desktop_assigns_keys
│   │   │       // (the row's data only); not run on a Hyprland session
│   │   ├── Portal version 2 -> Desktop shortcuts > Open -> ConfigureShortcuts,
│   │   │   the desktop's own dialog; ShortcutsChanged updates each row
│   │   │   // a desktop keeps a stored binding over a new preferred_trigger for the same id
│   │   └── Refused shortcut -> Danger row + how to free it (niri: config.kdl)
│   │       under that shortcut's own row (Dictation, Output);
│   │       the rest keep working
│   ├── Wayland without the portal -> Bind `hex` commands in the desktop
│   │   └── General hides shortcut rows and lists the commands
│   └── Listener starting or stopped -> `hex status` reports Unregistered,
│       never the X11 mode
├── hex dictation start|stop|toggle|cancel, hex paste-last
│   -> Same events as a shortcut                     // linux.commands
│   ├── start|toggle --raw -> Dictate without processing's events
│   ├── Idempotent: start while recording / stop while idle -> Nothing
│   └── Hex missing or not listening -> One-line error, non-zero exit
│       // as for hex quit|restart|status
├── Escape -> Cancel active capture, not newest accepted job
│   ├── X11 -> Grabbed only while recording; a grab conflict warns, dictation goes on
│   └── Portal -> Never bound: "Cancel dictation" joins the one bind with no key;
│       the user assigns one, idle presses do nothing (#482)
│       ├── Desktop gave it a key anyway -> General shows a "Cancel dictation"
│       │   row, "Your desktop granted <key>", and process.log warns (#624)
│       └── KDE startup -> before the bind, the pre-#579 `hex` set (with the
│           pre-#492 `cancel` on Escape) and `cancel` under `anomaly.hex` are
│           removed from kglobalaccel (`unregister`); other apps untouched
│           // check: a_key_the_desktop_gave_cancel_dictation_is_shown (the row's
│           // data only); the KDE removal is not run on a Plasma session
├── Recording HUD -> Dot scope, as on macOS, on the
│   │   top or bottom edge, recording with the voice's level, then transcribing
│   │   until accepted output finishes; Hidden position -> none   // linux.hud
│   ├── Display -> picked at each capture start, kept until it hides (#486)
│   │   ├── X11 -> the monitor under the pointer (XRandR monitors)
│   │   ├── Layer-shell (niri, Sway, Hyprland) -> no output pinned: the
│   │   │   compositor's focused output
│   │   └── GNOME, KDE (XDG_CURRENT_DESKTOP) -> the primary display
│   └── Rune overlay in Hex's process (rune_hud, rune_hud.wgsl on the GPU)
│       ├── Wayland -> layer-shell overlay layer, no keyboard, empty input region
│       ├── X11 -> override-redirect window, empty input shape
│       ├── Wayland without layer-shell (zwlr_layer_shell_v1; GNOME) and XWayland
│       │   (`DISPLAY`) -> only the HUD opens on XWayland: override-redirect ARGB
│       │   window, notification type, empty input shape, primary monitor's edge,
│       │   scale from Xft.dpi; the rest of Hex (window, portal shortcuts) stays
│       │   on Wayland (#465)
│       │   // seen in headless mutter 51 + Xwayland on apollo: HUD at the top
│       │   // centre, animating, focus unchanged, the pointer over it hits the
│       │   // window below; not yet on a real GNOME Shell session
│       ├── Neither (Rune refused it, or no layer-shell and no XWayland)
│       │   -> process.log warns once; `hex status` reports `hud_limitation`
│       │   and the listener warning
│       └── No window loop (no GPU or display, `hex dictate`) -> no HUD
├── Tray host (StatusNotifierWatcher) -> Tray icon: click opens the window; menu as Windows' (tray_menu, linux_tray, rune::Tray)
│   └── No tray host -> No icon; status and sounds remain available
├── Microphone -> System default or a saved device; `--device` overrides
│   └── System default -> ALSA's default, resolved at each stream open // no live follow
├── General > Launch at login -> writes or removes ~/.config/autostart/anomaly.hex.desktop (X-Hex-Managed; linux_login_item)
│   └── An entry without the mark -> error beside the row; Hex never replaces it
├── Microphone failure -> Same recovery as macOS (RecoveringAudioInput)
│   ├── Stream fails -> Cancel an unfinished capture, "reopening" failure, retry with backoff
│   ├── Startup open fails -> Retry with backoff; the listener keeps running
│   └── Released while idle, on-demand open fails -> Failed entry; stays released
│   └── Overload (Xrun) glitch -> Logged; not a failure
├── Paste -> Restore the previous clipboard 500 ms after the shortcut  // linux.clipboard-restore
│   ├── Before the transcript -> snapshot every format (32 MiB, 1 s); empty -> cleared again
│   ├── Later paste, copy, or another client replaced the transcript -> left alone
│   ├── Over the cap or unreadable -> transcript stays; the log says why
│   ├── Paste only reached the clipboard -> transcript stays for Ctrl+V
│   └── Wayland without wlr-data-control -> transcript stays
│   └── Wayland: wl-copy, then the shortcut, chosen at the first paste  // linux.wayland-paste
│       ├── Asked by a modifier shortcut -> wait for its portal release + 250 ms
│       │   (2 s cap without a release); after a hex command -> 250 ms
│       │   // Hyprland merges held physical modifiers into virtual-keyboard keys (#353)
│       ├── Compositor offers zwp_virtual_keyboard_manager_v1 and wtype is installed -> wtype
│       │   └── V sent on evdev KEY_V (placeholder releases first), so Hyprland's
│       │       binds read Ctrl(+Shift)+V, not Ctrl(+Shift)+Escape (#444)
│       ├── Otherwise -> RemoteDesktop portal: keyboard-only session, approved once
│       │   (restore token, persist_mode 2) -> Ctrl+V as keysyms; session lives with Hex
│       │   ├── New session -> 250 ms for the approval dialog to hand focus back
│       │   └── Existing session -> 150 ms after wl-copy, so the target holds the new
│       │       selection before Ctrl+V (GNOME pasted nothing or the old clipboard, #578)
│       │       // fixed delay, no acknowledgement; not observed on a real GNOME session
│       └── Neither works -> Failed: "Copied to the clipboard; press Ctrl+V…" naming
│           the RemoteDesktop portal; the clipboard keeps the transcript
└── Portal identity -> Register as `anomaly.hex` (reverse-DNS: GNOME 49/50's
    shortcuts provider refused the bare `hex`, so binding failed, #579)
    ├── Installs ship anomaly.hex.desktop (StartupWMClass=anomaly.hex); windows
    │   report app_id / WM_CLASS anomaly.hex -> one launcher entry
    ├── Icon=anomaly.hex -> packages install hicolor 16–512 px PNGs (deb/rpm refresh
    │   the icon cache); otherwise Hex writes them to ~/.local/share/icons/hicolor
    │   at startup and fixes Icon= in managed entries it wrote (#652)
    ├── Startup -> Hex's own managed hex.desktop (launcher, autostart) renamed
    │   to anomaly.hex.desktop; the installer does the same; unmanaged ones kept
    └── No anomaly.hex.desktop in XDG data dirs -> Hex writes
        ~/.local/share/applications/anomaly.hex.desktop (X-Hex-Managed),
        NoDisplay when an older package's hex.desktop already shows Hex,
        removed once a package ships anomaly.hex.desktop
        // KDE bindings and RemoteDesktop grants stored under `hex` are asked again once
        // checked by unit tests only, not on a GNOME 49 session
Enable / Disable (General, tray)         // linux.listener-persist
├── Saves enable_on_launch -> a disabled Hex stays disabled after a restart (#575)
└── GNOME (XDG_CURRENT_DESKTOP) Disable -> after the listener's session closes, a
    fresh session binds no shortcuts: GNOME Settings stores that empty set, so
    Hex's entries leave its list; Enable shows GNOME's approval dialog again
    // never on KDE: an empty bind drops the user's keys (#482)
GNOME startup -> pre-#579 `hex` set listed in GNOME Settings -> removed through
    gsettings (reset its shortcuts, drop it from `applications`); other apps untouched
    // observed on apollo with gnome-control-center 51.1's provider, portal-gnome 50,
    // a stand-in org.gnome.Shell, not a full GNOME session

Clipboard restoration lives in linux_paste.rs (generation and timing) and linux_clipboard.rs (snapshot and serving). Observed on apollo (2026-10-29), in nested headless Sway: native_wayland_clipboard_shortcut restores a two-format clipboard and keeps an empty one empty; a full dictation from a file-fed ALSA microphone pasted into a GTK target, then Ctrl+V pasted the original text; an X11 round trip on Xwayland restored a 3 MB image through INCR. Not observed on X11 dictation, KDE, or Hyprland. The Wayland paste choice lives in linux_paste.rs and linux_remote_desktop.rs. Observed: the wtype path in the headless-Sway smoke and the virtual-keyboard probe on niri (apollo). niri's portal frontend advertises RemoteDesktop but its GNOME backend fails CreateSession, so wtype stays first there. The RemoteDesktop paste itself (KWin, Mutter) has unit tests for its key sequence only and is not yet observed on a real desktop. The modifier wait (ShortcutKeys in linux_input.rs) has unit tests for its timing only; it is not yet observed on Hyprland, and a modifier held past the wait still changes the paste. wtype numbers its keymap from evdev code 1 in order of first use, and Hyprland matches binds through its configured layout, so a bare -k v read as Escape and a Ctrl+Shift+Escape bind swallowed Terminal paste (#444). Hex now releases 46 never-pressed placeholder keysyms first so V lands on KEY_V; wtype_sends_v_on_the_v_key checks the numbering against wtype's rule, not a real Hyprland session.

Windows beta                             // x64 (Vulkan GPU) and ARM64 (CPU)
├── Install -> Setup.exe or install-windows.ps1 -> hex.exe + install.json receipt
├── Launch -> DLLs load from System32 and Hex's folder only, never PATH (#587)
│   └── DX12 compiles shaders with FXC (System32), never a dxcompiler.dll on PATH
├── Hex app -> General, tray, HUD; Enabled = shortcut works in every app
├── HUD display -> the monitor under the pointer (GetCursorPos) at each capture
│   start, inside that monitor's work area, at its own DPI (#486)
├── Launch again -> Running app shows its window; hidden (login) launch -> no-op
├── Tray icon -> White Hex mark; light taskbar (SystemUsesLightTheme) -> black mark
│   └── Theme read when the tray is created; a later switch applies at the next launch
├── Tray menu (tray_menu, shared with Linux)    // maintain.tray
│   ├── Recording / Transcribing… status line and tooltip while the listener works
│   ├── Show Hex · Paste last dictation (refocuses the app the menu covered)
│   ├── Speech model: downloaded models, ✓ on the selected one; none while one prepares
│   └── Update item · Enable / Disable Hex · Quit Hex
├── Single-modifier shortcut -> Left / Either / Right side picker, as macOS
├── Ctrl+Space -> Hold or Toggle -> Capture -> Stream (GPU) -> Paste
├── Escape -> Cancel; a held modifier alone never ends or cancels a capture
├── One modifier alone (Right Alt, Right Ctrl) -> Shortcut; Alt/Win release masked (no menu, Start)
├── Microphone switched off in Settings › Privacy -> Onboarding Microphone row -> Allow opens it
├── Microphone -> System default or a saved device
│   └── Windows default input changes -> Switch once capture is idle // cpal's default-device report
├── Paste -> Waits for Shift, Alt, and Win; a held Ctrl is kept and harmless
│   └── Ctrl+V carries scan codes from the foreground layout, so Chromium apps
│       see KeyboardEvent.code: VS Code's terminal takes it as Paste instead of
│       sending ^V to a TUI inside it (#618; from VS Code's source, not run)
├── Update staged (managed install) -> Tray, sidebar footer: Restart to update
├── Another release (unmanaged install) -> Tray, sidebar footer: Download update
├── Another channel chosen -> Tray, sidebar footer: Switch to Stable 2.3.1 (confirm)
├── Otherwise -> Sidebar footer: Check for updates
└── Modes, History, Activity, transformations -> as on macOS

Windows updates follow maintain.updates (updater.rs, updater/windows.rs). CI test builds never check. The tray item and tooltip (windows_rune.rs) follow the published update state. They pass the cross-compiled Clippy check, but no one has clicked them on a real Windows PC. The white tray mark vanished on a light taskbar (#460); a light taskbar now gets the same mark in black (light_taskbar_gets_a_dark_mark_with_the_same_shape checks the pixels, not a real taskbar). Releases are Authenticode-signed once Azure Artifact Signing is configured (windows-signing.md).

Known defect, outside Hex: Discord's game detection matches the running hex.exe to HEX: Shards of Fate (#445). The executable name stays as it is because the managed install layout, updater, shortcut, and login entry use it. The workaround is to hide the entry in Discord's Registered Games (Windows guide). No check covers it.

No Windows Meetings or local transcription API. See the Windows guide for the parity table, streaming notes, and release flow; check-windows builds, lints, and tests x64 and builds and tests ARM64 on every pull request. Physical smoke tests on Windows 10 and on ARM64 hardware have not been run.

Linux updates                            // maintain.linux-updates
├── Direct install (versions/ + install.json receipt) -> release service -> staged -> sidebar footer: Restart to update
├── Source install (source-build marker) -> Sidebar footer: Updated from your source checkout
├── Local build (HEX_LOCAL_BUILD=1) -> Sidebar footer: Local build · updates off; Version 2.3.1 (local <commit>); no checks, no channel row
├── pacman (AUR hex-bin: /usr/bin, root-owned) or Nix store -> Sidebar footer: Updated by your package manager
└── Anything else (a downloaded executable) -> Sidebar footer: Download update

The install kind comes from updater/linux.rs. The 2026-10-23 apollo observation (a staged update and the sidebar button) predates the release service and is not evidence for it.

No Linux meetings. Wayland shortcuts need either the desktop's GlobalShortcuts portal or the user's own keybindings for the hex commands; Hex reads no input devices. Click-through and a physical portal or keybinding press still need native evidence. Its smoke explicitly isolates HEX_APPLICATION_SUPPORT_DIR, pastes while another keyboard holds Ctrl and Alt, opens the window through a second launch, and checks that closing it leaves the same process answering.

Shortcut rules are pure and tested once for every source in linux_input.rs: hold, double-tap lock, toggle, double-tap only, Escape, Paste Last, and command idempotence, plus X11 auto-repeat filtering and a portal release that never arrives (Hyprland's Lua binds drop it when the modifier comes up first; see dictation.md). The X11 grab tests run on an isolated display in CI; commands_and_grabs_share_the_x11_listener drives grabs and commands through one listener. scripts/test-linux-service.py checks that commands fail without a listening Hex. On 2026-10-22 the portal path was observed on niri with xdg-desktop-portal-gnome using synthetic uinput keys against an isolated service: granted triggers, hold and Escape while held (since replaced by Cancel dictation), double-tap lock, Paste Last, and commands. A physical keyboard press has not been recorded.

linux.hud: linux_rune_hud.rs keeps the listener's generation slot (an old listener cannot hide a restarted HUD) and turns its reports into rune_hud.rs's events; reports before the window's app exists are dropped. The shaders render offscreen with cargo test --bin hex rune_hud_frames -- --ignored (2026-10-28 on apollo: Dot scope frames match the Metal look; 2026-10-29 on apollo: recording, processing, and done after Dot scope became the default). The GTK HUD it replaced is deleted. The text pill was observed on 2026-10-22 in a headless sway on apollo (isolated data root, real microphone, hex service): hex dictation start showed the pill at the top edge and it was gone after hex dictation stop and transcription. The Rune overlay itself was also observed passing a virtual-pointer click through to the window below and leaving keyboard focus where it was (Rune examples/background). Not observed: X11 with Hex, niri, KWin, Mutter.

See the Linux guide, linux_app.rs, linux_portal.rs, and Linux CI for existing checks. The direct installer/updater and installer tests do not establish a published, supported-host-validated signed release. Nix owns its package updates; Hex updates only managed direct installs. Its Home Manager module merges programs.hex.settings into Hex on every activation through hex settings apply (control-cli.md); nix/checks.nix evaluates the module, not a live activation.

Linux process                            // maintain.linux-service
├── hex / hex app -> Hex running? -> Activate{activation token} over the socket -> exit 0
│   ├── No Hex, hex.service loaded -> session.env -> systemctl start (Type=notify) -> Activate
│   ├── No Hex, no unit or custom data dir -> This process becomes Hex
│   └── Two racing launches -> One Hex; the other forwards and exits 0
├── hex start / desktop autostart / hex service -> Same, without the window
├── hex dictation|paste-last -> Socket worker -> Listener queue (never the UI thread)
├── hex status -> Published status; hex quit / Quit Hex / launcher action -> Drain, exit 0
├── Open the window -> Maps at 980×760 (min = max), tiling WMs float it -> Resizable from 860×560
├── Close the window -> Keep microphone, accepted jobs, model preparation, and HUD
│   └── Uncommitted shortcut capture -> Cancel and restore the prior listening state
├── Crash -> systemd Restart=on-failure -> Hex without a window
├── Update -> Restart the unit -> Hex reopens the window
├── hex restart without a display (SSH) -> Keep the saved session.env
├── Saved XAUTHORITY file gone (a reboot: Xwayland's cookie is new each login)
│   -> Take the user manager's current XAUTHORITY, else drop it (#465)
├── Window fails (no display, no GPU) -> Logged; Hex keeps running
└── Startup -> transcribe.cpp's Vulkan instance first, on the main thread
    └── then the model load and the window's wgpu instance never create one at once

Sources: the process, host, and window seam in linux_app.rs, the single-instance socket and systemd lifecycle in linux_service.rs, the CLI in linux.rs, and the managed installer/Nix user units. hex status queries the running Hex; --lines explicitly reads historical observations. hex app --hidden and hex start start Hex without the window; hex stop equals hex quit. Closing the window does not mean Disable; the General's listener card Enable/Disable controls the listener, Quit Hex the process. No root daemon or new input grants. The window runs on Rune's app lifetime: it opens or focuses with the forwarded activation token and keeps its state across close and reopen. Observed in a headless sway on apollo on 2026-10-22 with test-wayland-paste.sh, then with GPUI and Rune builds (open in the same process, close, still answering, reopen, quit); not rerun since GPUI was removed. On niri (apollo, 2026-10-22, isolated data directory, listener off) launches spawned through niri msg action spawn opened the window in the instance's process, close-window left the same process answering, a relaunch opened a new window in it, and hex quit exited; the window never took focus although niri passed XDG_ACTIVATION_TOKEN (an IPC spawn's token, without user input, so this does not show whether a launcher's token focuses it). Not yet on KWin, Mutter, or i3.

Listener starts consume the host's settings snapshot; the foreground hex dictate command loads its own snapshot once. Invalid startup settings leave defaults available only for editing and block capture until a successful settings commit or a restart with valid settings. Dismissing the load error does not authorize recording with defaults. Feedback is initialized at these entry points, so a delayed listener start cannot reset a newer live volume selection.

Checks: test-linux-service.py exercises the real socket and process lifetime without a display, installed model, or audio device; it passed on apollo (Arch, niri) on 2026-10-22. closing_the_window_cancels_only_an_uncommitted_capture, commands_route_only_to_a_started_listener, and adopting_saved_settings_restarts_only_for_listener_settings use controlled runtime workers. The virtual-microphone and Wayland scripts open the window through a second launch; the Wayland script passed on apollo (headless sway, WLR_RENDERER=gles2, before GPUI was removed), the X11 virtual-microphone script has not run since the single-process change. A throwaway Type=notify user unit on apollo (HEX_SYSTEMD_UNIT) started ready through hex start, restarted after SIGABRT and SIGKILL without a window, stopped cleanly on systemctl stop and hex quit, and an older Type=simple unit still started. Before the Vulkan ordering fix, a launch with an installed model and enable_on_launch SIGSEGVed in the Vulkan loader 3/3 in a headless sway on apollo (the model load raced the window's wgpu instance); with it, 6/6 ran. These checks do not establish a physical desktop logout, cross-version signed update, or microphone/target-app success on the user's devices.

malformed_settings_cannot_start_even_after_dismissing_the_error in linux_app.rs checks the failure gate and recovery after a simulated successful settings commit. It selects a model never installed on Linux, so the resumed start stops at Model required instead of opening the microphone on a machine with the real model; it passed on apollo on 2026-10-22 beside the live service. listener_startup_ignores_the_previous_session_until_a_new_one_arrives checks the listener owner's startup observation and stop signal; the exact production owner and test passed in an isolated standard-library-only Rust harness on macOS. That harness does not compile the Linux application or prove its runtime integration; the test has not been executed on Linux. These checks do not exercise physical devices or persistence failure on disk.

The September 4 service-build evidence records an Arch/i3 client-independent ownership check, not transcription accuracy or physical logout, Wayland, or signed cross-version update proof.

Linux General > Sound volume              // dictate.feedback.linux; X11 + Wayland
├── Default / older settings -> 50%
├── Off -> No recording tones
└── 25% / 50% / 75% / 100% -> Save and apply live; preview the start sound
Linux General > Sounds                    // dictate.sound-theme
└── Signature / Classic -> Save and apply live; preview the start sound

Capture                                  // hex app and hex dictate
├── Shortcut starts capture -> Queue start sound immediately, before audio arrives
├── Hold passes 300 ms -> While dictating mute/pause begins, never at the press
├── Finish retained recording -> Stop sound at the release, while the 200 ms
│   tail is still captured; a muted output unmutes first   // dictate.stop-tone
│   └── Clip turns out near-silent or speechless -> Stop sound already played,
│       nothing pasted
├── Brief discarded tap -> Start sound, but no stop sound or transcription
├── Dictation pasted -> Done sound; failures play the error sound
└── Cancel active capture -> Cancel sound; idle Escape stays silent

The shared feedback player uses Hex's bundled sounds (Feedback) on every platform; only macOS wake/sleep use afplay. Decoding happens before the Linux capture loop; the output device opens for the first tone and is released five minutes after the last one, as described in macOS feedback. Playback admission is bounded and nonblocking. Output preparation failures are logged without blocking dictation. The Settings window saves the volume to settings.json and the service reloads it without restarting the listener. The start sound does not wait for the 300 ms capture-retention threshold; input delivery and audio-device latency still apply. While dictating mute or pause does: the audio loop requests it when the capture's audio reaches 300 ms past the press, and not during a release tail. Until 2026-10-22 the service muted at the press. This ordering is checked by reading the loop, not by a test or a muted device.

Checks: bundled_sounds_decode_without_an_audio_device and feedback_admission_never_waits_for_playback in the player. start_sound_precedes_audio_and_does_not_change_short_tap_discard in linux_dictation.rs exercises the production start path with a sound spy: immediate feedback without any audio, while a 100 ms tap still discards. Existing timing checks in dictation.rs cover the unchanged intentional-hold boundary. These are not proof of audible native playback or physical hotkey-to-sound timing.

The September 4 sound-build evidence records the initial and immediate-start builds. It does not establish audible native playback, measured physical key-to-sound latency, or Wayland UI behavior.

Both Linux CI triggers include tests/**, and the Nix source fileset includes those sources. The keyboard_layout harness skips on Linux; packaging it does not provide macOS layout evidence.

TypeScript SDK + local service             // macOS, Linux, Windows
├── Caller records audio -> Explicit helper -> Raw transcription
│   └── Linux, Windows -> No packaged helper; command: hex service --embedded
├── Caller streams audio -> Running app's /streams -> Partials, segments, final
│   └── Dictation first; uses the loaded model only (docs/streams.md)
├── Running app's /transcriptions -> Listener's engine when it holds the model
│   └── Otherwise the service's own copy of the requested model
├── Running desktop capture API -> Separate microphone ownership protocol
│   ├── No silence gate                  // model output, unlike hotkey dictation;
│   │                                     // only recognition-hint echoes are dropped
│   └── Linux, Windows -> serviceCapture false, 404 service-capture-unavailable
└── Distribution
    ├── SDK 0.3.0 -> Published
    └── Turnkey signed helper -> Unfinished

Linux and Windows serve the same local API from the running app (linux_app.rs, windows_rune.rs) and from hex service [--embedded]; a failed start only logs, and Hex runs on without it. On Linux, the embedded service answered /health, /capabilities, /models, and transcribed a WAV with Parakeet Unified English on the Vulkan iGPU (October 30). The running app's shared-engine path, /streams on a live Linux or Windows listener, and anything on Windows beyond cargo xwin clippy (x64) are unverified.

The SDK guide, service contract, and SDK tests distinguish those paths. Helper-only service mode is not a physical hotkey/microphone test. Embedded/packaged service smokes now expect API 2, matching service/SDK compatibility checks. Helper packaging requires an explicit signing team and notarization profile, with the same Developer ID identity validation as the app; identity fixtures do not establish a signed helper distribution. Low-level blocking-peek and FIN/half-close limitations in local_api.rs must not be generalized to every SDK cancellation path.

Live streams (/streams, contract) are exercised by fake engines only: transcription_stream tests cover windows, partials, the backlog during dictation, preemption and offline fallback, the lease, cancel, and busy; local_api::streams tests cover routes, auth, and bounds; and transcription_engine tests cover yielding streams closed by dictation. No real-model or live-app stream has been run.

Model progress and dictation levels share bounded SSE framing in sse.ts. In-memory SDK regressions cover transport-chunk independence, line/event limits, callback cancellation, reader cleanup, and bounded level observations. A CR-delimited terminal event is also checked with an open response body, so completion does not depend on EOF or subsequent data. These checks do not establish live service or microphone behavior.

Developer And Prototype Surfaces

macOS debug runtime                      // not release navigation
├── Meetings -> Always available; release builds need the Meetings opt-in (its pane's card or Meetings › Settings)
├── Activity / HUD Lab / hex dev -> Observation and developer controls
└── HUD Lab in release -> Only in the playground (Labs › Playground)

`hex app -- --snap OUT --scene NAME` -> Rendered fixtures    // not proof capture/download/update happened
Linux `hex` -> the Linux window on Rune, in the Hex process // dev.rune-ui
├── Calls the host directly: saves settings through it; woken on host changes
├── Sidebar: General, Modes, History
├── Transcription picker -> ChooseModel / CancelModel; progress from the snapshot
├── First-run setup -> onboarding::Flow without permissions or practice
├── Modes: "Choose a file…" through file_dialog (FileChooser portal); drag to reorder
├── Modes: app icons (hicolor theme or pixmaps, PNG or SVG) in the picker,
│   chips, and mode rows; monogram while loading or without an icon
├── Model and application searches: Up/Down highlight, Enter picks it
├── Not yet: right-click delete
└── macOS/Windows surfaces as scenes too (Capabilities + preview data):
    Meetings (Recordings | Settings, opt-in card), Voice Filter,
    HUD Lab, Activity, permission rows, Launch at login, Dock icon, shortcut
    sides, the full tour
macOS `hex app` -> one menu-bar process, Rune window // dev.rune-ui-macos
├── rune::App on the main thread: accessory, windowless unless setup/onboarding/Dock icon open it
├── Same AppKit loop: status item, updater, Hex/File/Window menu, Dock reopen event, the HUD pump (16 ms timer; the HUD itself is a Rune overlay)
├── Runtime thread: model preparation (menu bar: installed only), starts the listener once setup is ready
├── Listener, meeting controller, local API: their own threads
├── macos_host: AppKit event monitor records chords (Option, Fn); NativeHost does permissions,
│   login item, Hex Microphone, meeting start/stop/rename/trash/reveal, relaunch
├── Meetings pane lists the store through meeting::query (1 s rescan, sent only when changed);
│   a controller failure shows in the pane; rune::FileDialogs (file_dialog) chooses the
│   meetings folder and Modes' "Choose a file…" .app
├── Tour: practice dictations from the listener's event log; a listener that failed to start
├── Sidebar and pane header move the window (Rune El::window_drag; the title bar is transparent)
├── While the window shows, its poll re-checks timed state every 250 ms (Rune Tx::wake_after); close hides it (Dock policy restored)
├── ⌘Q / menu-bar Quit / Ctrl-C quit and join
├── Meeting capsules: Rune interactive overlays (offer, call ended, ready, recording
│   with Copy new (N) / Copy all of the in-call transcript; compact until hovered,
│   dragged to any corner)
└── Modes app icons: Finder's icon (NSWorkspace) in the picker, chips, and mode rows
Windows `hex app` -> one tray process, Rune window // dev.rune-ui-windows
├── Runtime thread owns WindowsDesktopHost (listener, model preparation, OpenCode, transformations, updater)
├── Named event: a second launch shows the window; tray Show / Enable-Disable / Quit
├── rune::App: --hidden starts windowless, close hides (keep_running); Quit or no tray ends it
├── General adds Launch at login; no terminal paste; chord capture polls
├── HUD: shared indicator model + CPU shader in a Rune overlay
├── Modes: "Choose a file…" opens rune::FileDialogs (file_dialog) on its own thread
├── Modes app icons: the executable's first icon (PrivateExtractIconsW)
└── Not yet: custom caption
iOS prototype -> Record/transcribe/copy and keyboard handoff
Unmerged Windows / deferred features -> Not part of this map

Sources: meeting.rs, meeting_watcher.rs, developer_control.rs, SCENES in rune_ui.rs, iOS guide. iOS physical-device proof and temporary-WAV failure cleanup remain gaps; best-effort deletion is not guaranteed deletion.

dev.rune-ui lives in rune_ui.rs and its pane modules; its tests drive the Rune runtime headlessly (navigation; General rows per shortcut source, fitting the default window with Advanced collapsed, Double-tap to lock folding under the rows below, saves through the host, Quit Hex, microphone-release confirmation, shortcut recording and Escape, conflicts; History select, delete, two-step clear, copy; Modes add/delete, corrections, transformations (drag reorder saves once on release), application conflicts, a chosen file joining the mode, arrow keys in the model and application searches; the picker's ChooseModel/CancelModel; setup's download; draft saves; and, from macOS/Windows preview data, each OS's panes and rows, Launch at login, permission warnings, the Meetings opt-in card and Settings view, Meetings select, search, copy, rename, start/stop, two-step trash, and the overflow menu, and the macOS tour's permissions, practice, and relaunch; Voice Filter's experiment gate, modes, sliders, and demo requests, HUD Lab's indicator requests, Activity, and shortcut sides) and compare 62 offscreen GPU renders with tests/golden. In these tests the macOS and Windows requests are recorded, never performed; the macOS native host (dev.rune-ui-macos) has no automated test of its own. They skip the goldens without a GPU adapter and do not prove a real window, input method, or physical shortcut. Before the single-process change, a headless script against an isolated service saved a new mode and a correction. See the Rune port plan for what was observed.

dev.rune-ui-macos lives in macos_rune.rs and rune_ui/macos_host.rs. On hailmary (macOS 26.2, Command Line Tools only) it compiles, passes clippy -D warnings and the macOS unit tests (including macos_host's chord capture), and a debug hex app with an isolated data root started, showed its 980×760 window (CGWindowList; lsappinfo: Foreground while open), answered hex dev status/show/hud, and quit cleanly on Ctrl-C, also with the meeting fixtures copied into its data root and the Meetings pane shown. macos_host's tests cover the meeting feed over tests/fixtures/meetings (order, recovery to Interrupted, turns and pauses, no resend when unchanged) and the practice feed over a written event log. After GPUI's removal the same host built with a local Command Line Tools stub passed clippy and all 648 macOS tests on hailmary (2026-10-22); it was not launched again. Those builds stub the HUD's Metal shader and Apple Intelligence (Xcode is missing), so the HUD failed to load its shader there; window contents (including the Meetings list and the practice step), dragging the window, the folder panel, the status item, reopen, shortcut capture, and Sparkle have not been seen or exercised, and no screenshot could be taken over SSH.

dev.rune-ui-windows lives in windows_rune.rs and rune_ui/windows_host.rs. It has only been cross-compiled (cargo xwin clippy -D warnings for x86_64-pc-windows-msvc and aarch64-pc-windows-msvc, with the transcribe.cpp build stubbed out, last after GPUI's removal on 2026-10-22); it has never run on Windows, and no test covers it. It replaces the GPUI app earlier Windows releases shipped.

each_os_lists_only_the_panes_it_can_run and developer_panes_show_only_in_developer_builds in rune_ui/tests/panes.rs check pane availability from the capabilities, the developer flag, and the Meetings opt-in; they do not exercise native navigation clicks.

Scenes and tests use fixture state (Service::Fixture, PlatformHost::Fixture): requests are recorded, never performed. They do not establish native device discovery or installed-app behavior.

default_paste_hotkeys_follow_build_capabilities in app_settings.rs checks the production default runtime and settings projections: Paste Last remains available in both build profiles; meeting paste is present in debug or after the Meetings opt-in. meeting_paste_is_only_active_in_developer_builds in suppression.rs checks the corresponding constructed hotkey event, including ordinary-chord discard in release. These replace an unconditional debug-only expectation and a test-only copy of the reservation rule; they do not establish native keyboard delivery or meeting insertion.

structural_event_equality_preserves_serialized_identity in events.rs covers Activity's full-event equality across event variants, equal timestamps, non-ASCII text, and optional/error payloads. It does not exercise native Activity selection or scrolling.

Executed September 3, 2026, source simplification pass: 444 Rust tests passed in both debug and release (nine opt-in tests ignored in each), with all twelve keyboard-layout child scenarios passing in each profile. Formatting and strict Clippy passed in both profiles. These checks cover the working-tree cleanup, not a new published app; no installed-app, microphone, or native Linux test was run.

September 22, 2026 Cleanup Verification

The working-tree cleanup passed 481 Rust tests in debug and 480 in release, with twelve opt-in tests ignored in each profile and all twelve keyboard-layout child scenarios passing per profile. Strict all-target/all-feature Clippy, formatting, app/helper signing-input guards, and changed shell-script syntax checks passed. The command SDK passed 52 tests; the public SDK passed 56; both typechecks and builds passed. A packed public SDK imported both Promise and Effect entry points from an isolated consumer.

The isolated embedded-service smoke passed API 2 handoff, health/models routes, absence of discovery-file publication, and shutdown on host-lease closure. It did not open a microphone or run inference. No signed helper artifact, installed-app settings flow, provider, physical capture/paste, or Linux integration was tested. The Linux listener-owner harness and source review do not replace Linux CI. The optimized Modes preview built and launched, but screenshot capture failed with could not create image from window; native visual layout is unverified.

Grow The Map

One feature file
├── Sub-features -> Small action/outcome diagrams with stable IDs
├── Entry points -> Every user route and its prerequisites
├── Existing checks -> Source/test links and what they do not prove
└── Gotchas -> Exceptions, known defects, missing native proof

This is the map-only stage of the verification workflow, not a working driver. Keep checks beside the behavior and tests with the implementation. Split new files only when useful; link shared failure behavior to Recovery.

Future evidence should name the behavior ID, entry point, revision/binary, platform, prerequisites, outcome, and artifact location. Report skipped paths. Never publish tokens, personal transcripts, or unredacted diagnostics as proof. This map does not authorize operating a live installation.