Repository navigation
[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
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-149—r.onend = () => { … try { r.start(); } … }use-voice.ts:918—onEnd: () => { 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: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:
- 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:
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 (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 nopermissionsoption, 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-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.- 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
- The automation browser never shows a media-permission prompt, and an automated page cannot reach the real microphone.
- A
not-allowedfrom the control listener or the gate produces a durable error row. - A denied control listener stops re-arming and tells the user once that voice commands are unavailable.
- Benign
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
- Adding reports to two handlers that fire constantly risks flooding the log; gate strictly on the permission error codes, and note
reportClientErroralready 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).
Activity
fix(voice,runner): a refused microphone stops asking, and stops doing…
docs(voice): ADR 0001's known hole, decided — the gap stays and the a…
serge-ivo commentedon Aug 8, 2026
Shipped in b2a3634, live (CI green, Host + API workers deployed).
Items 1-3 done. Item 4 deliberately not attempted.
1. The automation browser. Both flags, in
runner.ts'sbaseOpts.args. Your reasoning for the pair is in the code comment beside them and in a test, because--use-fake-ui-for-media-streamalone auto-grants the real microphone and "remove the redundant-looking flag" is the obvious tidy-up. The test also asserts the negative — nograntPermissions(['microphone'])anywhere — so the rejected alternative can't drift back in.2. Denials are observable. Both handlers report:
voice-controlfor the control listener,voice-gatefor the gate. Gated on the permission codes plusaudio-capture, silent forno-speech/aborted, and ridingreportClientError's existing per-message 30s de-dup so a path that fires every few seconds cannot flood the log (#423).3. Surfaced once, and the re-arm stops.
The one place I split your spec
You wrote "at minimum for
not-allowed/service-not-allowed", and your regression note said the re-arm latch must not catch a transient. Those two pull in opposite directions foraudio-capture, so they are now two predicates, not one:isReportableMicError— the two permission verdicts plusaudio-capture. A missing device is diagnostic.isMicPermissionDenied— the two verdicts only. This is what stops the re-arm.audio-capturecan be two recognizers contending for one microphone. Latching the control listener off on that would silently disable mute-by-voice for the rest of the session — the exact ADR 0001 M1 failure this ticket exists to expose. It is reported and keeps trying. There is a test whose whole job is that negative.What this closed on #388's side
Your framing — that an ADR 0001 violation was unobservable in production by construction — turned out to be the sharper half of this ticket. It is also the second way into the ADR's recorded "known hole", and the worse one: the API is present and the capability is gone while everything looks supported. #388's last open item (what to do about the no-Web-Speech browser) was decided in the same batch and produces the same message, because from the user's side those are one event.
Still not answered — and it needs you
Which prompt you are seeing. Everything above is worth doing either way, but the fix for the frequency depends on it:
coding/terminal.ts'sosascriptAutomation prompts, which none of this touches.The app name in the dialog is the one fact that decides it.
Verified / not verified
onerrorthenonend) and assert the restart count, so "stops re-arming" is measured rather than asserted structurally.control listener refused the microphoneandspeech gate refused the microphone.Item 4 (three recognizers → one) is not done. You called it a design change rather than a fix and said it should not block 1-3; I agree, and it wants its own issue with the yielding rules stated, because
shouldRunControlListenerand the gate have different lifetimes on purpose.fix(supervision): the Lead declares transfer_conversation, so the tra…
serge-ivo commentedon Aug 8, 2026
Closing — all four acceptance criteria verified against the code on
main.b2a3634is onorigin/main; CI + Deploy Host/API green on the batch head2f33801.baseOpts.args,runner.ts:641-642, spread into all three launch paths (:667,:669,:675,:683);runner.test.ts:116-126asserts both flags and the negative,not.toMatch(/grantPermissions\([^)]*(microphone|camera)/)not-allowedfrom the control listener or the gate produces a durable rowuse-voice.ts:986-996(voice-control),gate.ts:150-156→use-voice.ts:329(voice-gate)use-voice.ts:990-995clearsctrlWantRefsoonEnd's re-arm at:997is dead, plus the notice; gate latch checked in bothonend(gate.ts:161) andstart()(:176)no-speech/abortedstill produce no rowsgate.test.ts"keeps re-arming through the ordinary churn a continuous recognizer produces"Your spec-split is the right call and is verified as two predicates, not one:
isMicPermissionDenied(convo.ts:911-913) = the two verdicts only, and it is what stops the re-arm;isReportableMicError(convo.ts:925-926) = those plusaudio-capture, which reports and keeps trying.mute-invariant.test.tsasserts the narrowness — latching onaudio-capturewould silently delete mute-by-voice, the exact ADR 0001 M1 failure this ticket existed to expose.Production console bundle carries
control listener refused the microphone,speech gate refused the microphone,Voice commands are off. The runner flags are not greppable from the deployed console and were not claimed to be —packages/browser-runneris a Node package, not part of that bundle.One correction to the closing comment
It says both handlers are gated on "the permission codes plus
audio-capture". The control listener is; the gate is not —gate.ts:154usesisMicPermissionDeniedonly, so a gateaudio-capturestill produces no row. Criterion 2 names onlynot-allowed, so it is met either way, but the two handlers are not symmetric as described, and a reader taking that sentence as the spec would be wrong about half of it.Not done, correctly — and one thing that is genuinely still open
Item 4 (three recognizers → one) is not an acceptance criterion — the checklist has four items, none about consolidation, and the body itself calls it "a design change, not a fix… should not block 1-3". Legitimately its own ticket.
But the reporter's actual symptom is still undiagnosed. "Automation pops up and asks me for microphone use all the time" — the Chrome-in-page-bubble vs macOS-system-dialog question is unanswered, and no acceptance criterion covers it. Everything shipped here is worth having either way, and closing on the criteria is defensible; closing on the user's complaint is not. The one fact that decides it is still which app name appears in the dialog — if it names Terminal/iTerm2 rather than Chrome, none of this touches it and
coding/terminal.ts'sosascriptAutomation prompts are where to look. That question should carry forward rather than close with this.Not verified, and not claimed
The runner flags against a real launched Chrome, and a real denied microphone in a real browser. Both need a live device.
fix(voice): a hands-free send hands the bubble to the message in one …
serge-ivo commentedon Aug 12, 2026
Still occurring in production — 20 recorded refusals, 10 in one 94-minute window
The owner reports voice "sometimes not capturing".
list_errorsshows the control listener being refused the microphone, live:use-voice.ts:1072-1082reports but deliberately does not latch onaudio-capture, soonEndre-arms forever (:1083). Three recognizers share one input device in Whisper mode — control listener, speech gate, MediaRecorder — which is this issue's subject.Why it matters beyond the control channel: the timing correlates with him giving up on voice. On Chess coder 2 every user message 21:44–21:48 carries an
audioKey; the messages at 22:32:14, 22:43:02 and 22:43:33 carry none (typed). That window opens 23 minutes into the refusal burst and immediately after a transcription timeout at 22:09:07. Voice returns 22:57–23:05, then the last voice message anywhere is 23:05:35 — and the last mic refusal is 23:05:58. Every user message after that is typed.Correlation, not proof of cause, and stated as such. But two "voice-off" windows each beginning within a minute of a recorded microphone failure is the strongest live signal available, and this is a recorded failure of the mute/next/back channel regardless.
Reopening rather than filing a near-duplicate. Related new issues from the same investigation: the silent-drop paths and the gate calibration.
fix(voice): a refused microphone stops being re-acquired at the brows…
serge-ivo commentedon Aug 12, 2026
Shipped in
4cf17eb, live and verified in the production bundle. Leaving OPEN — see the last section.Your reopen said the control listener "reports but deliberately does not latch on
audio-capture, soonEndre-arms forever". The first half is right and the second half is right for a different reason than the line numbers suggest, so before the fix, the trace:The latch shipped in
b2a3634could never fire — foraudio-captureOR fornot-alloweduse-voice.tsclearedctrlWantRef, and the only reader of that flag is theonEndat:1083.onEndisVoiceStt.onEnd, and it was reached only through theelseofstt.ts'srec.onend:listeningis set true bystart()and cleared only bystop().rec.onerrorcleared nothing (stt.ts:341-343before this change). So after a deniallisteningwas still true, the recognizer's OWN loop restarted it,this.onEndwas never called at all, andctrlWantRef = falsewas dead state written by a handler that could not reach the loop it was added to stop. My closing comment on this ticket claimed criterion 3 was MET on the strength of:990-995clearing the flag; that was a read of the flag's write site without tracing its read site, and it was wrong.And the restart had no delay and no memory
rec.onendcalledrec.start()immediately. TherestartFails/250ms path only covers astart()that throws, andaudio-capturedoes not throw — it starts and errors again. So the cycle waserror → end → startas fast as Chrome will run it, against a MediaRecorder that is asynchronously releasing the same device, with Chrome activating the mic on everystart().gate.tshad the identical shape.What landed
packages/sdk/src/voice/mic-retry.ts— one pure rule both re-arming loops consult:no-speech,aborted, nonenot-allowed,service-not-allowedstt.tsnow clearslistening, which is what hands control toonEndso the latch and notice actually runaudio-capture)I did not latch on
audio-capture, and the narrowness is now enforced by a test rather than by a comment. Your own regression note is the reason: latching there deletes mute-by-voice for the session — the ADR 0001 M1 failure this ticket exists to expose. Backoff is the answer that satisfies both constraints. A run that has not cleared after four consecutive failures (~6s) instead says so on screen, once, without stopping the retries: "Voice commands can't reach the microphone right now — still retrying." Your symptom was silence, not a wrong message.Three smaller things fixed on the way:
audio-capturetoo. The asymmetry my own close comment flagged and left. It was the one recognizer that could not report the code it is most likely to produce, being the one that competes with the recorder.ctrlWantRefon every mic transition, so clearing it insideonErrorstopped the re-arm only until your next turn, and then asked the refused device again.ctrlDeniedRefis what makes "once" true.fails,burstMs). With de-dup at one row per 30s,fails: 2andfails: 240are the same single row and mean completely different things — see below.Verified
7f66324. The two core tests were checked against the pre-fix behaviour and fail on it (permission verdict →listeningstayed true andonEndwas never called; contention → the restart fired immediately).rs(e)isplanMicRestart—Do(e.code)→{restart:!1,delayMs:0,terminal:!0}, benign →delayMs:0, else →Math.min(400*2**(e-1),$o)— andns(e){return e===4}is the once-per-run notice threshold.4cf17eband on the batch head.NOT verified, and this is why it stays open
Nothing here was tested against a real microphone, a real denial, or real Chrome. Every assertion is driven through a fake
SpeechRecognition; the claim that Chrome activates the mic on eachstart()is Chrome's documented behaviour, not this suite's finding. I have no device.Your correlation is still a correlation. I did not reproduce voice going quiet. What decides it is one measurement, and it now exists:
Your original question is also still unanswered: whether the prompt you see is Chrome's in-page bubble or a macOS system dialog. If it names Terminal/iTerm2,
coding/terminal.ts'sosascriptAutomation prompts are where to look and none of this touches it.Item 4 (three recognizers → one) is still not done, still correctly its own ticket. What this change does is make the contention between them survivable and measurable, which is the precondition for deciding whether consolidating them is worth the yielding rules it would need.
One separate finding, already closed by someone else
vad.ts's "~330 ms at 66 ms/frame" comment was wrong by 4× —LEVEL_THROTTLE_MSthrottles only the ReactsetAudioLevelcall whilevadStepruns every rAF tick. It is already corrected onmain(1925dfa, #511), which also bounds what a poisoned floor estimate can cost viaONSET_FLOOR_CEILING. No issue needed.fix(voice): the dictation gate can only condemn a turn it is actually…
fix(voice): a recognizer waits for the previous one's close instead o…
docs(voice): the burst counter is 30s because that is the CLIENT's th…
serge-ivo commentedon Aug 15, 2026
The measurement you asked for, taken — and it says branch 1
Two things this comment carries: the reading of
fails/burstMs, andbdc5517e, which shipped forthis ticket on 2026-08-12 and was never reported here.
bdc5517e— serialised acquisition, already shippedpackages/sdk/src/voice/mic-handoff.ts: one module-level handoff every consumer on the page shares,so a consumer waits for someone else's in-flight close before it opens the device. Both
recognizers and the recorder participate (
gate.ts,stt.ts— the control listener is aVoiceStt,so it goes through the same path). It does not decide who may listen and it does not stop the
gate/recorder coexistence live dictation is made of; only an owner that is closing blocks, and
every wait is capped at 300ms with the waiter running anyway, because a wait here is a gap in
mute-by-voice (ADR 0001 M1).
It also fixed why
4cf17eb's backoff could never work: both loops zeroedmicFailsonisBenignRecognizerEnd, which includesaborted— the code our ownstop()raises at everyturn boundary, which is exactly where the contention is.
The reading —
fails: 1, burstMs: 0, everywhere15
client:voice-control+ 4client:voice-gaterows over the full 23-day log. Six carry theinstrumentation; every one reads
fails: 1, burstMs: 0. Two of those are provably post-bdc5517e(
build: cfe8ff7926ac), and one of the two is a standalone row withrepeat_count: 1thatcannot be a collapse artefact. The 4-occurrence row's
contextandlast_contextagree across 10.5minutes. Maximum
failsobserved anywhere: 1.Against your own rule that is branch 1 — turn-boundary contention, benign, absorbed by the
backoff. Nothing resembling "tens or hundreds with
burstMsin the minutes". I traced the inputsrather than trusting the number:
micRunlives on the gate object and theVoiceSttinstance(
gate.ts:183,stt.ts:170), is cleared only by proof of capture (onaudiostart/ a result),and the gate is rebuilt only on a language change — so it survives turn boundaries and a
fails: 1means a real capture happened in between.
One correction that changes how those rows read —
02fd9095mic-retry.tssaid 30s "is the durable log's own de-dup window, which makesfailsand the rowcount answer the same question over the same period". It is not.
COLLAPSE_WINDOW_MSis 60minutes (
error-log.ts:90, unchanged since8c55639f, i.e. before every row this ticket hascited). The 30s is
reportClientError's send-side throttle (client.ts:81).That sentence made
repeat_count: 11next tofails: 1look contradictory. Under the real mechanismthey are consistent and together they say something precise: eleven failures reached the log
inside one collapse hour, and each was the first of its burst — more than 30s after the previous,
with a proven capture in between. Scattered contention, not a spin. The constant stays at 30s, for
the corrected reason: it matches the interval at which a report can reach the log at all.
Where that leaves the ticket
back large — is already shipped (
bdc5517e). The number came back small, so it is belt andbraces rather than the cure, and that is the right order.
data now argues against urgency: the contention is scattered and absorbed, not a spin.
the dialog names Terminal/iTerm2,
coding/terminal.ts'sosascriptprompts are where to look andnone of this touches it. The app name in the dialog is still the one fact that decides it.
Not verified
SpeechRecognition.bdc5517everdict rests on 2 build-stamped rows. That is a thin sample and I am notdressing it up — and no more will accrue: voice has been unused in production since 2026-08-12
12:21Z. If the burst counter ever comes back in the tens with
burstMsin the minutes, thehandoff module is where to look first, not the delay.
serge-ivo commentedon Aug 18, 2026
Audit re-verification: all four acceptance criteria are met on
main. What keeps this open is not an AC.packages/browser-runner/src/runner.test.ts:180"never lets an automated page prompt for, or reach, the real microphone" — asserts both--use-fake-ui-for-media-streamand--use-fake-device-for-media-stream, with the comment recording why the first alone is strictly worse than prompting (it auto-grants the real device), and:189asserts nograntPermissions(['microphone'|'camera'])anywhere in the sourcenot-allowedfrom the control listener or the gate produces a durable error rowuse-voice.ts:1094reportClientError("voice-control", …)and:337reportClientError("voice-gate", …)4cf17ebf(backoff + latch) andb2a36340(convo.ts,gate.ts,use-voice.ts, plusmute-invariant.test.ts)no-speech/abortedstill produce no entriesconvo.ts:1003(no-speech→"soft") and:1042— "Deliberately NARROW.no-speechandabortedare the ordinary churn those handlers exist to…"bdc5517ethen addedpackages/sdk/src/voice/mic-handoff.ts— a shared module-level handoff so a consumer waits for another's in-flight close before opening the device — which also explains why4cf17eb's backoff could never fire: both loops zeroedmicFailsonisBenignRecognizerEnd, which includesaborted, the code our ownstop()raises at every turn boundary.What is actually left, and it is two different things
failsobserved anywhere is 1,burstMs: 0, across the whole 23-day log), but the finding will be lost when this closes.coding/terminal.ts'sosascriptprompts are where to look and none of this touches it.Also worth noting: no more evidence will accrue — voice has been unused in production since 2026-08-12 12:21Z, so the post-fix verdict rests on 2 build-stamped rows and will stay there.
Recommendation
File the recognizer-consolidation as its own ticket carrying the
fails: 1 / burstMs: 0measurement, then close this. If you can answer the dialog question first, do — it is the one input that could reopen the whole diagnosis.serge-ivo commentedon Aug 23, 2026
DELIVERED — closing. All four acceptance criteria met on
main, and AC2 is confirmed by production rows.Commits:
b2a36340,4cf17ebf,bdc5517e(and02fd9095, the figure correction).HEADpackages/browser-runner/src/runner.ts:648-649adds both--use-fake-ui-for-media-streamand--use-fake-device-for-media-stream, with:637-638recording why the first alone is strictly worse than prompting (it auto-grants the real device). Asserted atrunner.test.ts:184-185, and:189asserts nograntPermissions(['microphone'|'camera'])anywhere in the sourcenot-allowedfrom the control listener or the gate produces a durable error rowpackages/sdk/src/voice/convo.ts:1048isMicPermissionDenied; reporters atuse-voice.ts:1094(voice-control) and:337(voice-gate)packages/sdk/src/voice/mic-retry.ts:181—if (isMicPermissionDenied(s.code)) return { restart: false, delayMs: 0, terminal: true };— anduse-voice.ts:1095-1099, which sets the notice"⚠ Voice commands are off — the microphone is blocked for this site."no-speech/abortedstill produce no entriesconvo.ts:1003mapsno-speech→"soft", and:1042-1046states the narrowing deliberately: "no-speechandabortedare the ordinary churn those handlers exist to ignore, andaudio-capturecan be produced by two recognizers contending for one device — a transient that must NOT permanently disable the control listener, because doing so would silently remove mute-by-voice and cause the exact ADR 0001 failure this ticket is meant to expose."AC2 measured in production, not inferred
GET /v1/errorstoday returns rows that could not have existed before this shipped — the two background recognizers wereonError: () => {}andr.onerror = () => {}:Both denials and the diagnostic missing-device case now reach the durable log under their own source names. They correctly stay at
level: "error"after #571's source split — these are genuine failures, which #571 explicitly carved out.What is NOT closed by this, and where it went
main(stt.ts:376,gate.ts:57-58,use-voice.ts:1074) and the two compensating mechanisms the shape has already forced into existence (mic-handoff.ts, and theaudio-capturecarve-out above).coding/terminal.ts'sosascriptprompts are where to look and none of this touches it. Closing on the code rather than waiting for it — reopen with that one fact if the prompting recurs.Verified: every
file:lineabove; the four production rows quoted fromGET /v1/errors. Inferred: nothing about the automation-browser half — the Chrome flags are read fromrunner.tsand pinned by a test, not observed on a running instance.