feat(tools): pause_voice_input — pause the mic without ending the call - #105
Merged
Conversation
"Stop listening" / "mute the mic" is now a tool call by the voice model. The session stays connected, playback keeps playing, background work keeps running and its results are still announced; only the operator's speech stops reaching the provider. Until now the only way to stop Talk listening was Ctrl+C, which ends the session and orphans the spoken-delivery path. - The flag lives on the capture surface. DuplexAudio and DiscordAudio grow pause_input / resume_input / input_paused, the same one-interface pattern playback_pending followed in #87, so both rooms honour it identically: blocks captured while paused are dropped in the callback (never queued stale), the already-queued ones are discarded, the reader answers empty, and resume drains the one block that can race the flag. The Discord bridge keeps taking the host's buffers and re-arming its inactivity timer so the bot is not evicted from the channel, and keeps its pacing clock current so a resume does not replay the pause as a burst of silence. - talk_pause is the registry: one live capture surface per process (the talk_runs.attach_owner contract), attached by run_talk_session before any tool can run, refusing — never arming — when nothing is attached. - Resume: the same tool with paused=false, and the operator's own control, because a paused microphone cannot hear the word "resume": Enter in the terminal (toggle; p/r explicit — a polling watcher, never a blocking stdin read that could swallow the next Hermes prompt line), /talk pause and /talk resume in Discord (/talk status says when it is paused). - Spoken receipt both ways: the model's tool result for its own flips, a contained tools-off announcement for the operator's. - Classified read-only: it changes nothing outside the session and can only narrow what it does; a pause is never a path to authority. Advertised only on lanes whose microphone this process pumps — the dashboard tab's lives in the browser. Tests: 32 new in tests/test_pause.py plus the surface/base-tool pins. Live check not possible on this box; see the PR body for what it would add. Ported idea from bielcarpi/hermes-live-voice (MIT) — idea only, no code. Fixes #100 — SmokeDev
Review fixes for #105 (hermes-talk#100). The core stays: the flag lives on the capture surface, both rooms honour it, playback and background work are untouched. What changes is the operator's way back on the terminal — the one thing the spec said Ctrl+C must not be the answer to. - The pause decision is made ONCE, before the tools are built, and the same fact drives every surface. run_talk_session resolves an operator resume control: `/talk resume` on the Discord lane; Enter on the CLI lane only when the session owns a real tty (keyboard_control=True AND keyboard_pause_control_available(), the very predicate the watcher starts on). default_talk_tools(pausable=...) advertises pause_voice_input exactly when a control exists (the `lane` argument is gone; default False), the watcher starts iff the control is the keyboard, and the control is registered with talk_pause.attach_session(resume_control=). - talk_pause refuses to pause a session with no registered control (new outcome NO_RESUME_PATH) — the execution-side half of the gate, so a pause call that arrives anyway (a relayed tool name, a stale schema) cannot arm a pause nobody can undo. Resuming is always allowed. - The PAUSED receipt names the control THIS session registered, never the other room's key or command (the wrong-room receipt from the review). - /talk at the Hermes prompt never watches stdin: prompt_toolkit holds that tty in raw mode with its own reader (a second reader races it for every byte and, on POSIX, parks in readline() waiting for a newline raw mode never delivers). _talk_command passes cli_entry(keyboard_control= False); the standalone `hermes talk` subcommand (argparse args) is the only lane that gets the key, and so the only terminal lane that offers the pause. A non-tty stdin (mintty, a pipe, a launcher wrapper) offers no pause either. - Windows: an extended key arrives from msvcrt.getwch() as a '\x00'/'\xe0' prefix and then its scan code; both bytes are consumed together, so Down-Arrow ('P') no longer pauses and Insert ('R') no longer resumes. The letter branch is ASCII-only. Tests: 47 in tests/test_pause.py (+15): the Windows byte sequences with a fake msvcrt, the advertisement predicate == the watcher's own, the NO_RESUME_PATH gate from every source, the receipt per room, the standalone/TUI/non-tty session matrix (advertised + watched vs neither + refused), cli_entry's grant, and the capture-side drops asserted on the queues so the reader gate can no longer mask them. Eight mutants each killed (prefix ignored; callback drop removed; drain-loop drop removed; advertise gate always on; execution gate removed; cli_entry always grants; /talk grants; watcher started regardless). — SmokeDev
TheSmokeDev
force-pushed
the
feat/pause-voice-input
branch
from
September 3, 2026 16:50
64c093e to
e3d0201
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
pause_voice_input: "stop listening" / "mute the mic" is now a tool call by the voice model. The session stays connected, playback keeps playing, background work keeps running and its results are still announced — only the operator's speech stops reaching the provider. Resume is the same tool withpaused: falseand the operator's own control, because a paused microphone cannot hear the word "resume": Enter in the terminal (toggle;p/rexplicit),/talk pause//talk resumein Discord. Both directions get a spoken receipt.Why
Until now the only way to stop Talk listening was Ctrl+C, which ends the session and orphans the spoken-delivery path — a run that lands after the hang-up has nobody to speak it to until a reconnect adopts it. Ported idea from bielcarpi/hermes-live-voice (MIT) — idea only, written fresh against our audio surface; no code copied.
How it works
DuplexAudioandDiscordAudiogrowpause_input/resume_input/input_paused— the same one-interface patternplayback_pendingfollowed in fix(audio): gate announcements on playback drain, not the server terminal #87 (the surface contract test now pins eleven names). Both rooms honour it identically: blocks captured while paused are dropped in the callback (never queued stale), the already-queued ones are discarded, the reader answers empty while the flag is up, and resume drains the one block that can race the flag. The Discord bridge keeps taking the host's buffers (they must not grow) and re-arming its inactivity timer (the bot must not be evicted), and keeps its pacing clock current so a resume does not replay the pause as a burst of catch-up silence frames.talk_pauseis the registry — one live capture surface per process, thetalk_runs.attach_ownercontract.run_talk_sessionattaches its audio before any tool can run and detaches on every exit path. With nothing attached a pause is refused, never armed: a flag remembered against a session that has not started would silently mute the next one.msvcrt.kbhiton Windows,selectelsewhere, cooked mode, no tty state ever changed) — never a blockingreadline, which would outlive the session and swallow the operator's next line at the Hermes prompt/talkreturns to. Offered only when stdin is a real terminal; the connected line says whether the key exists. Discord:/talk pause//talk resume(mute/unmuteaccepted),/talk statussays when it is paused; the canonical core lane refuses (host-owned, input-only).input_pause_commands) for the operator's — never both for one flip, so nothing is said twice.READ_ONLY_TALK_TOOLS: it changes nothing outside the session and can only narrow what it does. In a Discord room any speaker may mute listening; nobody gains authority by it and resume is the operator's typed command. Advertised only where an operator resume control is guaranteed (see Review fixes below; the originalPAUSABLE_LANESlane gate was replaced) — the dashboard tab's microphone lives in the browser, so its mint never offers it.How to test
git diff --stat==git diff --ignore-all-space --stat(no line-ending churn).Counts: 32 new tests in
tests/test_pause.py(capture stops on both surfaces, queued/raced blocks discarded, playback +playback_pending+ heard boundary untouched, Discord host buffers drained and timer armed, no silence burst on resume, registry refuses/flips-once/concurrency, tool receipts and text-flag parsing, lane-conditional advertising, keyboard watcher,/talkcommand routing, and a live-session run where an operator pause stopsinput_audio_buffer.appendand is announced in the contained shape). Plus the surface-contract pin (8 → 11 names), the base-tool pin, and a/talk pause|resumeregistration test.What a live check would confirm (not possible on this box)
hermes talk, say "stop listening": the model calls the tool, speaks the receipt naming Enter, and nothing said afterwards produces a transcript or response; a delegated run started before the pause still lands and is still spoken. Enter → "listening again" spoken; speech resumes normally with no stale block leading the first turn.talk join,/talk pausetyped in the linked text channel: the room hears "microphone paused", the bot is not evicted by the host's inactivity timer during a long pause,/talk statusreports the pause,/talk resumerestores capture without a burst of silence frames on the wire.Review fixes (second commit,
e3d0201)Per the #105 review — the core stays; what changed is the operator's way back on the terminal.
Decision:
pause_voice_inputis advertised only where an operator resume path is guaranteed, and that decision is made once, before the tools are built.run_talk_sessionresolves a resume control first:/talk resumeon the Discord lane; Enter on the CLI lane only when the session owns the tty (keyboard_control=Trueandkeyboard_pause_control_available()— the very predicate the watcher starts on).default_talk_tools(pausable=...)offers the tool exactly when a control exists (thelaneargument is gone; default False), the watcher starts iff the control is the keyboard, and the same control is registered withtalk_pause.attach_session(resume_control=). With a non-tty stdin (mintty, a pipe, a launcher wrapper) there is no key, so no pause is offered and the connected line does not mention Enter.talk_pause.set_paused(True)on a session with no registered control returns the newNO_RESUME_PATHand flips nothing — a pause call that arrives anyway (a relayed tool name, a stale schema) cannot arm a pause nobody can undo. Resuming is always allowed. This also covers the dashboard-relay case from should-fix 7 for any process whose attached surface has no way back.PAUSE_RECEIPTS[PAUSED]is a template filled fromtalk_pause.resume_control()— "Enter in the terminal" or "/talk resume in Discord", never both, never the other room's.msvcrt.getwch()returns'\x00'/'\xe0'and then the scan code; both are consumed together, so Down-Arrow ('P') no longer pauses and Insert ('R') no longer resumes. The letter branch is ASCII-only. Tested with the exact byte sequences against a fakemsvcrt:[DownArrow, Insert, F1, x, é, Enter, p, R] → [None, None, None, None, None, toggle, pause, resume]._talk_commandcallscli_entry(keyboard_control=False), andcli_entrydefaults the grant toargs is not None— only the argparse-dispatched standalonehermes talkowns its tty. Inside/talkthe session therefore offers no pause at all (the prompt owns the keyboard; a second reader would race prompt_toolkit's for every byte and, on POSIX, park inreadline()on a newline raw mode never delivers). One manual run must confirm: from the interactivehermesCLI on a real tty, type/talk; the connected line must readCtrl+C to hang up.with no Enter clause, saying "stop listening" must NOT produce a pause (the tool is not in the session's list), pressing Enter mid-call must neither flip the microphone nor be swallowed, and after Ctrl+C the Hermes prompt must accept the next typed line normally. Separately, standalonehermes talkin a real console must show the Enter clause and toggle on Enter._input.qsize() == 0,_inbound.qsize() == 0) right after a paused callback/deliver, so the reader gate can no longer mask a removed drop.hermes talkon a tty, and the legacy Discord lane;/talk pause//talk resumeis the universal path, including onprovider-host-tools.Gates after rebase onto
a270e80: 1637 passed, 40 skipped, 5 xfailed;ruff check .clean;git diff origin/main --stat==--ignore-all-space --stat(18 files, +1758/−28).tests/test_pause.pyis 47 tests (+15). Eight mutants each killed: prefix ignored; callback drop removed; drain-loop drop removed; advertise gate always on; execution gate removed;cli_entryalways grants;/talkgrants; watcher started regardless.Fixes #100
— SmokeDev