Skip to content

[bug] Repeated microphone prompts: three recognizers share one mic and two re-arm forever — and a denial in either is completely unlogged, so mute-by-voice can die silently #425

Description

@serge-ivo

The report

"automation pops up and asks me for microphone use all the time. Does it actually run properly or is it blocked?"

Two separate things are true, and they need separating before anything is changed.

Verified: PAGS runs three microphone consumers, and two of them restart forever

# Consumer API Lifecycle
1 Main recognizer (sttRef) getUserMedia (Whisper) or Web Speech (dictation) Per turn
2 Dictation gate (createSpeechGate) Web Speech SpeechRecognition Per turn, self-restarting
3 Always-on control listener (ctrlStt) Web Speech SpeechRecognition Whenever voice is engaged and the main mic is idle, self-restarting

Both background ones re-arm themselves on every onend:

  • gate.ts:139-149r.onend = () => { … try { r.start(); } … }
  • use-voice.ts:918onEnd: () => { if (ctrlWantRef.current) { try { ctrlSttRef.current?.start(); } catch {} } }

Chrome's SpeechRecognition ends itself after a short silence, so while voice is engaged this is a perpetual start → end → start cycle. Chrome activates the microphone on every start(). If the origin's mic grant is not persistent — most commonly because "Allow this time" was chosen rather than "Allow on every visit" — Chrome re-prompts on each activation. That is the shape of "asks me all the time": not one prompt per session, one per recognizer restart.

