Skip to content

Replace hotkey-driven dictate with signal-driven headless mode#219

Merged
alexkroman merged 4 commits into
mainfrom
claude/youthful-shannon-42pous
Jun 17, 2026
Merged

Replace hotkey-driven dictate with signal-driven headless mode#219
alexkroman merged 4 commits into
mainfrom
claude/youthful-shannon-42pous

Conversation

@alexkroman

Copy link
Copy Markdown
Collaborator

Replace the interactive hotkey-based dictation interface with a signal-driven headless mode that responds to SIGTERM instead of keyboard input. This enables assembly dictate to be launched as a background task by external controllers (like Hammerspoon hotkey tools) that send SIGTERM to mean "I'm done dictating."

Key changes

  • Removed aai_cli/core/hotkey.py: The TerminalKeys class that read individual keypresses in cbreak mode is no longer needed. This eliminates the terminal requirement and enables headless operation.

  • Added stop_on_terminate() context manager in aai_cli/core/signals.py: A new signal handler that latches SIGTERM into a poll-able flag. Unlike terminate_as_interrupt() (which routes SIGTERM to the cancel path), this allows the capture loop to see the flag flip and stop gracefully, transcribing the utterance before exiting cleanly (exit 0). SIGINT (Ctrl-C) remains the cancel path (exit 130).

  • Updated aai_cli/commands/dictate/_exec.py:

    • Replaced TerminalKeys with stop_on_terminate()
    • Changed _record() to accept a stop_requested callable instead of a TerminalKeys object
    • Removed STOP_KEYS constant and the key-polling logic
    • Updated user-facing messages to reference SIGTERM instead of Enter/Space keypresses
    • Removed the terminal validation that previously happened in TerminalKeys.__enter__()
  • Updated test harness in tests/test_dictate_exec.py:

    • Replaced FakeKeys with FakeStop that yields a boolean predicate
    • Updated all test cases to use FakeStop([True/False, ...]) instead of FakeKeys([key, ...])
    • Tests now verify SIGTERM behavior (poll count) instead of keypress behavior (timeout list)
  • Updated documentation and help text:

    • Changed assembly dictate help from "Push-to-talk dictation" to "Signal-driven dictation"
    • Updated examples to show kill -TERM $(pgrep -f 'assembly dictate') as the stop mechanism
    • Updated the recording hint from "press Enter to stop" to "send SIGTERM to transcribe"

Implementation details

The new flow is:

  1. Recording starts immediately (no terminal needed, no interactive prompt)
  2. The capture loop polls stop_requested() between ~100 ms mic chunks
  3. When SIGTERM arrives, the handler sets the flag to True
  4. The next poll sees True and breaks the capture loop
  5. The utterance is transcribed and printed (exit 0)
  6. SIGINT (Ctrl-C) still cancels without transcribing (exit 130)

This design allows assembly dictate to be used in pipes and as a background task, with external controllers managing the recording lifecycle via signals rather than terminal interaction.

https://claude.ai/code/session_01JrDRFsdAYyXwWSudM2da8g

Recording now starts immediately and runs without a terminal, finishing on
SIGTERM (clean exit 0) so a hotkey tool like Hammerspoon can launch `assembly
dictate` as a background task and `kill -TERM` / `task:terminate()` to
transcribe. SIGINT (Ctrl-C) still cancels (exit 130).

The press-Enter/keypress terminal mode and its `core/hotkey.py` (TerminalKeys)
backend are removed; dictate now polls a new `signals.stop_on_terminate` latch
between mic chunks. Contrast `signals.terminate_as_interrupt` (stream/agent/
speak), which routes SIGTERM into the cancel path instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrDRFsdAYyXwWSudM2da8g
@alexkroman alexkroman enabled auto-merge June 17, 2026 14:49
@alexkroman alexkroman added this pull request to the merge queue Jun 17, 2026
@alexkroman alexkroman removed this pull request from the merge queue due to a manual request Jun 17, 2026
…indow

The lock-free config readers and the atomic os.replace in _dump can both lose a
race on Windows, which (unlike POSIX) has no atomic replace-over-open: a reader's
open or the writer's replace transiently fails with PermissionError while the
file is being swapped in. This surfaced as a reliably-red Windows CI on the
config-concurrency stress test (test_config_concurrent_writers_*), pre-existing
on main since the cross-process write lock landed.

Wrap both the read and the replace in a small bounded retry that rides out the
sub-millisecond rename window (no-op on POSIX, which never raises here).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrDRFsdAYyXwWSudM2da8g
@alexkroman alexkroman enabled auto-merge June 17, 2026 15:18
@alexkroman alexkroman added this pull request to the merge queue Jun 17, 2026
@github-merge-queue github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jun 17, 2026
claude added 2 commits June 17, 2026 15:37
The filelock-based serialization of config.toml's read-modify-write was a
recurring source of Windows CI flakiness, and the lost-update race it closed
(two concurrent `assembly` processes clobbering each other's profile/telemetry
writes) isn't worth that cost for a single-user CLI. Remove core/config_lock.py,
core/locking.py, and the filelock dependency.

Writes still go through a load -> mutate -> dump (now config._update) whose
_dump does a temp-file + atomic os.replace, so a reader never sees a torn file;
writers and readers are otherwise unsynchronized (last write wins). The Windows
os.replace sharing-window retry (config._retry_on_sharing_violation) stays, since
the lock-free read/replace race it guards is independent of the removed lock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrDRFsdAYyXwWSudM2da8g
…edge the job

The community.chocolatey.org download for ffmpeg sometimes hangs for the entire
20-minute job timeout rather than failing fast, so the existing retry loop never
got to retry — the stuck `choco install` never returned, and the Windows matrix
cell was cancelled (surfacing as a spurious "tests (windows)" failure with the
test suite never run). Wrap each attempt in Start-Job + Wait-Job -Timeout so a
hung download is killed after 4 minutes and the next attempt actually runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrDRFsdAYyXwWSudM2da8g
@alexkroman alexkroman added this pull request to the merge queue Jun 17, 2026
Merged via the queue into main with commit 3ae8404 Jun 17, 2026
19 checks passed
@alexkroman alexkroman deleted the claude/youthful-shannon-42pous branch June 17, 2026 16:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants