diff --git a/CHANGELOG.md b/CHANGELOG.md index 10313c6..2dd0483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,50 +15,107 @@ named rather than smoothed. ### Added - `pause_voice_input` — the model can pause listening without ending the - call (#100). "Stop listening" or "mute the mic" is a tool call: 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. The flag lives on the capture surface — `DuplexAudio` and - `DiscordAudio` grew `pause_input` / `resume_input` / `input_paused`, the - same one-interface pattern as `playback_pending` in #87 — so both rooms - honour it identically: blocks captured while paused are dropped (never - queued stale), the already-queued ones are discarded, and the Discord bridge - keeps the host's buffers drained and its inactivity timer armed so the bot - is not evicted from the channel. A paused microphone cannot hear the word - "resume", so the way back is the operator's own control — Enter in the - standalone `hermes talk` terminal (toggle; `p`/`r` explicit — a polling - watcher, never a blocking stdin read), `/talk pause` / `/talk resume` in - Discord (`/talk status` says when it is paused) — and the tool is offered - ONLY where that control is guaranteed: the pause decision is made once, - before the tool list is built, from the same predicate that starts the - keyboard watcher, and the registered control is what the receipt names. - No control, no pause: a piped or non-tty stdin gets no key and no tool; - `/talk` at the Hermes prompt shares its tty with prompt_toolkit, so that - lane never watches stdin and offers no pause; and a pause call that - arrives anyway is refused (`no_resume_path`) rather than armed, because a - pause nobody can undo would be a hang-up. On Windows an extended key - (arrows, Insert, F-keys) is consumed whole — before, Down-Arrow's scan - code read as `p` and paused the microphone. Both directions get a spoken - receipt: the model's tool result for its own flips, a contained - announcement for the operator's. The tool classifies read-only (it can - only narrow what a session does, and a pause is never a path to authority) - and refuses — never arms — when no session is attached. Ported idea from - bielcarpi/hermes-live-voice (MIT), idea only. -- Run admission control on `delegate_task` (#101). The model may declare - `execution_mode` (`exclusive`, the default, or `parallel_read_only`) and up - to eight normalized `resource_keys` naming what a task touches — a repo - checkout, a deployment target. Two live runs that share a key never overlap - unless both are read-only; the check runs before a run id is minted or an - acceptance record is written, so a refused job burns nothing and can never - surface as `lost`. The refusal is a spoken tool result naming the run in - the way and the shared key, never a hang or a silent queue, and - `check_work` reads out what each running job holds. New knob - `TALK_TRUST_DECLARED_READ_ONLY`, default off: until the operator sets it, - `parallel_read_only` is downgraded to `exclusive` and recorded that way, - because the declaration is the delegating model's own claim, not a - sandbox. A task that names no keys is exactly the task Talk always ran. -- The contributor experience, written down. `CONTRIBUTING.md` now ranks - what we take first (bug fixes on live lanes, then provider and host + call (#100). "Stop listening" or "mute the mic" is a tool call: 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. The flag lives on the capture surface — + `DuplexAudio` and `DiscordAudio` grew `pause_input` / `resume_input` / + `input_paused`, the same one-interface pattern as `playback_pending` in + #87 — so both rooms honour it identically: blocks captured while paused + are dropped (never queued stale), the already-queued ones are discarded, + and the Discord bridge keeps the host's buffers drained and its inactivity + timer armed so the bot is not evicted from the channel. A paused + microphone cannot hear the word "resume", so the way back is the + operator's own control — Enter in the standalone `hermes talk` terminal + (toggle; `p`/`r` explicit — a polling watcher, never a blocking stdin + read), `/talk pause` / `/talk resume` in Discord (`/talk status` says when + it is paused) — and the tool is offered ONLY where that control is + guaranteed: the pause decision is made once, before the tool list is + built, from the same predicate that starts the keyboard watcher, and the + registered control is what the receipt names. No control, no pause: a + piped or non-tty stdin gets no key and no tool; `/talk` at the Hermes + prompt shares its tty with prompt_toolkit, so that lane never watches + stdin and offers no pause; and a pause call that arrives anyway is refused + (`no_resume_path`) rather than armed, because a pause nobody can undo + would be a hang-up. On Windows an extended key (arrows, Insert, F-keys) is + consumed whole — before, Down-Arrow's scan code read as `p` and paused the + microphone. Both directions get a spoken receipt: the model's tool result + for its own flips, a contained announcement for the operator's. The tool + classifies read-only (it can only narrow what a session does, and a pause + is never a path to authority) and refuses — never arms — when no session + is attached. Ported idea from bielcarpi/hermes-live-voice (MIT), idea + only. +- Run admission control on `delegate_task` (#101). The model may + declare `execution_mode` (`exclusive`, the default, or + `parallel_read_only`) and up to eight normalized `resource_keys` naming + what a task touches — a repo checkout, a deployment target. Two live runs + that share a key never overlap unless both are read-only; the check runs + before a run id is minted or an acceptance record is written, so a refused + job burns nothing and can never surface as `lost`. The refusal is a spoken + tool result naming the run in the way and the shared key, never a hang or + a silent queue, and `check_work` reads out what each running job holds. + New knob `TALK_TRUST_DECLARED_READ_ONLY`, default off: until the operator + sets it, `parallel_read_only` is downgraded to `exclusive` and recorded + that way, because the declaration is the delegating model's own claim, not + a sandbox. A task that names no keys is exactly the task Talk always ran. +- `hermes talk check` proves the whole voice path end to end, right now + (#97). Doctor is read-only by design, so a green doctor could + still hide a dead mint, a refused socket, or a delegation lane that never + starts. The check runs the doctor checks, then a REAL session on the + configured provider through the same adapter and credential resolution the + voice uses (connect, `SessionReady`, one text turn, `ResponseFinished`), + then ONE bounded Hermes run through the same delegation path the voice + uses whose output must contain `HERMES_TALK_CHECK_OK`. `--json` reports + every step as pass/fail/skip with its duration and the exit code is 0 only + when every non-skipped step passed; `--no-run` skips the agent step; + `--timeout` budgets it (a run that outlives its budget is stopped, not + abandoned). The report never carries tokens or paths. A mock cannot go + green: `--provider` accepts only live lanes, the report's provider is + validated against the same fail-closed list `TALK_PROVIDER` uses, and the + live steps refuse under the test harness unless a test opts in by name. + Ported idea from bielcarpi/hermes-live-voice's `launch-check` (MIT) — idea + only, no code. Session credential/model/voice resolution moved into one + `talk_cli.resolve_provider_lane()` so the check proves the session's real + path rather than a copy of it. +- `hermes talk diagnostics` writes a redacted support bundle for issue + reports (#98). Most "it doesn't work" reports could not be + reproduced from what the reporter pasted. The bundle carries versions + (Python, hermes-talk, the Hermes host, the OS), the NAMES of the `TALK_*` / + `HERMES_*` variables that are set plus a fixed list of shared ones + (presence only — values are never read), audio device counts and default + device names, host capability facts, and every doctor check's outcome with + an allowlisted subset of its details. No logs, prompts, transcripts, task + results, audio, secret values, or paths. The serializer is default-deny: + `BUNDLE_ALLOWLIST` names every key and the shape its value must have, + identifier-shaped leaves are dropped outright if secret redaction would + change them, free-text leaves pass redaction plus a path scrub and a + length cap, and anything not on the list is dropped — a key doctor grows + later cannot leak by inheritance (a test plants secret-shaped values at + every level and proves none reach the file). `--bundle [PATH]` writes it + owner-only (POSIX `0600`; the setup wizard's protected owner-only DACL on + Windows, applied to the empty temp before any bytes land) and verifies the + permissions after the move, deleting the file if they cannot be proven; + `--json` prints it instead. The bug-report issue template now asks for the + bundle first. Ported idea from bielcarpi/hermes-live-voice's `diagnostics` + (MIT) — idea only, no code. +- hermes-talk's three realtime lanes now register on the Hermes core + `RealtimeVoiceProvider` contract (`agent/realtime_voice_provider.py`, API v2 — + NousResearch/hermes-agent#101808) as `hermes-talk/openai`, + `hermes-talk/grok`, and `hermes-talk/gemini`, so `hermes realtime --provider + ` drives them through core's own orchestrator. Registration is + feature-detected on both sides: a host without the hook, or with a different + contract version, loads hermes-talk exactly as before — one debug line, no + warning, every other surface intact. Capabilities are declared per lane + rather than assumed, so core degrades explicitly instead of calling an + operation the wire cannot perform. Availability stays offline and read-only + on all three lanes: no socket, no token refresh, no auth-store write. +- The neutral session contract gained `ToolCallsCancelled`, and the Gemini Live + adapter emits it for `toolCallCancellation`. The server can discard a + pending tool call mid-turn; until now that was recorded silently and only + visible as a dropped result on the send path, which told policy nothing until + it had already produced work nobody wanted. +- The contributor experience, written down. `CONTRIBUTING.md` now + ranks what we take first (bug fixes on live lanes, then provider and host compatibility, security hardening, cross-platform, new providers behind the contract, new surfaces, docs), maps the common paths — a new realtime provider, a new surface, a new talk tool, a fix, a docs change — to the @@ -86,173 +143,79 @@ named rather than smoothed. showed up: @kvnloo, @TheAngryPit, @webdevtodayjason. Ported idea from bielcarpi/hermes-live-voice's provider-compatibility receipt (MIT) — idea only, no text. -- `SECURITY.md`: supported versions (the latest PyPI release and `main`), - private reporting through GitHub security advisories (private vulnerability - reporting is enabled on the repository), a 72-hour acknowledgement target, - and what counts — credential leakage, auth-store writes outside the - documented refresh, tool-authority bypass, redaction failures, supply chain. -- `.github/dependabot.yml`: weekly `pip` and `github-actions` updates, minor - and patch bumps grouped into one PR per ecosystem, majors on their own. -- `hermes talk check` proves the whole voice path end to end, right now - (#97). Doctor is read-only by design, so a green doctor could still hide a - dead mint, a refused socket, or a delegation lane that never starts. The - check runs the doctor checks, then a REAL session on the configured - provider through the same adapter and credential resolution the voice - uses (connect, `SessionReady`, one text turn, `ResponseFinished`), then - ONE bounded Hermes run through the same delegation path the voice uses - whose output must contain `HERMES_TALK_CHECK_OK`. `--json` reports every - step as pass/fail/skip with its duration and the exit code is 0 only when - every non-skipped step passed; `--no-run` skips the agent step; `--timeout` - budgets it (a run that outlives its budget is stopped, not abandoned). - The report never carries tokens or paths. A mock cannot go green: - `--provider` accepts only live lanes, the report's provider is validated - against the same fail-closed list `TALK_PROVIDER` uses, and the live steps - refuse under the test harness unless a test opts in by name. Ported idea - from bielcarpi/hermes-live-voice's `launch-check` (MIT) — idea only, no - code. Session credential/model/voice resolution moved into one - `talk_cli.resolve_provider_lane()` so the check proves the session's real - path rather than a copy of it. -- `hermes talk diagnostics` writes a redacted support bundle for issue - reports (#98). Most "it doesn't work" reports could not be reproduced from - what the reporter pasted. The bundle carries versions (Python, hermes-talk, - the Hermes host, the OS), the NAMES of the `TALK_*` / `HERMES_*` variables - that are set plus a fixed list of shared ones (presence only — values are - never read), audio device counts and default device names, host capability - facts, and every doctor check's outcome with an allowlisted subset of its - details. No logs, prompts, transcripts, task results, audio, secret values, - or paths. The serializer is default-deny: `BUNDLE_ALLOWLIST` names every - key and the shape its value must have, identifier-shaped leaves are dropped - outright if secret redaction would change them, free-text leaves pass - redaction plus a path scrub and a length cap, and anything not on the list - is dropped — a key doctor grows later cannot leak by inheritance (a test - plants secret-shaped values at every level and proves none reach the file). - `--bundle [PATH]` writes it owner-only (POSIX `0600`; the setup wizard's - protected owner-only DACL on Windows, applied to the empty temp before any - bytes land) and verifies the permissions after the move, deleting the file - if they cannot be proven; `--json` prints it instead. The bug-report issue - template now asks for the bundle first. Ported idea from - bielcarpi/hermes-live-voice's `diagnostics` (MIT) — idea only, no code. -- hermes-talk's three realtime lanes now register on the Hermes core - `RealtimeVoiceProvider` contract (`agent/realtime_voice_provider.py`, API v2 — - NousResearch/hermes-agent#101808) as `hermes-talk/openai`, - `hermes-talk/grok`, and `hermes-talk/gemini`, so `hermes realtime --provider - ` drives them through core's own orchestrator. Registration is - feature-detected on both sides: a host without the hook, or with a different - contract version, loads hermes-talk exactly as before — one debug line, no - warning, every other surface intact. Capabilities are declared per lane - rather than assumed, so core degrades explicitly instead of calling an - operation the wire cannot perform. Availability stays offline and read-only - on all three lanes: no socket, no token refresh, no auth-store write. -- The neutral session contract gained `ToolCallsCancelled`, and the Gemini Live - adapter emits it for `toolCallCancellation`. The server can discard a pending - tool call mid-turn; until now that was recorded silently and only visible as a - dropped result on the send path, which told policy nothing until it had - already produced work nobody wanted. -- A visible trust surface on the repository (#95). Three new workflows: - CodeQL over the Python and over the workflow files themselves (PR, push, - weekly), OpenSSF Scorecard (push, weekly, results published so the badge - and the public viewer render), and dependency review on every PR (fails - on a moderate-or-worse advisory or a license outside the permissive - allowlist). Releases now carry provenance twice: `publish.yml` attests the - built dist with `actions/attest-build-provenance` before uploading, and the - PyPI upload passes `attestations: true` (PEP 740). Every third-party action - in every workflow is pinned to a full commit SHA with its version alongside - — the standard `plugin-guard.yml` already set for the upstream scanner — - which also moves `checkout` and `setup-python` from their floating `v4`/`v5` - majors to current releases. The README header gained CodeQL, Scorecard, and - PyPI badges. ### Changed -- README leads with what works today (#96). The first screen is now: what it - is, the three surfaces and three providers as facts, a one-line install, - the demo GIF with the with-sound cut on its own line, a full badge row (CI, - CodeQL, Scorecard, PyPI version and downloads, license), and an "Is it - working?" block that puts `hermes talk doctor` before any narrative. New - Surfaces and Providers tables, a "Current boundaries" section that states - the no-self-hosted-lane limit and the other honest gaps ourselves, and a - "Where this sits upstream" section for RFC #77111, core PR #101808, and - docs PR #97325. The stale "650+ offline tests" receipt is now 1,400+ across - 46 files, and the Status block no longer hardcodes a stale version — the - PyPI badge carries it. +- README leads with what works today (#96). The first screen is now: + what it is, the three surfaces and three providers as facts, a one-line + install, the demo GIF with the with-sound cut on its own line, a full badge + row (CI, CodeQL, Scorecard, PyPI version and downloads, license), and an + "Is it working?" block that puts `hermes talk doctor` before any narrative. + New Surfaces and Providers tables, a "Current boundaries" section that + states the no-self-hosted-lane limit and the other honest gaps ourselves, + and a "Where this sits upstream" section for RFC #77111, core PR #101808, + and docs PR #97325. The stale "650+ offline tests" receipt is now 1,400+ + across 46 files, and the Status block no longer hardcodes a stale version — + the PyPI badge carries it. Every existing section survives — reorganized, none deleted. CONTRIBUTING gains the `uv sync --extra dev` path, the `ruff==0.16.5` pin's reason, the #93 twelve-test baseline on a box where Hermes is importable, and the module table now lists every shipped module. +- The software echo gate (mic blocks below the playback echo floor are + dropped while model audio plays) now runs on every platform whenever + PulseAudio AEC is not active — that is always on Windows and macOS. + On headphones there is no echo to suppress, so it is tunable: + `TALK_ECHO_GATE=off` disables it; `TALK_ECHO_GATE_OUTPUT_ACTIVE_LEVEL`, + `TALK_ECHO_GATE_MIN_BARGE_IN_LEVEL`, and `TALK_ECHO_GATE_RATIO` retune it. + Read when the audio stream is constructed. All four are now in + `docs/OPERATING.md`'s Audio table. ### Fixed -- The dashboard `/status` route no longer echoes a configuration error's - text into its response (CodeQL `py/stack-trace-exposure`). An unusable - `TALK_VOICE` or `TALK_VOICE_MODE` still keeps the tile answerable, but the - response now names only WHICH setting refused plus a short reference; the - exception text — which quotes the offending value — goes to the dashboard's - own log under the same reference. The mint keeps repeating the exact - remediation on its own refusal path, where the caller is the operator - pressing Start. -- The `ci`, `plugin-guard`, and `CodeQL` workflows run on a least-privilege - `GITHUB_TOKEN`: `contents: read` at the workflow level (they inherited the - repository default, which can be read/write), with the one write CodeQL - needs — `security-events: write` to upload its SARIF — granted on the - analyze job alone. Closes CodeQL alerts #20/#21 and Scorecard's - Token-Permissions findings. -- CI lints against a pinned ruff. The dev extra asked for `ruff>=0.4` and CI - installed whatever was current, so ruff 0.16 arrived on its own and failed - the build on `RUF100`: it stopped reporting `BLE001` where the handler logs - the exception through a name it treats as a logger, which retired three - `noqa: BLE001` directives in `talk_core_provider.py` — the only module that - both names its logger `logger` (the other fourteen use `_log`, which ruff - does not recognise) and carries those directives. The trap was that the two - versions wanted opposite source: deleting the directives fixed CI and broke - every dev box still on 0.15.x. `ruff==0.16.5` is now pinned in the dev extra - (which is what CI installs), the three retired directives are gone with - their reasons kept as plain comments, and the `[tool.ruff]` comment no - longer claims CI runs whatever ruff is current. -- A Discord `talk join` that refuses before going live now says WHAT - refused instead of "session exited unsuccessfully". The session already - knew — it printed the reason to the gateway's stderr and returned a bare - exit code — so the operator, the one person who could act on it, was the - only one who never saw it. Configuration and provider-connect refusals - also point at `/talk core join`, which resolves its provider through the - host and so routes around exactly those two. An audio refusal does not: - core voice opens the same channel and would fail the same way. -- A tool-setup failure on the legacy Discord lane raised - `AttributeError` out of the session instead of refusing. That lane has no - host execution attachment, and the handler closed one unconditionally, so - the crash — not the tool problem — was what reached the operator. +- Linux terminal calls now route default audio through PulseAudio's WebRTC + echo canceller and noise suppressor. Echo-cancelled input bypasses the + fallback amplitude/VAD gate so barge-in does not clip quiet words. + ([#81](https://github.com/TheSmokeDev/hermes-talk/pull/81), thanks + [@kvnloo](https://github.com/kvnloo) — the first outside contribution to a + live lane.) - Proactive announcements now wait for the SPEAKER to drain, not just for the - server's `response.done`. The model streams far faster than realtime, so the - terminal event can arrive with a second of the previous answer still queued - locally — and the announcement started on top of it, overlapping two - responses at the only surface the operator actually has. `DuplexAudio` and - the Discord bridge gained a non-destructive `playback_pending`; the - announcement gate now consults it both in the pump's poll and in the - re-check inside the send lock, so the wire and the room are decided - together. A deferred announcement is delayed, never dropped. + server's `response.done`. The model streams far faster than + realtime, so the terminal event can arrive with a second of the previous + answer still queued locally — and the announcement started on top of it, + overlapping two responses at the only surface the operator actually has. + `DuplexAudio` and the Discord bridge gained a non-destructive + `playback_pending`; the announcement gate now consults it both in the + pump's poll and in the re-check inside the send lock, so the wire and the + room are decided together. A deferred announcement is delayed, never + dropped. - An announcement deferred for longer than `ANNOUNCE_STARVATION_WARN_S` - (30s, 0 disables) now tells the operator once. Deferring is correct, but a - gate that never opens was previously a silent slow poll with nothing to see. -- A voice session whose transport declares remote speakers - (`discord_speaker_authorization`) but carries no authorization ledger is now - REFUSED at session setup, before a secret is minted or a socket is opened. - That equivalence held by construction and was asserted only in a comment - several hundred lines from where it is relied on — and the branch relying on - it silently selects the allow-all `local_operator_authorizer`, so a - construction bug would have handed every speaker in a voice channel full - operator authority with nothing anywhere refusing. It refuses through the - same bounded-reason sink every other startup refusal uses, so a Discord - `talk join` says what happened instead of "session exited unsuccessfully" — - and that sentence does not offer `/talk core join`, which would take the - same channel with the same speakers and refuse identically. -- An unnamed tool call is now identified the same way on every path. The two - authorizer call sites disagreed (`"tool"` when revoking a permit, `""` when - authorizing one), so the identity a nameless event was revoked under was not - the identity it would have been authorized under. One helper answers for - both, and it returns `""` — which cannot collide with a registered tool, a - classification set, or a permit's recorded action. -- The README demo GIF is re-rendered from the original 1280x582 screen - recording instead of the 640x291 downscale it shipped as, so the - transcript and the agent's brief in the runs panel are readable rather - than grey mush (#79). `docs/render-dashboard-gif.py` regenerates it from - the published release asset. + (30s, 0 disables) now tells the operator once. Deferring is + correct, but a gate that never opens was previously a silent slow poll + with nothing to see. +- A Discord `talk join` that refuses before going live now says WHAT + refused instead of "session exited unsuccessfully". The session + already knew — it printed the reason to the gateway's stderr and returned + a bare exit code — so the operator, the one person who could act on it, + was the only one who never saw it. Configuration and provider-connect + refusals also point at `/talk core join`, which resolves its provider + through the host and so routes around exactly those two. An audio refusal + does not: core voice opens the same channel and would fail the same way. +- A tool-setup failure on the legacy Discord lane raised + `AttributeError` out of the session instead of refusing. That lane + has no host execution attachment, and the handler closed one + unconditionally, so the crash — not the tool problem — was what reached + the operator. +- CI lints against a pinned ruff. The dev extra asked for + `ruff>=0.4` and CI installed whatever was current, so ruff 0.16 arrived on + its own and failed the build on `RUF100`: it stopped reporting `BLE001` + where the handler logs the exception through a name it treats as a logger, + which retired three `noqa: BLE001` directives in `talk_core_provider.py` — + the only module that both names its logger `logger` (the other fourteen + use `_log`, which ruff does not recognise) and carries those directives. + The trap was that the two versions wanted opposite source: deleting the + directives fixed CI and broke every dev box still on 0.15.x. `ruff==0.16.5` + is now pinned in the dev extra (which is what CI installs), the three + retired directives are gone with their reasons kept as plain comments, and + the `[tool.ruff]` comment no longer claims CI runs whatever ruff is + current. - The plugin scans `safe` again. A literal U+FEFF typed into `tests/test_grok_auth.py` — the BOM fixture for the BOM-prefixed auth-store test, added with the Grok subscription lane in `01518ae` — tripped the @@ -263,18 +226,68 @@ named rather than smoothed. the same `startswith(b"")` assertion the test already made, and no invisible character left in the source for a reader or a scanner to have to distinguish from an accidental one. -- Linux terminal calls now route default audio through PulseAudio's WebRTC - echo canceller and noise suppressor. Echo-cancelled input bypasses the - fallback amplitude/VAD gate so barge-in does not clip quiet words. +- The README demo GIF is re-rendered from the original 1280x582 screen + recording instead of the 640x291 downscale it shipped as, so the + transcript and the agent's brief in the runs panel are readable rather + than grey mush (#79). `docs/render-dashboard-gif.py` regenerates it + from the published release asset. + +### Security +- A visible trust surface on the repository (#95). Three new + workflows: CodeQL over the Python and over the workflow files themselves + (PR, push, weekly), OpenSSF Scorecard (push, weekly, results published so + the badge and the public viewer render), and dependency review on every PR + (fails on a moderate-or-worse advisory or a license outside the permissive + allowlist). Releases now carry provenance twice: `publish.yml` attests the + built dist with `actions/attest-build-provenance` before uploading, and the + PyPI upload passes `attestations: true` (PEP 740). Every third-party action + in every workflow is pinned to a full commit SHA with its version alongside + — the standard `plugin-guard.yml` already set for the upstream scanner — + which also moves `checkout` and `setup-python` from their floating `v4`/`v5` + majors to current releases. The README header gained CodeQL, Scorecard, and + PyPI badges. +- `SECURITY.md`: supported versions (the latest PyPI release and + `main`), private reporting through GitHub security advisories (private + vulnerability reporting is enabled on the repository), a 72-hour + acknowledgement target, and what counts — credential leakage, auth-store + writes outside the documented refresh, tool-authority bypass, redaction + failures, supply chain. +- `.github/dependabot.yml`: weekly `pip` and `github-actions` + updates, minor and patch bumps grouped into one PR per ecosystem, majors on + their own. +- The `ci`, `plugin-guard`, and `CodeQL` workflows run on a least-privilege + `GITHUB_TOKEN`: `contents: read` at the workflow level (they + inherited the repository default, which can be read/write), with the one + write CodeQL needs — `security-events: write` to upload its SARIF — + granted on the analyze job alone. Closes CodeQL alerts #20/#21 and + Scorecard's Token-Permissions findings. +- The dashboard `/status` route no longer echoes a configuration error's + text into its response (CodeQL `py/stack-trace-exposure`). An + unusable `TALK_VOICE` or `TALK_VOICE_MODE` still keeps the tile + answerable, but the response now names only WHICH setting refused plus a + short reference; the exception text — which quotes the offending value — + goes to the dashboard's own log under the same reference. The mint keeps + repeating the exact remediation on its own refusal path, where the caller + is the operator pressing Start. +- A voice session whose transport declares remote speakers + (`discord_speaker_authorization`) but carries no authorization ledger is now + REFUSED at session setup, before a secret is minted or a socket is + opened. That equivalence held by construction and was asserted only in a + comment several hundred lines from where it is relied on — and the branch + relying on it silently selects the allow-all `local_operator_authorizer`, + so a construction bug would have handed every speaker in a voice channel + full operator authority with nothing anywhere refusing. It refuses through + the same bounded-reason sink every other startup refusal uses, so a Discord + `talk join` says what happened instead of "session exited unsuccessfully" — + and that sentence does not offer `/talk core join`, which would take the + same channel with the same speakers and refuse identically. +- An unnamed tool call is now identified the same way on every path. + The two authorizer call sites disagreed (`"tool"` when revoking a permit, + `""` when authorizing one), so the identity a nameless event was revoked + under was not the identity it would have been authorized under. One helper + answers for both, and it returns `""` — which cannot collide with a + registered tool, a classification set, or a permit's recorded action. -### Changed -- The software echo gate (mic blocks below the playback echo floor are - dropped while model audio plays) now runs on every platform whenever - PulseAudio AEC is not active — that is always on Windows and macOS. On - headphones there is no echo to suppress, so it is tunable: - `TALK_ECHO_GATE=off` disables it; `TALK_ECHO_GATE_OUTPUT_ACTIVE_LEVEL`, - `TALK_ECHO_GATE_MIN_BARGE_IN_LEVEL`, and `TALK_ECHO_GATE_RATIO` retune it. - Read when the audio stream is constructed. ## [0.16.0] — 2026-09-01 Grok voice on a SuperGrok / X Premium+ subscription. `hermes auth add @@ -312,6 +325,7 @@ lane rides the Codex CLI's. - Reading Grok Build CLI's `~/.grok/auth.json`; a device-code login inside the plugin; any write to any auth store. The dashboard tab stays OpenAI-only. + ## [0.15.1] — 2026-09-01 Voice hears you again on end-to-end-encrypted Discord calls. 0.15.0's diff --git a/docs/OPERATING.md b/docs/OPERATING.md index 9908fe4..5069de1 100644 --- a/docs/OPERATING.md +++ b/docs/OPERATING.md @@ -274,6 +274,31 @@ cancels the answer's TTS rather than flushing it. |---|---|---| | `TALK_INPUT_DEVICE` | auto | sounddevice input override. List devices: `python -c "import sounddevice; print(sounddevice.query_devices())"` | | `TALK_OUTPUT_DEVICE` | auto | sounddevice output override. | +| `TALK_ECHO_GATE` | on | The software echo gate: while model audio is playing, microphone blocks below the echo floor are treated as playback leakage and dropped. `0`, `off`, `false`, `no`, or `disabled` (case-insensitive) turns it off; **anything else, junk included, leaves it on** — this gate is the default, not an opt-in. Turn it off on headphones, where there is no echo to suppress and the gate can only cost you a quiet word. | +| `TALK_ECHO_GATE_OUTPUT_ACTIVE_LEVEL` | `0.015` | Playback RMS above which the gate considers the speaker "active" at all. Below it nothing is dropped. | +| `TALK_ECHO_GATE_MIN_BARGE_IN_LEVEL` | `0.04` | Absolute floor a microphone block must clear to count as barge-in, however quiet the playback is. | +| `TALK_ECHO_GATE_RATIO` | `0.65` | Fraction of the current playback RMS a block must also clear. The effective threshold is the **larger** of this and the floor above, so raising either only makes the gate stricter. | + +All four are read when the audio stream is CONSTRUCTED — once per session, not +per block and not at import — so a change lands on the next call, never the +live one. The three numeric knobs fail soft: blank, unparseable, or negative +silently takes the default (`0` is honored, and on the ratio it means "the +floor decides alone"). There is no receipt for a typo, so read them back with +`hermes talk diagnostics` — it reports the NAMES you have set. + +**Linux gets a real canceller first, and then skips the gate.** On Linux, +when both device overrides are unset and `pactl` is on `PATH`, a terminal +call loads +PulseAudio's own `module-echo-cancel` (`aec_method=webrtc`, with noise +suppression on) into a per-process source/sink and routes through it. While +that route is active the software gate above is **bypassed entirely** — the +canceller has already removed the echo, and gating cancelled input again is +what clipped quiet words (#81, [@kvnloo](https://github.com/kvnloo)). Every +failure is silent and non-fatal: no `pactl`, a refused module, a five-second +timeout, or a device that will not open through the route all fall back to +your original devices and the software gate. Pinning either +`TALK_INPUT_DEVICE` or `TALK_OUTPUT_DEVICE` opts out of the route by +construction — an explicit device is an explicit device. ### Identity @@ -363,6 +388,27 @@ allowlist returns a non-sensitive spoken denial without running the handler. | `TALK_CATALOG_STARTUP_WAIT_S` | `2.5` | Bounded head start a session start gives the first capability-catalog read, so a cold process still mints the live-catalog prompt section deterministically. `0` is honored and disables the wait (fire-and-forget); on expiry the session starts with the section omitted — logged, never a stall. Junk or negative silently takes the default. | | `TALK_MEMORY_SEARCH_TIMEOUT_S` | `10.0` | Wait bound for the in-process remembered-context (Honcho) tier of `search_memory`. On timeout the model speaks a retryable failure instead of the tool pipeline blocking; the transcript tier (`session_search`, a local FTS5 read) is not bounded. Junk or ≤0 silently takes the default. | +#### Admission declarations — arguments, not variables + +`TALK_TRUST_DECLARED_READ_ONLY` above is the operator half of a two-part +contract; the other half is two OPTIONAL arguments the model may attach to +`delegate_task`, which no environment variable sets: + +| Argument | Values | What it does | +|---|---|---| +| `resource_keys` | up to 8 strings | Names what the task touches — an absolute repo path, a deployment target, a service name. Whitespace-collapsed and case-folded, so two spellings of one path are one key. **Omitted means no fence in either direction**: a run naming nothing is exactly the run Talk always did. More than eight after normalization is refused, never truncated — a key dropped on the floor would be a silent hole. | +| `execution_mode` | `exclusive` (default) / `parallel_read_only` | Whether this run may share its keys. Two live runs sharing any key never overlap unless BOTH are read-only *and* the trust knob is on; `parallel_read_only` is otherwise downgraded to `exclusive` and recorded that way. | + +The check runs before a run id is minted and before an acceptance record is +written, so a refused job burns nothing and can never surface later as +`lost`. The refusal is a spoken tool result naming the run in the way and the +shared key ("wait for it, stop it, or re-delegate without that key") — never a +hang, never a silent queue. The fence is per PROCESS: it covers the +api-server and detached lanes whose runs this registry owns, and inside +`/talk` a job is still checked against the keys this registry holds but holds +none itself afterwards. Model-facing contract: +[README](../README.md#two-jobs-one-checkout--admission-control). + ### api-server lane | Variable | Default | Effect / failure mode | @@ -419,9 +465,12 @@ run-history tee so test suites can't write into a real Hermes home.) Inside the gateway, `/talk join` runs the call in the Discord voice channel the host is already sitting in. `/talk leave` ends it, `/talk -status` reports whether a session is live. Outside the gateway (a plain -terminal) `/talk` still means the terminal session, and those -subcommands say so. +status` reports whether a session is live. `/talk pause` (or `mute`) stops +the session listening without leaving the channel, and `/talk resume` (or +`unmute`) brings it back — typed, not spoken, because a paused session +hears nobody, and `/talk status` says so while it is paused. Outside the +gateway (a plain terminal) `/talk` still means the terminal session, and +those subcommands say so. Before inviting Talk into a shared voice room, set the operator list in the gateway environment and restart the gateway: @@ -497,6 +546,18 @@ detached child may well have finished its work; what died was the watcher that would have spoken the result. `lost` means "this process cannot know either way" — an honest answer, not a failure. +**`[talk] an update has been waiting 30s for a safe opening`** — an +announcement (a finished run, a landed note) is queued and being held back +because the model is mid-response or the speaker has not finished the +previous answer. Deferring is correct — the alternative is two voices at +once — and the update is delayed, never dropped. The warning fires once per +held update, after `ANNOUNCE_STARVATION_WARN_S`. That is a module constant +in `talk_cli.py` (30 seconds; `0` disables the warning, not the deferral), +**not an environment variable** — there is no knob to set here. Seeing it +repeatedly means a gate that is not clearing: check whether a tool call is +stuck or playback is wedged, because a pump that never opens is otherwise +indistinguishable from a quiet session. + **"I can't watch for delivery on this build"** — the steer queued fine, but neither delivery artifact is observable in this process, so confirmation will never upgrade past queued. Everything still works; you diff --git a/docs/REALTIME-ORCHESTRATOR.md b/docs/REALTIME-ORCHESTRATOR.md index 52f1eb3..9c29c72 100644 --- a/docs/REALTIME-ORCHESTRATOR.md +++ b/docs/REALTIME-ORCHESTRATOR.md @@ -1,9 +1,9 @@ # Realtime Orchestrator — architecture map (on-demand context) -Written 2026-08-15 after wiring the full tool-calling lane live. Load this -instead of re-crawling the repos. Sources: hermes-talk @ `0e3b477` (main, -PRs #29/#30), hermes-agent fork branch `feat/realtime-execution-attachment-20260813` -merged into the live install checkout. +Written 2026-08-15 after wiring the full tool-calling lane live, and kept +current since. Load this instead of re-crawling the repos. Sources: +hermes-talk @ `0e3b477` (main, PRs #29/#30) plus the hermes-agent side of +the realtime execution attachment. ## The one-sentence version @@ -18,34 +18,56 @@ handler table. - `__init__.py` — `register(ctx)`; captures the host's realtime execution attachment via `capture_realtime_execution_attachment` on the session - invocation (~line 185). + invocation (in `_talk_command`, ~line 237). - `talk_core_realtime.py` / `talk_core_session.py` — provider-neutral realtime session core; batches provider function calls into ONE canonical Hermes execution, returns ordered durable tool results, requests one continuation. Response-local cancellation + barge-in boundaries preserved. -- `talk_openai_realtime.py` — OpenAI Realtime provider (`gpt-realtime-2.1`, - voice `cedar` on this machine). -- `talk_tools.py` — the curated Talk verbs that remain: `search_memory`, - `search_vault`, `delegate_task`, `check_work`, `list_agents`, - `steer_agent`, `redirect_agent`, `stop_work`, `talk_status`, - `talk_capabilities`. +- `talk_openai_realtime.py` — OpenAI Realtime provider (defaults + `gpt-realtime-2.1`, voice `cedar`; both are `TALK_MODEL` / `TALK_VOICE`). +- `talk_tools.py` — the curated Talk verbs that remain. `default_talk_tools()` + advertises ten unconditionally: `search_memory`, `delegate_task`, + `check_work`, `list_agents`, `steer_agent`, `redirect_agent`, `stop_work`, + `resolve_approval`, `talk_status`, `talk_capabilities`. Two are + CONDITIONAL, because advertising a verb that cannot be served is the same + defect as passing through a provider block: `search_vault` only when a + memory provider is loadable in this process, and `pause_voice_input` only + when this process pumps the microphone AND the operator has a guaranteed + way back (`pausable=True`). (`talk_identity.py` renders the exact schema names into the session prompt so the persona knows its real surface.) +- `talk_core_provider.py` — the OTHER direction. The modules above make the + provider drive Hermes; this one publishes all three lanes on Hermes core's + own provider-neutral contract (`agent/realtime_voice_provider.py`, API v2) + as `hermes-talk/openai`, `hermes-talk/grok`, `hermes-talk/gemini`, so + core's orchestrator can drive them. Registration is feature-detected on + both sides (`core_contract_available()` → `build_providers()` → + `ctx.register_realtime_voice_provider`); a host without the hook loads + hermes-talk exactly as before, with one debug line. Capabilities are + DECLARED per lane (`OPENAI_CAPABILITIES` / `GROK_CAPABILITIES` / + `GEMINI_CAPABILITIES`) rather than assumed, so core degrades explicitly + instead of calling an operation the wire cannot perform. This lane is + deliberately input-only — it never executes provider tools; the duplex + lane described above still owns those, and `talk_status` labels it + `legacy-provider-executor` to keep the two apart. - `talk_discord.py` — Discord `/talk join` runs the SAME `run_talk_session` with `DiscordAudio` swapped in; identical tools/instructions. Mutating tools are gated by an immutable-ID operator allowlist. - `talk_runs.py` — async run registry: slow work returns a spoken receipt (`WORK_STARTED #`) and the watcher speaks the result when it lands. - `talk_auth.py` — fail-closed auth order: `TALK_OPENAI_API_KEY` → - `OPENAI_API_KEY` → Codex CLI OAuth (winning lane on this machine: - `codex-oauth`). + `OPENAI_API_KEY` → Codex CLI OAuth (`codex-oauth`). `hermes talk doctor` + names whichever lane wins on your box. ### Host side (`hermes-agent`) - `gateway/realtime_execution_attachment.py` — the canonical execution - attachment the plugin captures. Added by fork branch - `feat/realtime-execution-attachment-20260813`; **not yet upstream** - (NousResearch PR pending — track the fork branch until it merges). + attachment the plugin captures. Its upstream status is a hermes-agent + question, not one this repo can answer: nothing here proves whether it has + landed. Check `hermes-agent` itself, and treat the plugin side as the + contract — the capture is feature-detected (`getattr(invocation, + "capture_realtime_execution_attachment", None)`), so a host without it + simply takes the other lane. - `agent/external_tool_batch.py` + `gateway/external_tool_batch.py` — the batched canonical execution path for provider function calls. - Plugin capability surface (upstream, current): `ctx.dispatch_tool` @@ -58,36 +80,59 @@ handler table. - Core has NO duplex realtime voice by design — plugin-owned Realtime session is the sanctioned shape (upstream docs steer voice to plugins). -### Live install layout (this machine) - -- `HERMES_HOME=C:\Users\Degen\AppData\Local\hermes` (NOT `~/.hermes` — the - `~/.hermes/plugins/hermes-talk` copy is a stale v0.6.1 leftover). -- Hermes venv: `%LOCALAPPDATA%\hermes\hermes-agent\venv`, installing from - `C:\Users\Degen\isolated-dev\hermes-agent-prp005-desktop-realtime-20260809` - (branch `feat/discord-core-session-runtime`, attachment branch merged - 2026-08-15, pre-merge HEAD `d03d60fe3` if rollback is needed). -- Dev clone of the plugin: `C:\Users\Degen\hermes-talk` (tracks origin/main). -- Gateway runs as `pythonw -m hermes_cli.main gateway run` (supervisor tree); - after ANY plugin or host update it must be restarted — kill the specific - PID only, never kill-all. +### Install layout and restart rules + +- **`HERMES_HOME` is the installed layout, and `~/.hermes` is only a + fallback.** `talk_config.get_hermes_home()` asks the HOST's own resolver + first (`hermes_constants.get_hermes_home`), which resolves context override + → process `HERMES_HOME` → platform default (`%LOCALAPPDATA%\hermes` on + Windows). Only when hermes-agent is not importable at all does the plugin + fall back to `$HERMES_HOME`, then to `~/.hermes`. The state dir + (`HERMES_HOME/state/`, home of `talk-runs.jsonl`) hangs off whichever won, + so read the winner out of `hermes talk doctor` instead of assuming. +- **A directory on disk is not proof of what is loaded.** Because the two + resolutions above can differ, a box can hold a `~/.hermes/plugins/hermes-talk` + copy that nothing loads from — a stale version sitting where it is easy to + read and easy to believe. `hermes plugins list` reports the version the host + actually registered; trust that over any directory, and over a dev clone's + git log. +- **The gateway keeps running the code it loaded at startup.** It runs under a + supervisor tree, so after ANY plugin or host update it must be restarted + before the new code is live — files changing on disk does nothing to a + process already holding the old ones. Restart it, then re-check + `talk_status`'s `version` field. +- **Kill only the specific PID.** Never kill-all-by-name: a supervised tree + and any other Python on the box look identical to a name match. ## Verify (no talking required) ```bash -hermes plugins list # hermes-talk · enabled · 0.8.0 -hermes talk doctor --json # 8/8 pass, incl. "host exposes every Talk capability" +hermes plugins list # hermes-talk · enabled · +hermes talk doctor --json # 10/10 pass, incl. "host exposes every Talk capability" +hermes talk check --json # the live proof: doctor + one provider turn + one bounded run hermes talk & # wire canary: established TLS :443 = session mints+connects ``` -Test gate: in `C:\Users\Degen\hermes-talk` — `.venv/Scripts/python -m pytest -q` -(834 passed / 7 skipped at wiring time) + `ruff check`. +Doctor's check set is fixed by `talk_doctor.CHECK_ORDER` and asserted against +the report it builds, so the denominator is ten: plugin, provider, auth, +model, voice, cascade, audio, identity, discord, host. + +Test gate: `uv run --extra dev pytest -q` + `uv run --extra dev ruff check .` +in a clone. (This page was written at 834 passed / 7 skipped; the suite has +grown well past that — see CONTRIBUTING for the current baseline and the #93 +known failures.) ## Known limits - Subagent completion announcements need - `PluginContext.active_parent_session_id` (upstream PR #79716, pending). - Below that, announcements are suppressed, not guessed. -- `redirect_agent`'s clean-abort path wants host ≥ 0.20 (install is 0.20.0). + `PluginContext.active_parent_session_id` (upstream + [PR #79716](https://github.com/NousResearch/hermes-agent/pull/79716)). + Below that, announcements are suppressed, not guessed. Whether that PR has + landed is a hermes-agent fact this page cannot verify — `docs/OPERATING.md` + and the README carry the same compatibility boundary, and `hermes talk + doctor` reports what your host actually exposes. +- `redirect_agent`'s clean-abort path wants host ≥ 0.20; below that it + degrades to the steer queue and says so. - Live voice proof (speak a real tool request; Discord `/talk join`) needs a human at the mic — the canary proves everything short of speech. diff --git a/docs/VOICE-COMMANDS.md b/docs/VOICE-COMMANDS.md index a9f36fd..46df19e 100644 --- a/docs/VOICE-COMMANDS.md +++ b/docs/VOICE-COMMANDS.md @@ -14,13 +14,14 @@ delivery; "landed" only ever follows a real delivery artifact. | "what do you remember about the auth refactor?" | `search_memory` | matching excerpts from past Hermes sessions, summarized aloud | a real search ran; "nothing found" means the search was empty, not skipped | | "what do my notes say about the offer ladder?" | `search_vault` | matching excerpts from your long-term written notes | what was WRITTEN DOWN, as opposed to `search_memory`'s what was SAID. Only offered when a memory provider can actually serve it; "nothing in the notes" and "the lookup failed" are deliberately different sentences | | "delegate a task: audit the login module and list every route it touches" | `delegate_task` | `WORK_STARTED #N`, or "I can't start that yet — " if no Talk connection is bound to route the result | a watcher polls that run and SPEAKS the result when it finishes, even if you've gone quiet; the refusal means nothing was accepted, not that it started and broke | +| "…and it's working in the ship checkout" (naming what the job touches) | `delegate_task` with `resource_keys` / `execution_mode` | either the same `WORK_STARTED #N`, or a refusal naming the run in the way: "run 4 (audit the repo) is still running and touches the same resource ('/srv/app'); wait for it, stop it, or re-delegate without that key" | the fence, not a queue: the clash is decided BEFORE a run id is minted, so a refused job burns nothing and never surfaces later as `lost`. Two live runs sharing a key never overlap unless both declared read-only AND you set `TALK_TRUST_DECLARED_READ_ONLY` — the model's own claim about work it hasn't done is policy input, never a sandbox. Naming nothing means no fence, exactly as before | | "how's the work going?" / "check run 7" | `check_work` | per-run status lines, plus the state of every steering note you've sent | the note states come from the receipt ledger — never "they got it" without the artifact | | "what's running right now?" | `list_agents` | live subagent ids tagged **can steer**, run numbers tagged **stop only** | ids that exist RIGHT NOW — resolve "the research one" here, never from memory of earlier speech | | "tell that audit to focus on the token refresh instead" | `steer_agent` | "queued for their next step — I'll confirm when it lands" | queueing only; "landed" arrives later, pushed, when a delivery artifact fires | | "stop — wrong repo, use the ship branch" | `redirect_agent` | "redirect accepted — it takes the correction at its current step, or its very next one" | the stronger verb: interrupts current thinking where the host supports it (0.20+), degrades to the steer queue mid-tool or on older hosts — and says which | | "kill the audit" / "stop run 7" | `stop_work` | "sent the stop — winding down" then a death receipt ("it's down", exit code) when confirmed | stopping drops unread steering notes (their receipts flip to `superseded`); every stop offered is real on that lane | | "once" / "this session" / "no" (answering an approval question) | `resolve_approval` | "approved — just this once", "approved for the rest of the run", or "denied — the agent was told no" | voice can grant `once`, `session`, or `deny` — **never `always`** (narrowed in code, not in the prompt); an unanswered question denies itself on a timer, and interrupting the question denies it on the spot | -| "what are you running on?" / "status report" | `talk_status` | version, model, voice, auth lane, agent lane, audio, identity sections | the verification command — field-by-field meaning in [OPERATING.md](OPERATING.md#2-talk_status--the-one-command) | +| "what are you running on?" / "status report" | `talk_status` | version, model, voice, auth lane, agent lane, audio, identity sections | the verification command — field-by-field meaning in [OPERATING.md](OPERATING.md#4-talk_status--the-in-session-command) | | "what can you do right now?" / "which tools do you have?" | `talk_capabilities` | installed skills, resolved toolsets with their enabled/configured flags, gateway feature flags, live run counts | live evidence, not the prompt — read in-process off the attached agent, or over the api server when detached; a toolset listed `enabled: false` is reported as installed but NOT usable | | "stop listening" / "mute the mic" / "hold on, I'm talking to someone" | `pause_voice_input` | "microphone paused — press Enter when you want me back" (standalone `hermes talk` in a real terminal) or "… say `/talk resume`" (Discord) | the call stays up: playback, background runs and their announcements continue; nothing you say reaches the provider until YOU resume it — a paused mic cannot hear "resume", so the way back is a key or a command, never speech, and the tool is offered only where that key or command exists (not for `/talk` at the Hermes prompt, not with a non-tty stdin). Resume gets its own spoken receipt | @@ -51,3 +52,56 @@ The full receipt-state vocabulary (`queued` / `landed` / `redirected` / state live in the README's [redirecting-work section](../README.md#redirecting-work-thats-already-running) — that prose is canonical; this card doesn't repeat it. + +## Pausing the mic — and the way back + +"Stop listening", "mute the mic", "hold on, I'm talking to someone" is a +tool call, not a hang-up. `pause_voice_input` stops your speech reaching the +provider and changes nothing else: the session stays connected, the speaker +keeps playing, background runs keep running, and their announcements still +land. It is classified read-only — a pause can only narrow what a session +does — so in a Discord channel any participant may call it, not just the +operator allowlist. + +**A paused microphone cannot hear the word "resume."** That single fact +shapes everything else here: the way back is always a key or a typed +command, never speech. + +| Where | Pause | Resume | +|---|---|---| +| standalone `hermes talk` in a real terminal | say it (the tool), or `p` | **Enter** toggles; `r` is the explicit one | +| Discord, inside the gateway | say it, or type `/talk pause` (`mute`) | type `/talk resume` (`unmute`) — `/talk status` says while it is paused | + +On Windows those are single keypresses, read one character at a time; an +extended key (an arrow, Insert, an F-key) is swallowed whole, because read +on its own Down-Arrow's scan code is `P` and would pause the microphone. +Everywhere else the terminal stays in its own cooked mode — no tty state is +ever changed, so a crash can never leave your shell raw — and the key is a +line: type `p`, `pause`, `mute`, `r`, `resume`, `unmute`, or nothing at all, +then Enter. + +The model can also resume itself (`paused=false`) when something other than +your voice asks it to. Either direction gets a spoken receipt, and the +pause receipt always names the control THIS session registered — so what you +hear is the way back that actually exists in the room you are in, never a +key from the other one. + +**Where the tool is not offered at all.** The decision is made once, before +the tool list is built, from the same predicate that starts the keyboard +watcher — so the model is never handed a pause you have no way to undo: + +- **`/talk` typed at the Hermes prompt.** That prompt owns the terminal + (prompt_toolkit, raw mode, its own stdin reader); a second reader would + race it for every byte. That lane never watches stdin, so there is no key + — use the standalone `hermes talk` command if you want the pause. +- **A non-tty stdin** — piped, redirected, or a terminal that does not + report itself as one. Note that Git Bash's mintty reports `isatty()` as + false to Python, so `hermes talk` under mintty gets no pause key and no + tool; a native console (Windows Terminal, PowerShell, cmd) does. +- **The dashboard tab**, whose microphone lives in the browser — use the + page's own mute control. Asked anyway, the model is told there is no + microphone here to pause and says so. + +A pause call that reaches the handler some other way — a relayed name, a +stale schema — is refused rather than armed (`no_resume_path`), because a +pause nobody can undo would end the call in all but name.