I have not verified which prompt you are seeing (Chrome's in-page bubble vs. a macOS system dialog), and that distinction changes the fix — see "What we need from the reporter" below.

Verified: if the mic IS denied, both background recognizers fail completely silently

  • use-voice.ts:917onError: () => { /* mic denied / no-speech — onEnd re-arms if still wanted */ }. The handler is empty and its own comment names "mic denied" as an expected case.
  • use-voice.ts:918 — restart failure: catch { /* SR busy */ }.
  • gate.ts:137r.onerror = () => {}.
  • gate.ts:145-149 — after 3 failed restarts: catch { /* give up quietly */ }.

No error row, no notice, no state change, nothing in /v1/errors. Two consequences:

  1. Mute-by-voice can die without a trace. The control listener is the ONLY listener running while the agent is speaking or thinking — that is the entire reason Voice: mute command only active during active recording — must work at all times (during TTS, agent processing, co-pilot chat) #153 built it, and ADR 0001 M1 requires mute to be reachable in every phase. A permission denial silently removes it, and the ADR's own "known hole" paragraph anticipates the browser-support case but not this one. An ADR 0001 violation is currently unobservable in production.
  2. A dead gate changes turn handling: planNoiseRejection (turn.ts:171) discards a noise-shaped transcript when !gate.isAlive || !gate.heardSpeech, so a blocked gate makes marginal turns get dropped instead of kept.

So: is it running, or blocked?

The main path is running. Production error log, by signature:

client:voice::not-allowed   count 3   first/last 2026-08-01
client:voice::audio-capture count 15  2026-08-01 03:20 → 06:07

18 mic failures, all on 2026-08-01, none since — and the main recognizer does report (onErrorreportClientError, use-voice.ts:1260). So the main recognizer is not currently being denied.

But "no errors" is not evidence the other two work, because #2 and #3 cannot report. That is the honest answer to "does it actually run properly": the part that can tell us is fine; the parts that can't are unknown, and the product has no way to know either.

Verified: the automation browser never needs the microphone, and has no policy about it

grep -niE "microphone|audio|grantPermissions|permissions:" packages/browser-runner/srcno matches. The runner:

  • launches real Chrome via launchPersistentContext (runner.ts:648-665) with no permissions option, so a page it opens gets Chrome's real permission UI;
  • navigates only to task URLs (runner.ts:565) — job/ATS pages. It never hosts the PAGS console; takeover is viewed from the console, not by running the console inside the automation browser;
  • has no audio capability anywhere in the package.

So any mic prompt originating from the automation browser is pure noise — a site asking, nothing needing it.

One profile subtlety worth knowing: seedProfileCopy (browser-profile.ts:13-49) copies Preferences (where Chrome stores site permissions) but returns early if the destination exists (:14). So a grant made in your real Chrome after the first seed never reaches the copy unless PAGS_RUNNER_REFRESH_PROFILE=1.

What to do

1. Automation browser — suppress media prompts outright (cheap, unambiguous). It never needs the mic, so this is not a workaround, it is correct policy. Add to baseOpts.args in runner.ts:625:

"--use-fake-ui-for-media-stream",     // auto-answer the prompt, never show UI
"--use-fake-device-for-media-stream", // and hand over a synthetic device, not your mic

The pair matters: the first alone auto-grants the real microphone. Together they mean no prompt and no real audio ever reaches an automated page. Safe because nothing in the runner uses media, and the console (where voice really runs) is a different browser.

2. Make a denied background recognizer observable (this is the real bug). Give ctrlStt's onError and the gate's onerror a report — at minimum for not-allowed / service-not-allowed, which are permission verdicts and not the ordinary no-speech/aborted noise those handlers exist to ignore. Keep them silent for the benign codes. Without this, ADR 0001 M1 can be violated in production and nobody learns.

3. Surface it to the user once, not per restart. If the control listener is denied while voice is engaged, say so ("Voice commands are off — microphone blocked for this site") and stop re-arming. Re-arming into a denied device forever is what turns one denial into an endless prompt loop.

4. Reduce the churn. Three recognizers for one microphone is the root cause of the prompt frequency. The gate and the control listener never run at the same time by design (shouldRunControlListener yields while the main mic records) — worth asking whether they can be one recognizer with two consumers. That is a design change, not a fix, and should not block 1-3.

What we need from the reporter to finish this

The remaining question is which prompt it is, and I cannot determine that from the repo:

  • Chrome in-page bubble ("proagentstore.online wants to use your microphone") → cause is a non-persistent grant + the restart loop. Fix: choose "Allow on every visit" in the site settings; items 2-4 above make it diagnosable.
  • macOS system dialog ("«App» would like to access the microphone", or an Automation prompt naming Terminal/iTerm2) → different subsystem. Note coding/terminal.ts:279-305 shells out to osascript for the tmux/iTerm2/kitty operator agents, and :283 already anticipates macOS Automation prompts. If the dialog names a terminal app rather than Chrome, that is where to look, and item 1 is irrelevant.

Which app name appears in the dialog is the one fact that decides it.

Alternatives considered and rejected

  • --use-fake-ui-for-media-stream alone. Rejected: it silently auto-grants the real microphone to any automated page. Strictly worse than the prompt it removes.
  • Grant the mic to the automation context via grantPermissions(['microphone']). Rejected for the same reason — it hands a live microphone to arbitrary third-party pages the agent is driving.
  • Stop the control listener from restarting at all. Rejected: that is Voice: mute command only active during active recording — must work at all times (during TTS, agent processing, co-pilot chat) #153/ADR 0001 M1 deleted. Stop re-arming only on a permission denial, which is a terminal condition, not on the ordinary onend.
  • Suppress the prompt in the user's own console browser. Not possible and not desirable — a real mic grant must stay a user decision.

Acceptance criteria

Regression risk

  • Adding reports to two handlers that fire constantly risks flooding the log; gate strictly on the permission error codes, and note reportClientError already de-dups per source+message per 30s (client.ts:46).
  • Stopping the re-arm on denial must not catch a transient aborted — that would silently disable mute, i.e. cause the exact ADR 0001 failure this is meant to expose.

Related: #153 (the control listener), ADR 0001 (mute reachable in every phase — M1 and its "known hole" paragraph), #424 (the failure-logging audit; these are two more unlogged paths), #423 (why new logging must be de-duplicated).

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1: blocks external usersMust be true before someone who is not the owner can run an agent (#68)bugSomething isn't workingfrontendFrontend / UI workvoiceVoice / STT / TTS

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions