* feat(settings): hide local ASR behind new Advanced tab + system confirm dialog
Move local ASR options (local-qwen3 / foundry-local-whisper) out of
the main Providers → ASR dropdown into a new "Advanced" Settings tab,
so first-time users can't accidentally enable CPU inference and get
slower / less-accurate transcription than they expected. Enabling
local ASR now requires reading a strong yellow/amber warning and
confirming a system-level dialog (@tauri-apps/plugin-dialog ask()
with kind:'warning') — the in-page modal pattern wasn't loud enough.
Behavior changes:
- visibleAsrPresets now excludes local-qwen3 and foundry-local-whisper
(the dropdown is cloud-only)
- useEffect lookup uses ASR_PRESETS (all) so committedAsrProvider can
still resolve to a local provider when one is active
- When committedAsrProvider is local, the ASR Card replaces the
dropdown with a plain notice ("Local ASR (X) is currently active —
switch / disable in the Advanced tab"); existing
LocalAsrProviderHint rendering for the Card body is preserved
- New AdvancedSection component:
- Strong warning block (amber background + amber border + amber text,
fontWeight 500, ⚠️ icon) — meets user's "yellow/red obvious" ask
- Per-platform engine selector:
macOS : Qwen3-ASR only
Windows: Qwen3-ASR + Foundry Local Whisper
Linux : "Local ASR model integration is not supported on this platform"
- Enable buttons trigger Tauri ask() confirm dialog before switching
- When already on local: shows a "Disable local ASR" button
(switches to volcengine, no confirm needed) plus the existing
LocalAsrProviderHint for model status / download navigation
- SettingsSectionId gains 'advanced'; SECTION_ORDER appends it after
language so it sits last in the sidebar (matches macOS conventions
for Advanced/Experimental panes)
i18n: 5 locales (zh-CN, zh-TW, en, ja, ko) gain
- settings.sections.advanced
- settings.providers.localAsrActiveNotice
- settings.advanced.{localAsrTitle, localAsrDesc, localAsrWarningStrong,
qwen3Desc, foundryDesc, enable, alreadyActive, disableLocalLabel,
disableLocalDesc, disable, platformNotSupported,
confirmEnableLocalTitle, confirmEnableLocalBody}
Test plan:
- npm run build (tsc + vite) — green; the typeof zhCN constraint
catches any locale that drifted out of shape
- Manual: switch Settings tab to "Advanced", verify warning is
amber-on-amber and clearly visible, click "Enable" on Qwen3-ASR,
verify a system dialog (not a React modal) appears with kind:warning
iconography, cancel — verify nothing changed; confirm — verify the
Providers tab now shows "Local ASR is currently active" instead of
the dropdown
- Manual on macOS: confirm Foundry option does NOT appear
- Manual on Linux (if available): confirm "platform not supported"
message renders
* feat(advanced): inline LocalAsr model UI + page-top confirm popup + concise warning
User feedback: the standalone "Models" page (LocalAsr.tsx as a main
nav tab) was redundant once Advanced settings exists, the inline
amber warning was a wall of text, and clicking Enable triggered an
OS-native dialog that didn't feel like part of the app. Refactor
the whole local-ASR surface around what users actually need:
- "Models" main-nav tab removed (FloatingShell + NAVIGATE_LOCAL_ASR_EVENT
listener gone). Model management is now exclusively reachable via
Settings → Advanced.
- LocalAsr.tsx gains an `embedded` prop. When embedded, the outer
page padding/height, PageHeader, and standalone warning Card are
skipped — the host (AdvancedSection) supplies the chrome.
- AdvancedSection no longer calls @tauri-apps/plugin-dialog ask().
Enabling local ASR sets pendingTarget which renders a single
amber-bordered Card at the very top of the section with
Confirm / Cancel buttons. One box, in-app, dismissable. Disable
is direct (no confirm needed — going back to cloud is safe).
- Persistent inline warning shrunk to one line. The longer wording
(including the new "may drop words on under-spec hardware" risk
the user asked for) lives in the popup body so it's only seen
when actually consequential.
- ProvidersSection ASR Card body returns null when active is
local — the dropdown row's localAsrActiveNotice already points
to Advanced, no need to duplicate manage UI here.
i18n (5 locales): localAsrWarningStrong replaced by
localAsrWarningShort; confirmEnableLocalBody / Title shortened;
new `confirm` key for the popup CTA.
LocalAsrProviderHint and NAVIGATE_LOCAL_ASR_EVENT in Settings.tsx
are now unused but left in place to keep the diff focused; will
clean up in a follow-up commit if pr_agent flags them.
Test plan:
- npm run build (tsc + vite) — green
- Manual on macOS: Settings → Advanced shows one short amber line +
Enable Qwen3-ASR button + LocalAsr model UI inline. Click Enable
→ page-top popup appears (no OS dialog), with Confirm / Cancel.
Cancel → no change. Confirm → switches; Disable button appears,
still single-click no confirm.
- Manual: Providers → ASR with active=local-qwen3 shows only the
"Local ASR is currently active — switch in Advanced" notice,
no manage UI.
* fix(advanced): pr_agent #400 follow-up — keep disable accessible on unsupported platform
If a user enables local-qwen3 (or foundry-local-whisper) on a machine
that supports it, then their preferences sync to a Linux/unsupported
machine, the previous Advanced UI rendered only the
"platformNotSupported" message and hid the disable button — they had
no in-app path to switch back to cloud and dictation would fail
silently on every press.
Move the disable SettingRow out of the platformSupported branch.
It's gated only on isOnAnyLocal now, so:
- Supported platform + on local → enable Qwen3/Foundry rows + disable row
- Supported platform + on cloud → enable rows only
- Unsupported platform + on local → "not supported" notice + disable row
- Unsupported platform + on cloud → "not supported" notice only
This is the pr_agent advisory from PR #400 review @ d0a425a1.
---------
Co-authored-by: baiqing <lbx12309@icloud.com>
Issue #372 reports repeated classifier errors such as 2粒 being intended as 2例. The existing vocabulary hotword path is probabilistic, so this adds a separate deterministic correction-rules store and applies supported literal/{num} rules in the coordinator before polish and before insertion.
The review follow-up keeps the UI refresh path consistent, handles remove failures, rejects rules that would otherwise save as enabled but never run, and fixes the Traditional Chinese wording.
Constraint: Keep correction rules separate from dictionary.json and vocab presets.
Constraint: Only support literal replacement and one {num} wildcard; do not expose arbitrary regex.
Rejected: Add import/export or a new top-level page | outside the approved scheme-C scope.
Rejected: Change history rawTranscript schema | product/data-model decision not approved in this review round.
Confidence: high
Scope-risk: moderate
Tested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib correction
Tested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib correction_rule_syntax_rejects_silent_noops
Tested: cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml
Tested: cd openless-all/app && npm run build
Tested: git diff --check
Related: #372
Co-authored-by: OmX <omx@oh-my-codex.dev>
pr_agent on PR #394 flagged a "Stale cache on read error" issue:
both load_credentials / load_credentials_for_update were
unconditionally writing the result into CREDENTIALS_CACHE, including
the legacy-file fallback returned when load_keyring_credentials() hit
an Err (e.g. user hasn't yet clicked "Always Allow" on the first
keychain dialog, login keychain locked, DataProtection error).
Effect: after a transient keyring failure the process pinned the
fallback (or default) creds in cache for its entire lifetime — even
after the user granted access, subsequent reads would never retry
the keyring and would keep returning stale defaults / legacy content
until the app is restarted.
Fix: cache only on the Ok(Some) and Ok(None)→migrate paths. The Err
path now returns the legacy fallback without populating the cache, so
the next call retries the keyring and picks up the user's just-granted
access.
185/185 lib tests pass; cargo check clean.
Without an in-process cache, every CredentialsVault::get_* /
get_active_asr / snapshot call drove load_credentials() →
load_keyring_credentials(), which reads the manifest entry plus every
chunk entry from the OS keyring. On macOS each distinct keychain entry
has its own ACL — so an ad-hoc-signed binary (or any binary whose ACL
grants haven't yet been "Always Allow"-ed) prompts the user on every
read of every entry. A single dictation cycle reads credentials 5–10
times, multiplied by (1 manifest + N chunks), produces tens of
"OpenLess wants to use the keychain" prompts per recording.
Add a process-wide CREDENTIALS_CACHE: OnceLock<Mutex<Option<CredsRoot>>>:
- load_credentials / load_credentials_for_update consult the cache
before falling through to Keychain. First read populates the cache;
every subsequent read in the same process is silent.
- save_credentials populates the cache with the cleaned root after
successful Keychain write, keeping Settings → Recording credential
edits visible immediately to dictation.
- migrate_legacy_sources{,_for_update} call save_credentials internally
so legacy-migration paths inherit the cache update for free.
Trade-off: cross-process changes (e.g. user runs `security` CLI
manually, or a second instance of the app — single-instance is enforced
but defense in depth) are invisible until next launch. Acceptable per
the credential vault contract: the keyring is owned by this app.
Note: this does NOT fix the underlying problem that ad-hoc-signed
builds change cdhash on every rebuild and lose "Always Allow" grants.
For that the build must use a stable Apple Developer ID signature
(APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / APPLE_TEAM_ID env
vars). The cache only ensures that within a single process lifetime,
the user is prompted at most once per Keychain entry.
185/185 lib tests pass. cargo check clean.
Two real bugs surfaced by the 2026-05-10 end-to-end logic review of
beta after the 8-PR audit-fix wave landed.
1. coordinator.rs:2313 — `acquire_recording_mute(inner, "qa");` was
missing `.await`. PR #391 (`6171df61`) made the function `async fn`
and updated the dictation call site at coordinator/dictation.rs:451,
but the QA call site was missed. Effect: when a user has
`mute_during_recording = true` and triggers QA via Right Option, the
returned Future is dropped on the next line, `spawn_blocking` is
never scheduled, holders never increments, and system audio is NOT
muted (e.g. YouTube playback continues into the QA recording). Both
the matching `release_recording_mute(inner, "qa")` calls become
no-ops (early return at holders == 0). The compiler was emitting
`unused_must_use` for this site (verified before this commit).
Fix: add `.await`.
2. coordinator/dictation.rs:843-849 — PR #387 (`ce82fcd9`) was framed
as "clear focus_target on cancel regardless of phase", but the only
code path that gained the unconditional clear was
`finish_cancel_session_state`. cancel_session deliberately skips
that helper for Processing (so end_session can drive its own
teardown), and end_session's "ASR-finished, cancelled" exit at
dictation.rs:843-849 set phase=Idle but never touched focus_target.
Result: cancel-during-Processing leaves a stale `usize` focus slot
in state.focus_target until the next begin_session_state overwrites
it. Today the leak is bounded (no documented reader between cancel
and next begin), but it violates PR #387's stated contract and is a
silent footgun for any future reader of focus_target on that
interval. Fix: in the cancelled-after-ASR exit branch, take the
state lock once and clear both phase and focus_target together.
Source: docs/logic-review-2026-05-10.md (subagent end-to-end review).
185/185 lib tests pass; cargo check clean (unused_must_use gone).
Manual verification checklist for both fixes in the review doc.
Addresses pr_agent "Possible Issue" review on PR #391. The original
`let _ = tokio::task::spawn_blocking(...).await` silently swallowed
JoinError, which means a panic inside AudioMuteGuard::activate() (or
the parking_lot lock) would leave the user with system audio leaking
during recording — and zero [audio-mute] log to explain why.
Match the JoinResult and log::error! the panic. State (holders/guard)
stays consistent under panic — activate panic means no guard stored
and holders not incremented; lock panic can't reach the mutate. The
fix is purely about observability.
Addresses pr_agent "Stale State" review on PR #389. show_capsule was
read on the audio callback thread and snapshotted into the
run_on_main_thread closure. If the user toggled the preference between
the audio-thread emit and the main-thread closure firing (one or two
frames later), the queued window show/hide could apply the old
preference and briefly flash the wrong UI state.
Move that read inside the closure so the latest user pref always wins.
Why visible / translation stay snapshotted: they're the payload of
THIS specific capsule:state event — the whole point is to tell the JS
frontend "this is the state at the moment emit_capsule was called".
Re-reading them inside the closure would mean the OS-level window state
and the JS-side payload disagree (the closure lands one frame later
than the emit_to that already shipped).
No behavior change in the steady state; eliminates a 16–33 ms stale-
pref window when the user changes show_capsule mid-dictation.
The five hotkey supervisor loops in coordinator.rs (dictation / qa /
combo / translation / action) and the global-hotkey dispatcher loop in
global_hotkey_runtime.rs run as `loop { ... sleep N }` with no exit
signal. In production the OS reaps these threads at process exit, but:
- Integration tests can't drive coordinator up/down without leaking
threads across runs.
- Future RunEvent::Exit cleanup work has nowhere to plug in.
- A panicked listener thread leaves the supervisor spinning forever
with no way to surface the failure.
Fix:
- Inner gains an `AtomicBool shutdown` field; each supervisor loop
reads it at the top of each iteration (before any work / sleep) and
returns when set.
- GlobalHotkeyRuntime gains a parallel AtomicBool shutdown; dispatcher
loop checks it before each 250 ms recv_timeout.
- Public methods Coordinator::request_shutdown and
GlobalHotkeyRuntime::request_shutdown set the respective flags.
Marked #[allow(dead_code)] until the RunEvent::Exit hook lands —
infrastructure-only change for now.
Crucially this is *passive*: nothing sets the flags in the production
binary, so behavior is unchanged. Tests that want bounded teardown can
now call request_shutdown explicitly.
Audit IDs 3.1.2 + 3.4.4 (CONFIRMED 中 + 低). 3.1.3 (JoinHandle storage)
is deferred — without a supervisor consumer for is_finished()
liveness checks, storing the handle is no-op.
Test: 183/183 lib tests pass. cargo check clean.
AudioMuteGuard::activate() shells out to osascript on macOS (~100–300 ms
AppleScript runtime startup) and to wpctl/pactl on Linux (similar cost).
Both implementations execute synchronously through std::process::Command.
acquire_recording_mute and release_recording_mute were called from the
async begin_session path, so the tokio worker thread was blocked for the
full shellout duration on every dictation start and stop.
Fix:
- Make acquire_recording_mute async; wrap the existing
Mutex/holders/activate body in tokio::task::spawn_blocking. The
parking_lot Mutex never crosses an await — it lives entirely inside
the blocking task. Caller (start_recorder_for_starting) awaits.
- release_recording_mute stays a sync entry point (some call sites are
on plain std::thread workers, e.g. the recorder error monitor) but
delegates to spawn_blocking when a tokio Handle::try_current is
available. Falls back to running synchronously when there's no tokio
context — that case is already on a non-async OS thread, so blocking
there blocks nothing.
- start_recorder_for_starting becomes async to thread the
acquire_recording_mute().await up. Three call sites (Bailian, generic
Volcengine, the start_recorder_and_enter_listening helper) get
matching .await? sequels.
Net effect: dictation-start latency on macOS no longer absorbs the
~100–300 ms osascript startup before Recorder::start can run; the work
runs on a tokio blocking pool thread instead. Same on Linux. Windows
path uses native COM (IAudioEndpointVolume::SetMute), already fast —
spawn_blocking is a small extra hop but harmless.
Audit ID 3.2.4 (CONFIRMED 中). Audit IDs 3.2.3 (sync inserter.insert)
and 3.2.5 (Linux-only 120 ms thread::sleep in permission probe) deferred
to follow-up — both are smaller-impact perf concerns and don't share
code with the audio-mute path.
Test: 183/183 lib tests pass. Manual verification (start dictation,
measure time-to-first-mic-capture before/after) requires the running
app and is expected to drop ~150 ms on macOS.
handle_pressed_edge previously routed the dictation hotkey to the QA
panel whenever qa_state.panel_visible was true, regardless of whether a
main dictation session was already active. If the user opened the QA
panel mid-dictation (or while polishing/inserting), the next dictation-
hotkey edge was routed into begin_qa_session, which calls
Recorder::start a second time on the same mic device. cpal usually
rejects the second build_input_stream on macOS/Windows but the
dictation session keeps running with no UX path to stop it from the QA
panel; on Linux/PipeWire the second open can succeed and you get two
concurrent capture streams competing for the audio device.
Symmetrically, handle_released_edge swallowed Released entirely when
the panel was visible — fine when QA owned the press, but if dictation
owned the press, Hold-mode dictation could never end because the
Released edge was eaten.
Fix: read inner.state.phase alongside panel_visible. If a dictation
session is non-Idle (Starting / Listening / Processing / Inserting),
both edges go to dictation regardless of panel_visible. The QA panel
stays open, just doesn't capture this hotkey.
3.3.4 (same PR, same files): open_qa_panel always emit'd CapsuleState::Idle
to sweep stale Done residue, but if dictation was mid-flight that
clobbered the in-flight capsule (Recording bar, Polishing progress, the
brief Done toast). Guard the sweep on dictation phase == Idle.
Audit IDs 3.3.1 + 3.3.4 (CONFIRMED 高 + 中).
Test: 183/183 lib tests pass. Manual verification (open QA panel during
dictation, press dictation hotkey — should stop dictation, not begin QA;
also Hold-mode dictation while panel visible should still stop on
release) requires the running app, to be done after merge.
emit_capsule does two things — (a) push a CapsulePayload event to the
JS frontend via app.emit_to, and (b) flip the OS-level window visibility
via window.show/hide and reposition it via maybe_position_capsule_*.
Path (a) is safe from any thread (Tauri event bus marshals internally).
Path (b) calls NSWindow / HWND APIs that expect the main thread —
but emit_capsule is invoked at ~30 Hz from the cpal process_callback
(audio thread) via the level_handler in coordinator/dictation.rs.
On macOS this risks dispatch_assert_queue_fail SIGTRAP if the AppKit
runloop assertion fires; on Windows SendMessage can deadlock against
the GUI thread; in either case the audio callback can miss its deadline
and trigger kAudioUnitErr_TooManyFramesToProcess on macOS. The 30 Hz
throttle limits frequency but does not change per-call risk.
Fix: wrap window.show / window.hide / maybe_position_capsule_bottom_center
/ show_capsule_window_no_activate / restore_main_window_key_if_active
in app.run_on_main_thread. The closure captures an Arc<Inner> clone +
AppHandle clone + Copy primitives (state, translation, show_capsule,
visible) — no payload clone needed. emit_to stays on the calling thread
since it is internally thread-safe, preserving the existing happens-
before relationship between event payload and window state on the JS
frontend (frontend processes events asynchronously regardless).
This pattern is already established in the codebase — see
coordinator.rs::stop_qa_hotkey_listener (line 326) for the same
reasoning around Carbon hotkey drop on the wrong thread (issue #169).
Audit ID 3.2.2 (CONFIRMED 高).
Test: 31 lib tests pass. Functional verification (no SIGTRAP on long
recordings, capsule still appears at start of dictation) requires
running the app — to be done after PR ships.
CFRunLoopRun() in run_listen_loop has no return path; when
HotkeyMonitor::drop runs adapter.shutdown(), the macOS branch fell
through to the empty default impl from the trait. So every preference-
driven monitor swap (e.g. user changes hotkey trigger) leaked the
listener thread + CGEventTap. Long-running sessions that cycle bindings
would steadily accumulate background threads.
Fix:
- Add CFRunLoopStop FFI extern.
- Add MacShutdownHandles { tap, runloop } shared via Arc between the
listener thread (writes refs in run_listen_loop after CGEventTapCreate
and CFRunLoopGetCurrent) and MacHotkeyAdapter (reads them in
shutdown).
- Implement MacHotkeyAdapter::shutdown — disable tap then stop runloop,
in that order, so the OS stops dispatching before we tear down.
take()s on the option locks make shutdown idempotent.
- run_listen_loop now does Box::from_raw(context) after CFRunLoopRun
returns so the listener thread releases the callback context before
exiting.
- Move tap re-enable in tap_callback (TAP_DISABLED_BY_TIMEOUT) to read
through ctx.handles.tap (same lock the adapter releases on shutdown,
consistent with the rest of the FFI thread-safety story).
Mirrors the Windows adapter's PostThreadMessageW(WM_QUIT) shutdown
pattern. Apple documents CGEventTapEnable + CFRunLoopStop as safe to
call from any thread, captured in a SAFETY comment on the
unsafe Send/Sync impl.
Audit ID 3.1.1 (CONFIRMED 严重).
Test: hotkey + coordinator + types + commands lib tests pass (31/31).
Functional verification of the leak fix requires running the app and
swapping bindings — to be done after PR ships.
finish_cancel_session_state previously only cleared state.focus_target
when the cancelled phase was not Processing — Processing-phase cancel
left the stale window-resource handle in place until the next
begin_session_state overwrote it. Between cancel and next begin, any
reader of focus_target sees a value belonging to a session that is
about to be torn down.
Move state.focus_target = None ahead of the phase check so it always
fires when the cancel was accepted (i.e. not rejected for Idle /
Inserting phases). Phase transition stays conditional on the original
non-Processing branch, since end_session must still own the
Processing → Idle collapse to avoid racing with polish/insert.
Tighten cancel_session_state_machine_is_table_driven so it asserts
focus_target gets cleared for every accepted phase (including
Processing) and stays untouched on rejected cancels.
Audit ID 3.3.5.
Rust types.rs declares pub update_channel: UpdateChannel, but the TS
UserPreferences interface in src/lib/types.ts and the mockSettings
fixture in src/lib/ipc.ts both omitted the field. setSettings()
round-trips were lossless only because Rust's serde(default) refilled
the field, but consumers that destructured UserPreferences would
silently miss it.
- Move UpdateChannel type from ipc.ts to types.ts (avoids ipc <-> types
circular import); ipc.ts re-exports for backward compat with
SettingsModal etc.
- Add updateChannel: UpdateChannel to UserPreferences interface.
- Add updateChannel: 'stable' to mockSettings fixture.
- Update stylePrefs.test.ts fixture for new required field.
Audit ID 2.2.1 (docs/audit-2026-05-10-validated.md, kept local).
Bailian uses Notify::notify_waiters when the remote task starts. send_last_frame must register the Notified future before checking the started flag so a task-started event cannot land between the check and waiter registration.\n\nConstraint: DashScope stop should not falsely time out when task-started races with user stop.\nRejected: Replace Notify with polling | broader and less direct than the Tokio enable pattern.\nConfidence: high\nScope-risk: narrow\nTested: cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml\nTested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib asr::bailian::tests\nCo-authored-by: OmX <omx@oh-my-codex.dev>
Bailian startup stores the ASR instance before opening the remote session so recorder startup audio can be buffered. If the websocket setup then fails in the active startup path, the provider must be cancelled before the coordinator returns to idle. The partial-result fallback remains intentional and is now documented as matching the existing Volcengine no-loss behavior.\n\nConstraint: OpenLess ASR paths prefer not losing already recognized user speech.\nRejected: Treat every websocket close with partial text as failure | would diverge from Volcengine fallback semantics.\nConfidence: high\nScope-risk: narrow\nTested: cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml\nTested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib\nTested: cargo test --manifest-path openless-all/app/src-tauri/backend-tests/Cargo.toml\nCo-authored-by: OmX <omx@oh-my-codex.dev>
Review noted that changing defaultMode after turning Style master off made the switch look on again because enabledModes still reflected the old fallback pair.\n\nWhen master is already off, default-mode changes now persist the whole style preference set through setSettings so enabledModes is recomputed to raw plus the new default. Normal enabled master behavior still uses the narrower setDefaultPolishMode IPC.\n\nConstraint: Issue #312 selected raw/default fallback semantics for master-off state.\nRejected: Add a separate persisted masterEnabled flag | broader schema and migration change for a narrow UI consistency bug.\nConfidence: high\nScope-risk: narrow\nTested: cd openless-all/app && npx tsx src/lib/stylePrefs.test.ts\nTested: cd openless-all/app && npm run build\nNot-tested: Manual Style page click flow in packaged Tauri app.\nRelated: https://github.com/appergb/openless/pull/383\nCo-authored-by: OmX <omx@oh-my-codex.dev>
coordinator.rs had accumulated dictation lifecycle, QA panel state, and shared recording resources in one file. This split keeps behavior unchanged while moving the dictation edge/session helpers, QA panel state helpers, and resource ownership helpers into child modules under coordinator/.
Constraint: Issue #296 asks for module boundaries after Rust state-machine coverage is in place
Rejected: Split every helper into separate public modules | larger churn with no behavior benefit
Confidence: high
Scope-risk: moderate
Directive: Keep cross-module wiring in coordinator; leaf modules should remain coordinator-private
Tested: cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml
Tested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib
Tested: cargo test --manifest-path openless-all/app/src-tauri/backend-tests/Cargo.toml
Co-authored-by: OmX <omx@oh-my-codex.dev>