Hands-free can switch itself off, and that is the one path that says nothing
packages/sdk/src/voice/use-voice.ts:1228-1241, the recognizer's onEnd:
const { bail, nextRapidEnds } = decideRestart(Date.now() - lastListenStartRef.current, rapidEndsRef.current);
rapidEndsRef.current = nextRapidEnds;
if (bail) { setConvoOn(false); setMicOn(false); return; }
decideRestart (convo.ts:43) bails after 4 consecutive recognizer ends within 800ms of
starting — the signature of a failing restart loop (mic blocked, permission revoked mid-session,
another tab or app took the microphone, the OS suspended it). Bailing is correct: the alternative is
a restart loop that pegs the CPU and freezes the page.
What happens next is nothing. No setNotice, no reportClientError, no dictate({type:"failed"}),
no console line. Hands-free simply stops. The user is left looking at a mode toggle that has quietly
flipped back, with no way to tell it apart from a crash — or from the app deciding it was done.
The asymmetry is right there in the same handler
Twenty lines above (:1202-1225), the onError branch does all three:
reportClientError("voice", String(err), { sttWhisper: … }); // durable log
dictate({ type: "failed", note: String(err), at: Date.now() }); // the turn survives, marked failed
flushSync(() => setNotice(`⚠ ${err}`)); // and the user is told
So an error the recognizer reports is logged, surfaced and self-clearing. A failure it expresses
by dying four times in a row — the more serious of the two, because it ends the whole session rather
than one turn — is silent.
This also means the failure is invisible server-side: list_errors has no row, so "hands-free keeps
dropping out on my phone" cannot be confirmed, counted, or correlated with a browser or a device.
Why it matters more than a missing toast
Every cause on the list is user-fixable, and only if named: grant the mic permission again, close
the other tab that grabbed it, come back to the foreground. Without a message the user's model is
"hands-free is flaky", which is the conclusion #241 was written to prevent in the runner and #376 in
the loop — the same defect shape twice already on this board: the system recorded the state and no
surface read it back.
Suggested fix
- On bail: set a notice that says what happened and what to try — the mic became unavailable, check
permissions / another tab may be using it — and keep it up (this one should not auto-clear after
4.5s like a transcription error; the session is gone until the user acts).
reportClientError("voice", "handsfree-bail", …) with the rapid-end count and whether the main
path was Whisper or browser dictation, so the frequency is measurable.
- Consider distinguishing "gave up" from "you switched it off" in the UI state, so the toggle
flipping back reads as a result rather than a glitch.
Related: #386 (the control listener acts on the agent's own voice — another way hands-free ends
without the user asking), #385 (a built-in "stop" exits a session the user never bound it to).
All three produce the same user-visible event — hands-free stopped and I don't know why — from
three unrelated causes, and none of them leaves a trace.
Files: packages/sdk/src/voice/use-voice.ts:1228-1241, packages/sdk/src/voice/convo.ts:43-56
(decideRestart).
Hands-free can switch itself off, and that is the one path that says nothing
packages/sdk/src/voice/use-voice.ts:1228-1241, the recognizer'sonEnd:decideRestart(convo.ts:43) bails after 4 consecutive recognizer ends within 800ms ofstarting — the signature of a failing restart loop (mic blocked, permission revoked mid-session,
another tab or app took the microphone, the OS suspended it). Bailing is correct: the alternative is
a restart loop that pegs the CPU and freezes the page.
What happens next is nothing. No
setNotice, noreportClientError, nodictate({type:"failed"}),no console line. Hands-free simply stops. The user is left looking at a mode toggle that has quietly
flipped back, with no way to tell it apart from a crash — or from the app deciding it was done.
The asymmetry is right there in the same handler
Twenty lines above (
:1202-1225), theonErrorbranch does all three:So an error the recognizer reports is logged, surfaced and self-clearing. A failure it expresses
by dying four times in a row — the more serious of the two, because it ends the whole session rather
than one turn — is silent.
This also means the failure is invisible server-side:
list_errorshas no row, so "hands-free keepsdropping out on my phone" cannot be confirmed, counted, or correlated with a browser or a device.
Why it matters more than a missing toast
Every cause on the list is user-fixable, and only if named: grant the mic permission again, close
the other tab that grabbed it, come back to the foreground. Without a message the user's model is
"hands-free is flaky", which is the conclusion #241 was written to prevent in the runner and #376 in
the loop — the same defect shape twice already on this board: the system recorded the state and no
surface read it back.
Suggested fix
permissions / another tab may be using it — and keep it up (this one should not auto-clear after
4.5s like a transcription error; the session is gone until the user acts).
reportClientError("voice", "handsfree-bail", …)with the rapid-end count and whether the mainpath was Whisper or browser dictation, so the frequency is measurable.
flipping back reads as a result rather than a glitch.
Related: #386 (the control listener acts on the agent's own voice — another way hands-free ends
without the user asking), #385 (a built-in
"stop"exits a session the user never bound it to).All three produce the same user-visible event — hands-free stopped and I don't know why — from
three unrelated causes, and none of them leaves a trace.
Files:
packages/sdk/src/voice/use-voice.ts:1228-1241,packages/sdk/src/voice/convo.ts:43-56(
decideRestart).