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

Closed

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-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:

  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 (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.
  • 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).

Activity

  1. serge-ivo commented on Aug 8, 2026

    @serge-ivo
    ContributorAuthor

    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's baseOpts.args. Your reasoning for the pair is in the code comment beside them and in a test, because --use-fake-ui-for-media-stream alone auto-grants the real microphone and "remove the redundant-looking flag" is the obvious tidy-up. The test also asserts the negative — no grantPermissions(['microphone']) anywhere — so the rejected alternative can't drift back in.

    2. Denials are observable. Both handlers report: voice-control for the control listener, voice-gate for the gate. Gated on the permission codes plus audio-capture, silent for no-speech/aborted, and riding reportClientError'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 for audio-capture, so they are now two predicates, not one:

    • isReportableMicError — the two permission verdicts plus audio-capture. A missing device is diagnostic.
    • isMicPermissionDenied — the two verdicts only. This is what stops the re-arm.

    audio-capture can 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:

    • Chrome in-page bubble → the restart loop, and this change addresses it.
    • macOS system dialog naming Terminal/iTerm2 → coding/terminal.ts's osascript Automation prompts, which none of this touches.

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

    Verified / not verified

    • Verified: 6,606 unit tests, full CI bar, 249 e2e specs (chromium + webkit). The gate tests drive Chrome's real cycle (onerror then onend) and assert the restart count, so "stops re-arming" is measured rather than asserted structurally.
    • Verified live: the production console bundle carries control listener refused the microphone and speech gate refused the microphone.
    • Not verified: the runner flags against a real launched Chrome, and a real denied microphone in a real browser. Both need a live device.

    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 shouldRunControlListener and the gate have different lifetimes on purpose.

  2. serge-ivo commented on Aug 8, 2026

    @serge-ivo
    ContributorAuthor

    Closing — all four acceptance criteria verified against the code on main.

    b2a3634 is on origin/main; CI + Deploy Host/API green on the batch head 2f33801.

    # Criterion Verdict
    1 Automation browser never prompts; an automated page cannot reach the real mic MET (statically) — both flags in baseOpts.args, runner.ts:641-642, spread into all three launch paths (:667,:669,:675,:683); runner.test.ts:116-126 asserts both flags and the negative, not.toMatch(/grantPermissions\([^)]*(microphone|camera)/)
    2 not-allowed from the control listener or the gate produces a durable row MET — use-voice.ts:986-996 (voice-control), gate.ts:150-156 → use-voice.ts:329 (voice-gate)
    3 A denied control listener stops re-arming and tells the user once MET — use-voice.ts:990-995 clears ctrlWantRef so onEnd's re-arm at :997 is dead, plus the notice; gate latch checked in both onend (gate.ts:161) and start() (:176)
    4 Benign no-speech/aborted still produce no rows MET — both fall through both predicates; gate.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 plus audio-capture, which reports and keeps trying. mute-invariant.test.ts asserts the narrowness — latching on audio-capture would 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-runner is 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:154 uses isMicPermissionDenied only, so a gate audio-capture still produces no row. Criterion 2 names only not-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's osascript Automation 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.

  3. serge-ivo commented on Aug 12, 2026

    @serge-ivo
    ContributorAuthor

    Still occurring in production — 20 recorded refusals, 10 in one 94-minute window

    The owner reports voice "sometimes not capturing". list_errors shows the control listener being refused the microphone, live:

    client:voice-control  "control listener refused the microphone: audio-capture"
      Aug 8 – Aug 11: 20 rows
      Aug 11 21:31:01 → 23:05:58: 10 of them
    

    use-voice.ts:1072-1082 reports but deliberately does not latch on audio-capture, so onEnd re-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.

  4. serge-ivo commented on Aug 12, 2026

    @serge-ivo
    ContributorAuthor

    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, so onEnd re-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 b2a3634 could never fire — for audio-capture OR for not-allowed

    use-voice.ts cleared ctrlWantRef, and the only reader of that flag is the onEnd at :1083. onEnd is VoiceStt.onEnd, and it was reached only through the else of stt.ts's rec.onend:

    rec.onend = () => { if (this.listening) { … rec.start() … } else this.onEnd(); }
    

    listening is set true by start() and cleared only by stop(). rec.onerror cleared nothing (stt.ts:341-343 before this change). So after a denial listening was still true, the recognizer's OWN loop restarted it, this.onEnd was never called at all, and ctrlWantRef = false was 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-995 clearing 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.onend called rec.start() immediately. The restartFails/250ms path only covers a start() that throws, and audio-capture does not throw — it starts and errors again. So the cycle was error → end → start as fast as Chrome will run it, against a MediaRecorder that is asynchronously releasing the same device, with Chrome activating the mic on every start(). gate.ts had the identical shape.

    What landed

    packages/sdk/src/voice/mic-retry.ts — one pure rule both re-arming loops consult:

    code verdict
    no-speech, aborted, none re-arm instantly — a gap here is a gap in mute-by-voice (ADR 0001 M1)
    not-allowed, service-not-allowed terminal; stt.ts now clears listening, which is what hands control to onEnd so the latch and notice actually run
    anything else (incl. audio-capture) re-arm after 400ms doubling to an 8s ceiling, reset by any result

    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:

    1. The gate now reports audio-capture too. 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.
    2. The denial is sticky for the session. The reconcile effect recomputes ctrlWantRef on every mic transition, so clearing it inside onError stopped the re-arm only until your next turn, and then asked the refused device again. ctrlDeniedRef is what makes "once" true.
    3. The failure run rides on the durable row (fails, burstMs). With de-dup at one row per 30s, fails: 2 and fails: 240 are the same single row and mean completely different things — see below.

    Verified

    • 7,790 unit tests, full CI bar, 365 e2e specs (chromium + webkit) at the deployed head 7f66324. The two core tests were checked against the pre-fix behaviour and fail on it (permission verdict → listening stayed true and onEnd was never called; contention → the restart fired immediately).
    • Live in the production console bundle, as logic and not only as strings: rs(e) is planMicRestart — Do(e.code) → {restart:!1,delayMs:0,terminal:!0}, benign → delayMs:0, else → Math.min(400*2**(e-1),$o) — and ns(e){return e===4} is the once-per-run notice threshold.
    • CI + Deploy API + Deploy Host all green on 4cf17eb and 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 each start() 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:

    After your next voice session, list_errors for client:voice-control. Each row now carries fails and burstMs.

    • fails staying at 1–2, burstMs near 0 → turn-boundary contention. The backoff absorbs it and the 20 rows were the log's floor showing us a benign thing loudly.
    • fails in the tens or hundreds with burstMs in the minutes → a genuinely unavailable device, and the earlier rows were a spin. Then the next move is serialising acquisition (hold the control listener until the recorder's tracks are actually released) rather than tuning the delay.

    That is the number nobody could read off the rows we had, and it is worth one session before anything else is changed here.

    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's osascript Automation 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_MS throttles only the React setAudioLevel call while vadStep runs every rAF tick. It is already corrected on main (1925dfa, #511), which also bounds what a poisoned floor estimate can cost via ONSET_FLOOR_CEILING. No issue needed.

  5. serge-ivo commented on Aug 15, 2026

    @serge-ivo
    ContributorAuthor

    The measurement you asked for, taken — and it says branch 1

    Two things this comment carries: the reading of fails/burstMs, and bdc5517e, which shipped for
    this ticket on 2026-08-12 and was never reported here.

    bdc5517e — serialised acquisition, already shipped

    packages/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 a VoiceStt,
    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 zeroed micFails on
    isBenignRecognizerEnd, which includes aborted — the code our own stop() raises at every
    turn boundary, which is exactly where the contention is.

    The reading — fails: 1, burstMs: 0, everywhere

    15 client:voice-control + 4 client:voice-gate rows over the full 23-day log. Six carry the
    instrumentation
    ; 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 with repeat_count: 1 that
    cannot be a collapse artefact. The 4-occurrence row's context and last_context agree across 10.5
    minutes. Maximum fails observed anywhere: 1.

    Against your own rule that is branch 1 — turn-boundary contention, benign, absorbed by the
    backoff
    . Nothing resembling "tens or hundreds with burstMs in the minutes". I traced the inputs
    rather than trusting the number: micRun lives on the gate object and the VoiceStt instance
    (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: 1
    means a real capture happened in between.

    One correction that changes how those rows read — 02fd9095

    mic-retry.ts said 30s "is the durable log's own de-dup window, which makes fails and the row
    count answer the same question over the same period". It is not. COLLAPSE_WINDOW_MS is 60
    minutes
    (error-log.ts:90, unchanged since 8c55639f, i.e. before every row this ticket has
    cited). The 30s is reportClientError's send-side throttle (client.ts:81).

    That sentence made repeat_count: 11 next to fails: 1 look contradictory. Under the real mechanism
    they 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

    • Serialising acquisition — which the previous comment named as the next move if the number came
      back large — is already shipped (bdc5517e). The number came back small, so it is belt and
      braces rather than the cure, and that is the right order.
    • Item 4 (three recognizers → one) is still not done and still correctly its own ticket. The
      data now argues against urgency: the contention is scattered and absorbed, not a spin.
    • Your original question is still unanswered: Chrome in-page bubble vs macOS system dialog. If
      the dialog names Terminal/iTerm2, coding/terminal.ts's osascript prompts are where to look and
      none of this touches it. The app name in the dialog is still the one fact that decides it.

    Not verified

    • No real microphone, no real denial, no real Chrome. Every assertion is driven through a fake
      SpeechRecognition.
    • The post-bdc5517e verdict rests on 2 build-stamped rows. That is a thin sample and I am not
      dressing 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 burstMs in the minutes, the
      handoff module is where to look first, not the delay.
  6. serge-ivo commented on Aug 18, 2026

    @serge-ivo
    ContributorAuthor

    Audit re-verification: all four acceptance criteria are met on main. What keeps this open is not an AC.

    AC evidence
    1. the automation browser never prompts, and an automated page cannot reach the real mic 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-stream and --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 :189 asserts no grantPermissions(['microphone'|'camera']) anywhere in the source
    2. a not-allowed from the control listener or the gate produces a durable error row use-voice.ts:1094 reportClientError("voice-control", …) and :337 reportClientError("voice-gate", …)
    3. a denied control listener stops re-arming and tells the user once 4cf17ebf (backoff + latch) and b2a36340 (convo.ts, gate.ts, use-voice.ts, plus mute-invariant.test.ts)
    4. benign no-speech / aborted still produce no entries convo.ts:1003 (no-speech → "soft") and :1042 — "Deliberately NARROW. no-speech and aborted are the ordinary churn those handlers exist to…"

    bdc5517e then added packages/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 why 4cf17eb's backoff could never fire: both loops zeroed micFails on isBenignRecognizerEnd, which includes aborted, the code our own stop() raises at every turn boundary.

    What is actually left, and it is two different things

    1. Item 4 of the report — three recognizers collapsing to one. Never an AC here, and the last comment says it is "correctly its own ticket". No such ticket exists — I searched. The measurement argues against urgency (max fails observed anywhere is 1, burstMs: 0, across the whole 23-day log), but the finding will be lost when this closes.
    2. One fact only you can supply: whether the repeated prompt was a Chrome in-page bubble or a macOS system dialog, and if the latter, which app it named. If it named Terminal/iTerm2, coding/terminal.ts's osascript prompts 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: 0 measurement, then close this. If you can answer the dialog question first, do — it is the one input that could reopen the whole diagnosis.

  7. added
    P1: blocks external usersMust be true before someone who is not the owner can run an agent (#68)
    bugSomething isn't working
    voiceVoice / STT / TTS
    frontendFrontend / UI work
    on Aug 18, 2026
  8. serge-ivo commented on Aug 23, 2026

    @serge-ivo
    ContributorAuthor

    DELIVERED — closing. All four acceptance criteria met on main, and AC2 is confirmed by production rows.

    Commits: b2a36340, 4cf17ebf, bdc5517e (and 02fd9095, the figure correction).

    AC Evidence at HEAD
    1. the automation browser never prompts, and an automated page cannot reach the real mic packages/browser-runner/src/runner.ts:648-649 adds both --use-fake-ui-for-media-stream and --use-fake-device-for-media-stream, with :637-638 recording why the first alone is strictly worse than prompting (it auto-grants the real device). Asserted at runner.test.ts:184-185, and :189 asserts no grantPermissions(['microphone'|'camera']) anywhere in the source
    2. a not-allowed from the control listener or the gate produces a durable error row packages/sdk/src/voice/convo.ts:1048 isMicPermissionDenied; reporters at use-voice.ts:1094 (voice-control) and :337 (voice-gate)
    3. a denied control listener stops re-arming and tells the user once packages/sdk/src/voice/mic-retry.ts:181 — if (isMicPermissionDenied(s.code)) return { restart: false, delayMs: 0, terminal: true }; — and use-voice.ts:1095-1099, which sets the notice "⚠ Voice commands are off — the microphone is blocked for this site."
    4. benign no-speech / aborted still produce no entries convo.ts:1003 maps no-speech → "soft", and :1042-1046 states the narrowing deliberately: "no-speech and aborted are the ordinary churn those handlers exist to ignore, and audio-capture can 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/errors today returns rows that could not have existed before this shipped — the two background recognizers were onError: () => {} and r.onerror = () => {}:

    client:voice-gate     | speech gate refused the microphone: not-allowed
    client:voice-gate     | speech gate refused the microphone: audio-capture
    client:voice-control  | control listener refused the microphone: not-allowed
    client:voice-control  | control listener refused the microphone: audio-capture
    

    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

    • Item 4 of the report — three recognizers collapsing to one. Never an acceptance criterion here. Filed as Three SpeechRecognition instances contend for one microphone — merge the gate and the control listener into one device owner #744 so the finding survives this close, with the three construction sites verified on 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 the audio-capture carve-out above).
    • One fact only the reporter can supply: whether the repeated prompt was a Chrome in-page bubble or a macOS system dialog, and if the latter, which app it named. If it named Terminal/iTerm2, coding/terminal.ts's osascript prompts 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:line above; the four production rows quoted from GET /v1/errors. Inferred: nothing about the automation-browser half — the Chrome flags are read from runner.ts and pinned by a test, not observed on a running instance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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