You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[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
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:917 — onError: () => { /* 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.
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:
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.
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:
18 mic failures, all on 2026-08-01, none since — and the main recognizer does report (onError → reportClientError, 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/src → no 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.
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).
The report
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
sttRef)getUserMedia(Whisper) or Web Speech (dictation)createSpeechGate)SpeechRecognitionctrlStt)SpeechRecognitionBoth background ones re-arm themselves on every
onend:gate.ts:139-149—r.onend = () => { … try { r.start(); } … }use-voice.ts:918—onEnd: () => { if (ctrlWantRef.current) { try { ctrlSttRef.current?.start(); } catch {} } }Chrome's
SpeechRecognitionends itself after a short silence, so while voice is engaged this is a perpetual start → end → start cycle. Chrome activates the microphone on everystart(). 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:917—onError: () => { /* 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:137—r.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: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:
18 mic failures, all on 2026-08-01, none since — and the main recognizer does report (
onError→reportClientError,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/src→ no matches. The runner:launchPersistentContext(runner.ts:648-665) with nopermissionsoption, so a page it opens gets Chrome's real permission UI;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;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) copiesPreferences(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 unlessPAGS_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.argsinrunner.ts:625: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'sonErrorand the gate'sonerrora report — at minimum fornot-allowed/service-not-allowed, which are permission verdicts and not the ordinaryno-speech/abortednoise 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 (
shouldRunControlListeneryields 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:
coding/terminal.ts:279-305shells out toosascriptfor the tmux/iTerm2/kitty operator agents, and:283already 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-streamalone. Rejected: it silently auto-grants the real microphone to any automated page. Strictly worse than the prompt it removes.grantPermissions(['microphone']). Rejected for the same reason — it hands a live microphone to arbitrary third-party pages the agent is driving.onend.Acceptance criteria
not-allowedfrom the control listener or the gate produces a durable error row.no-speech/abortedstill produce no log entries (no new flood — see [bug] The stats rollup has failed on every cron tick since it shipped — 1780 identical D1 compound-SELECT errors in 29h, and it is 97% of the error log #423).Regression risk
reportClientErroralready de-dups per source+message per 30s (client.ts:46).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).