Efren Yevale Varela 1 місяць тому
батько
коміт
cb05dce80b
45 змінених файлів з 4874 додано та 68 видалено
  1. 141 15
      README.md
  2. 1 0
      audit/2026-09-21.jsonl
  3. 32 0
      config.example.toml
  4. 4 0
      pyproject.toml
  5. 36 0
      scripts/hotkeys/README.md
  6. 24 0
      scripts/hotkeys/sxhkdrc.example
  7. 19 0
      scripts/hotkeys/xbindkeysrc.example
  8. 1 0
      src/stt_daemon/assistant/__init__.py
  9. 8 0
      src/stt_daemon/assistant/__main__.py
  10. 43 0
      src/stt_daemon/assistant/audit.py
  11. 272 0
      src/stt_daemon/assistant/control.py
  12. 128 0
      src/stt_daemon/assistant/executor.py
  13. 151 0
      src/stt_daemon/assistant/llm.py
  14. 70 0
      src/stt_daemon/assistant/modes.py
  15. 72 0
      src/stt_daemon/assistant/runner.py
  16. 475 0
      src/stt_daemon/assistant/service.py
  17. 124 0
      src/stt_daemon/assistant/tools.py
  18. 12 0
      src/stt_daemon/audio.py
  19. 351 0
      src/stt_daemon/bus.py
  20. 128 0
      src/stt_daemon/config.py
  21. 96 0
      src/stt_daemon/ctl.py
  22. 245 51
      src/stt_daemon/daemon.py
  23. 76 0
      src/stt_daemon/events.py
  24. 7 2
      src/stt_daemon/output.py
  25. 1 0
      src/stt_daemon/segmenter.py
  26. 1 0
      src/stt_daemon/tui/__init__.py
  27. 188 0
      src/stt_daemon/tui/app.py
  28. 86 0
      src/stt_daemon/tui/client.py
  29. 90 0
      src/stt_daemon/tui/state.py
  30. 88 0
      tests/test_assistant_integration.py
  31. 244 0
      tests/test_assistant_service.py
  32. 42 0
      tests/test_audit.py
  33. 223 0
      tests/test_bus.py
  34. 266 0
      tests/test_control.py
  35. 33 0
      tests/test_ctl.py
  36. 324 0
      tests/test_daemon_events.py
  37. 76 0
      tests/test_events.py
  38. 91 0
      tests/test_executor.py
  39. 121 0
      tests/test_llm.py
  40. 71 0
      tests/test_modes.py
  41. 71 0
      tests/test_tools.py
  42. 60 0
      tests/test_tui_app.py
  43. 99 0
      tests/test_tui_client.py
  44. 95 0
      tests/test_tui_state.py
  45. 88 0
      uv.lock

+ 141 - 15
README.md

@@ -30,9 +30,17 @@ Queue (segment_queue)
     ↓
 Faster-Whisper (transcription)
     ↓
-TranscriptSink (stdout + JSONL)
+TranscriptSink (stdout + transcripts/YYYY-MM-DD.jsonl) + EventPublisher
+    ↓  publish-only UNIX socket ($XDG_RUNTIME_DIR/stt-daemon.sock)
+stt-assistant  (subscribes; owns modes, command buffer, chat, LLM, tools)
+    ↓  control socket ($XDG_RUNTIME_DIR/stt-assistant.sock)
+stt-tui (viewer) / stt-ctl (hotkeys)              audit/YYYY-MM-DD.jsonl ←┘
 ```
 
+The daemon never accepts input: it only transcribes and publishes. Everything
+stateful lives in the assistant, which is a separate process (see
+[Assistant Layer](#assistant-layer)).
+
 ## Prerequisites
 
 ### System packages
@@ -122,15 +130,41 @@ jsonl_dir  = "transcripts"            # Write JSONL files (one per day) to this
 save_audio = false                    # Also write each transcribed utterance as a 16 kHz mono WAV
 audio_dir  = "audio"                  # WAV destination (YYYY-MM-DD subfolders); only used if save_audio
 
-[sink]
-sink_name             = "VirtualMicSink"    # Name of the null sink to create
-remap_source_name     = "VirtualMic"        # Name of the exposed microphone
-loopback_latency_msec = 1                   # Loopback latency in ms (lower = faster, higher = smoother)
-monitor_output        = false               # Loop sink output back to default speaker (CAUTION: feedback risk!)
-
 [daemon]
 queue_size      = 50                        # Max utterances queued for transcription (oldest dropped when full)
 backlog_warn_at = 5                         # Warn when the queue backs up this far (worker falling behind)
+
+[events]                                    # Publish-only event bus
+socket       = ""                           # Default: $XDG_RUNTIME_DIR/stt-daemon.sock
+heartbeat_ms = 1000                         # Heartbeat interval (progress-driven, from the capture loop)
+ring_size    = 200                          # Recent events replayed to a newly attached subscriber
+
+[assistant]                                 # Controller process
+control_socket        = ""                  # Default: $XDG_RUNTIME_DIR/stt-assistant.sock
+stall_timeout_ms      = 3000                # No heartbeat for this long -> STT considered offline
+worker_stall_ms       = 15000               # Heartbeats arriving but a transcription runs this long -> degraded
+command_arm_timeout_s = 10                  # Disarm a command if no turn is captured in this time
+chat_idle_timeout_s   = 300                 # Leave chat mode after this much silence
+turn_gap_s            = 1.5                 # Quiet gap that ends a turn
+require_viewer        = true                # Refuse to execute commands with no TUI attached
+chat_exit_phrases     = ["end conversation", "that's all", "stop listening"]
+
+[audit]                                     # Executed actions only (commands/chats are volatile)
+enabled = true                              # audit/YYYY-MM-DD.jsonl
+dir     = "audit"
+
+[llm]                                       # local llama.cpp (OpenAI-compatible)
+enabled              = true
+endpoint             = "http://localhost:9931/v1/chat/completions"
+temperature          = 0.7
+max_tokens           = 1024                 # reasoning models spend tokens before the answer; keep generous
+timeout_s            = 120
+cache_prompt         = true                 # reuse the KV prefix (append-only history keeps it warm)
+max_prompt_tokens    = 64000                # ~half of the 131072 context
+trim_block_turns     = 4                    # trim history in blocks, not per-turn, to limit re-prefill
+max_tool_iterations  = 3
+system_prompt_chat   = "You are a concise voice assistant. Reply in 1-3 sentences."
+system_prompt_command = "You translate a spoken request into a single tool call when one applies."
 ```
 
 ### Audio Source Setup
@@ -369,6 +403,83 @@ emitted and a warning is logged.
 > job removing files older than N days), and leave `save_audio = false` unless you
 > actually need the clips for auditing.
 
+## Assistant Layer
+
+The assistant is a **separate process** that turns transcriptions into actions and
+conversation. The daemon stays a dumb, reliable publisher; the assistant owns all state.
+
+### Topology
+
+- `stt-daemon` → publish-only event socket (`$XDG_RUNTIME_DIR/stt-daemon.sock`, mode `0600`). It never accepts input.
+- `stt-assistant` → subscribes to that stream and serves a control socket (`$XDG_RUNTIME_DIR/stt-assistant.sock`, mode `0600`).
+- `stt-tui` / `stt-ctl` → clients of the assistant.
+
+### Running (three terminals)
+
+```bash
+uv run stt-daemon        # transcription + event bus
+uv run stt-assistant     # controller: modes, LLM, tools, audit, control socket
+uv run stt-tui           # viewer/controller TUI  (or `stt-tui --dump 10` headless)
+```
+
+### Requirements
+
+A local llama.cpp server exposing an OpenAI-compatible `/v1/chat/completions` with a
+tool-calling model (default endpoint `http://localhost:9931/v1/chat/completions`).
+Set `[llm].enabled = false` to run without an LLM (commands then report
+"no executor configured").
+
+### Modes
+
+| mode | what it does |
+|---|---|
+| `idle` | listening; nothing is sent to the model |
+| `command_armed` | the next complete turn becomes a command; the text accumulates live |
+| `executing` | a command is running through the tool loop |
+| `chat` | every completed turn is sent to the model; no tools |
+
+Command and chat modes are mutually exclusive.
+
+### The two-press command flow
+
+1. Arm (`stt-ctl command-start`, or `a` in the TUI).
+2. Speak; the transcript fills the buffer.
+3. Optionally edit with `e` (freezes the buffer, so ASR mistakes can be corrected before the model sees them).
+4. Execute (`stt-ctl command-execute` or `Enter`), or cancel (`Esc`).
+
+Execution is gated on **readiness**: no speech open, the last finalized segment's
+`segment_done` has arrived, and STT is healthy. With `require_viewer = true`, execute is
+refused unless a TUI is attached.
+
+### Control protocol
+
+`stt-ctl` verbs (one message each): `command-start`, `command-execute`, `command-cancel`,
+`command-edit --text "..."`, `chat-toggle`, `status`.
+
+Assistant → clients: `mode`, `command_text`, `transcript`, `speech_start`/`speech_stop`,
+`tool_call`, `tool_result`, `chat_message`, `heartbeat`, `error`.
+
+### Audit
+
+Only **actions** are persisted, to `audit/YYYY-MM-DD.jsonl`:
+
+```json
+{"ts":"...","mode":"command","text":"what time is it","edited":false,
+ "tool":"get_datetime","args":{},"side_effect":"read_only","requires_confirmation":false,
+ "confirmed":null,"ok":true,"result":"...","latency_ms":412}
+```
+
+Commands and chat history are volatile — held in memory and discarded.
+
+### TUI keys
+
+`a` arm · `Enter` execute · `Esc` cancel · `e` edit · `t` chat toggle · `q` quit
+
+### Hotkeys
+
+See [`scripts/hotkeys/`](scripts/hotkeys/README.md) for `sxhkd` and `xbindkeys` examples
+that bind those commands to global keys.
+
 ## Virtual Sink Provisioning: How It Works
 
 The daemon uses PulseAudio's module system to create a virtual microphone from multiple sources:
@@ -563,20 +674,35 @@ Tests cover:
 src/stt_daemon/
 ├── __init__.py          - Package metadata
 ├── __main__.py          - Entry point for `python -m stt_daemon`
-├── cli.py               - Argument parsing and entry point
+├── cli.py               - Argument parsing and entry point (`stt-daemon`)
+├── ctl.py               - One-shot control client (`stt-ctl`, hotkeys)
 ├── config.py            - Config loading (TOML)
 ├── audio.py             - Audio capture (parec, sounddevice)
 ├── segmenter.py         - VAD + pre-roll buffer + utterance segmentation (core)
 ├── transcriber.py       - Faster-Whisper wrapper
-├── output.py            - Sinks (stdout, JSONL)
-└── daemon.py            - Main orchestrator (threads, queues)
-
-tests/
-├── test_segmenter.py    - Pre-roll buffer, VAD, segmentation logic
-└── test_config.py       - Config loading
+├── output.py            - Sinks (stdout, JSONL, optional WAV)
+├── daemon.py            - Orchestrator (threads, queues, event emission)
+├── events.py            - NDJSON event envelope + codec
+├── bus.py               - Publish-only event bus (socket, ring replay)
+├── assistant/
+│   ├── modes.py         - Mode state machine (idle/command_armed/executing/chat)
+│   ├── service.py       - Modes, command buffer, readiness, timeouts
+│   ├── control.py       - Control socket (viewers, commands, broadcast)
+│   ├── executor.py      - NullExecutor / LlmExecutor (tool loop)
+│   ├── tools.py         - Tool registry + schema validation + get_datetime
+│   ├── llm.py           - llama.cpp client + chat session (block trimming)
+│   ├── audit.py         - Daily action audit log
+│   └── runner.py        - `stt-assistant` process
+└── tui/
+    ├── state.py         - Pure view-state reducer
+    ├── client.py        - Control-socket client
+    └── app.py           - Textual app + `stt-tui` entry point
+
+tests/                    - 196 tests, no audio hardware or live LLM required
 
 scripts/
-└── audio-sink.sh        - PulseAudio virtual sink manager
+├── audio-sink.sh        - Thin wrapper over `stt-daemon setup|clear|list`
+└── hotkeys/             - sxhkd / xbindkeys examples
 ```
 
 ### Running linters

+ 1 - 0
audit/2026-09-21.jsonl

@@ -0,0 +1 @@
+{"ts": "2026-09-21T01:19:32.661794", "mode": "command", "text": "what time is it", "edited": false, "tool": "get_datetime", "args": {}, "side_effect": "read_only", "requires_confirmation": false, "confirmed": null, "ok": true, "result": "2026-09-21T01:30:00-06:00", "latency_ms": 150}

+ 32 - 0
config.example.toml

@@ -43,3 +43,35 @@ monitor_output        = false               # Loop sink.monitor to default sink
 [daemon]
 queue_size      = 50                  # max utterances waiting for transcription (oldest dropped when full)
 backlog_warn_at = 5                   # warn when the queue backs up this far (worker falling behind)
+
+[events]
+socket       = ""                     # publish-only event socket (default $XDG_RUNTIME_DIR/stt-daemon.sock)
+heartbeat_ms = 1000                   # heartbeat interval (progress-driven, from the capture loop)
+ring_size    = 200                    # recent events replayed to a newly attached subscriber
+
+[assistant]
+control_socket        = ""            # control socket (default $XDG_RUNTIME_DIR/stt-assistant.sock)
+stall_timeout_ms      = 3000          # no heartbeat for this long -> STT considered offline
+worker_stall_ms       = 15000         # heartbeats arriving but a transcription runs this long -> degraded
+command_arm_timeout_s = 10            # disarm a command if no turn is captured in this time
+chat_idle_timeout_s   = 300           # leave chat mode after this much silence
+turn_gap_s            = 1.5           # quiet gap that ends a turn
+require_viewer        = true          # refuse to execute commands with no TUI attached
+chat_exit_phrases     = ["end conversation", "that's all", "stop listening"]
+
+[audit]
+enabled = true                        # write executed actions to a daily JSONL file
+dir     = "audit"                     # audit/YYYY-MM-DD.jsonl
+
+[llm]
+enabled              = true
+endpoint             = "http://localhost:9931/v1/chat/completions"  # llama.cpp, OpenAI-compatible
+temperature          = 0.7
+max_tokens           = 1024           # reasoning models spend tokens before the answer; keep this generous
+timeout_s            = 120
+cache_prompt         = true           # reuse the KV prefix (append-only history keeps it warm)
+max_prompt_tokens    = 64000          # ~half of the 131072 context
+trim_block_turns     = 4              # drop history in blocks (not per-turn) to limit re-prefill
+max_tool_iterations  = 3
+system_prompt_chat   = "You are a concise voice assistant. Reply in 1-3 sentences."
+system_prompt_command = "You translate a spoken request into a single tool call when one applies."

+ 4 - 0
pyproject.toml

@@ -13,6 +13,7 @@ dependencies = [
     "faster-whisper>=1.1.0",
     "webrtcvad-wheels>=2.0.14",
     "questionary>=2.0",
+    "textual>=0.80",
 ]
 
 [project.optional-dependencies]
@@ -23,6 +24,9 @@ dev = ["pytest>=8", "ruff"]
 
 [project.scripts]
 stt-daemon = "stt_daemon.cli:main"
+stt-assistant = "stt_daemon.assistant.runner:main"
+stt-ctl = "stt_daemon.ctl:main"
+stt-tui = "stt_daemon.tui.app:main"
 
 [tool.hatch.build.targets.wheel]
 packages = ["src/stt_daemon"]

+ 36 - 0
scripts/hotkeys/README.md

@@ -0,0 +1,36 @@
+# Hotkeys
+
+Example bindings that drive the assistant through `stt-ctl`. Pick your WM's tool:
+
+- `sxhkdrc.example` — copy the lines into `~/.config/sxhkd/sxhkdrc`, reload sxhkd.
+- `xbindkeysrc.example` — append to `~/.xbindkeysrc`, run `xbindkeys`.
+
+Both need a running `stt-assistant` (and `stt-daemon` feeding it transcriptions).
+
+## What the bindings do
+
+| Binding intent | Command |
+|---|---|
+| Arm a command (next complete turn becomes an action) | `stt-ctl command-start` |
+| Execute the armed command | `stt-ctl command-execute` |
+| Cancel the armed command | `stt-ctl command-cancel` |
+| Toggle chat mode | `stt-ctl chat-toggle` |
+| Show the TUI | `stt-tui` |
+
+Each `stt-ctl` call connects to the assistant's control socket, sends one message,
+prints the reply, and exits — so it is safe to bind to a global hotkey.
+
+## Why arm-then-execute
+
+Arming is the strongest possible intent signal, and executing is a **second,
+explicit press**. Nothing runs on a guess:
+
+1. Press the arm hotkey — the TUI shows `command_armed`.
+2. Speak. The transcript fills the command buffer live.
+3. Optionally press `e` in the TUI to correct the text (fixes ASR mistakes before
+   the model sees them).
+4. Press execute — or cancel with `Esc`.
+
+Command mode and chat mode are mutually exclusive, and `require_viewer = true`
+means `command-execute` is refused unless a TUI is attached (so a stray hotkey
+cannot fire an action with nobody watching).

+ 24 - 0
scripts/hotkeys/sxhkdrc.example

@@ -0,0 +1,24 @@
+# Assistant hotkeys (sxhkd)
+#
+# Requires a running `stt-assistant` (which needs `stt-daemon` for transcriptions).
+# Append to ~/.config/sxhkd/sxhkdrc and reload sxhkd.
+
+# Arm command mode: the next complete turn becomes a command.
+super + c
+    stt-ctl command-start
+
+# Execute the armed command (review the text in `stt-tui` first).
+super + Return
+    stt-ctl command-execute
+
+# Cancel the armed command.
+super + Escape
+    stt-ctl command-cancel
+
+# Toggle chat mode.
+super + t
+    stt-ctl chat-toggle
+
+# Open the assistant TUI.
+super + shift + a
+    stt-tui

+ 19 - 0
scripts/hotkeys/xbindkeysrc.example

@@ -0,0 +1,19 @@
+# Assistant hotkeys (xbindkeys)
+#
+# Requires a running `stt-assistant` (which needs `stt-daemon` for transcriptions).
+# Append to ~/.xbindkeysrc and run `xbindkeys`.
+
+"stt-ctl command-start"
+    mod4 + c
+
+"stt-ctl command-execute"
+    mod4 + Return
+
+"stt-ctl command-cancel"
+    mod4 + Escape
+
+"stt-ctl chat-toggle"
+    mod4 + t
+
+"stt-tui"
+    mod4 + shift + a

+ 1 - 0
src/stt_daemon/assistant/__init__.py

@@ -0,0 +1 @@
+"""Assistant package: consumes the STT event stream, owns modes and command state."""

+ 8 - 0
src/stt_daemon/assistant/__main__.py

@@ -0,0 +1,8 @@
+"""Allow `python -m stt_daemon.assistant`."""
+
+import sys
+
+from .runner import main
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 43 - 0
src/stt_daemon/assistant/audit.py

@@ -0,0 +1,43 @@
+"""Daily JSONL audit log for executed actions.
+
+Mirrors the transcript log's daily-file convention but lives in its own directory.
+Writes are best-effort: an audit failure must never break command handling.
+"""
+
+import json
+import logging
+from datetime import datetime
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+
+class AuditLog:
+    """Appends action records to `dir/YYYY-MM-DD.jsonl`."""
+
+    def __init__(self, enabled: bool = True, directory: str = "audit"):
+        """Create the audit log.
+
+        Args:
+            enabled: When False, `record()` is a no-op.
+            directory: Destination directory for daily JSONL files.
+        """
+        self.enabled = enabled
+        self.directory = Path(directory)
+
+    def path_for_today(self) -> Path:
+        """Path of today's audit file."""
+        return self.directory / f"{datetime.now().strftime('%Y-%m-%d')}.jsonl"
+
+    def record(self, **fields) -> None:
+        """Append one record. Never raises."""
+        if not self.enabled:
+            return
+        try:
+            self.directory.mkdir(parents=True, exist_ok=True)
+            record = {"ts": datetime.now().isoformat(), **fields}
+            with open(self.path_for_today(), "a") as f:
+                json.dump(record, f, ensure_ascii=False)
+                f.write("\n")
+        except Exception as e:  # noqa: BLE001 - auditing is best effort
+            logger.warning("audit write failed: %s", e)

+ 272 - 0
src/stt_daemon/assistant/control.py

@@ -0,0 +1,272 @@
+"""NDJSON control socket for the assistant (clients: TUI and hotkey CLI).
+
+Bidirectional, unlike the STT bus: clients send commands and may `subscribe` as a
+viewer to receive the assistant's broadcast events. All outbound traffic for a
+client goes through that client's bounded queue and its own writer thread, so a
+slow client never blocks the service.
+"""
+
+import collections
+import itertools
+import json
+import logging
+import os
+import socket
+import threading
+import time
+from pathlib import Path
+
+from .. import events
+
+logger = logging.getLogger(__name__)
+
+CLIENT_QUEUE = 256
+POLL_INTERVAL = 0.005
+ACCEPT_TIMEOUT = 0.2
+READ_CHUNK = 65536
+
+
+def default_control_path() -> str:
+    """Default control socket path under $XDG_RUNTIME_DIR (fallback /tmp)."""
+    runtime = os.environ.get("XDG_RUNTIME_DIR") or "/tmp"
+    return str(Path(runtime) / "stt-assistant.sock")
+
+
+class _Client:
+    __slots__ = ("conn", "queue", "viewer", "writer", "reader")
+
+    def __init__(self, conn: socket.socket, queue_size: int):
+        self.conn = conn
+        self.queue: collections.deque[bytes] = collections.deque(maxlen=queue_size)
+        self.viewer = False
+        self.writer: threading.Thread | None = None
+        self.reader: threading.Thread | None = None
+
+
+class ControlServer:
+    """Serves the assistant control protocol over a UNIX socket."""
+
+    def __init__(self, cfg, service, path: str | None = None):
+        """Create the server (call `start()` to bind).
+
+        Args:
+            cfg: Configuration (uses `[assistant]`).
+            service: The `AssistantService` this server drives.
+            path: Socket path; defaults to config or `default_control_path()`.
+        """
+        self.cfg = cfg
+        self.service = service
+        self.path = path or cfg.assistant.control_socket or default_control_path()
+        self._seq = itertools.count(1)
+        self._clients: list[_Client] = []
+        self._lock = threading.Lock()
+        self._stop = threading.Event()
+        self._server: socket.socket | None = None
+        self._accept: threading.Thread | None = None
+
+    # ------------------------------------------------------------------ lifecycle
+
+    def start(self) -> None:
+        """Bind the socket and start accepting clients."""
+        self._bind()
+        self._accept = threading.Thread(
+            target=self._accept_loop, name="control-accept", daemon=True
+        )
+        self._accept.start()
+
+    def _bind(self) -> None:
+        path = Path(self.path)
+        path.parent.mkdir(parents=True, exist_ok=True)
+        if path.exists():
+            path.unlink()
+        server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+        server.bind(str(path))
+        os.chmod(str(path), 0o600)
+        server.listen(16)
+        server.settimeout(ACCEPT_TIMEOUT)
+        self._server = server
+
+    def close(self) -> None:
+        """Stop accepting, drop clients, and remove the socket."""
+        self._stop.set()
+        if self._server is not None:
+            try:
+                self._server.close()
+            except OSError:
+                pass
+        with self._lock:
+            clients = list(self._clients)
+            self._clients.clear()
+        for client in clients:
+            self._shutdown(client)
+        if self._accept is not None:
+            self._accept.join(timeout=1)
+        try:
+            Path(self.path).unlink()
+        except OSError:
+            pass
+
+    # ------------------------------------------------------------------ queries
+
+    def client_count(self) -> int:
+        with self._lock:
+            return len(self._clients)
+
+    def viewer_count(self) -> int:
+        with self._lock:
+            return sum(1 for client in self._clients if client.viewer)
+
+    # ------------------------------------------------------------------ outbound
+
+    def broadcast(self, type_: str, **fields) -> None:
+        """Send an assistant event to every subscribed viewer."""
+        self._enqueue_viewers(events.make_event(type_, next(self._seq), **fields))
+
+    def relay(self, event: dict) -> None:
+        """Forward a raw STT event to every subscribed viewer."""
+        self._enqueue_viewers(event)
+
+    def _enqueue_viewers(self, event: dict) -> None:
+        line = events.encode(event)
+        with self._lock:
+            clients = [c for c in self._clients if c.viewer]
+        for client in clients:
+            client.queue.append(line)
+
+    def _send(self, client: _Client, type_: str, **fields) -> None:
+        client.queue.append(events.encode(events.make_event(type_, next(self._seq), **fields)))
+
+    # ------------------------------------------------------------------ accept/IO
+
+    def _accept_loop(self) -> None:
+        server = self._server
+        if server is None:
+            return
+        while not self._stop.is_set():
+            try:
+                conn, _ = server.accept()
+            except socket.timeout:
+                continue
+            except OSError:
+                break
+            client = _Client(conn, CLIENT_QUEUE)
+            with self._lock:
+                self._clients.append(client)
+            client.writer = threading.Thread(
+                target=self._writer, args=(client,), name="control-writer", daemon=True
+            )
+            client.reader = threading.Thread(
+                target=self._reader, args=(client,), name="control-reader", daemon=True
+            )
+            client.writer.start()
+            client.reader.start()
+
+    def _writer(self, client: _Client) -> None:
+        try:
+            while not self._stop.is_set():
+                try:
+                    line = client.queue.popleft()
+                except IndexError:
+                    time.sleep(POLL_INTERVAL)
+                    continue
+                client.conn.sendall(line)
+        except OSError:
+            pass
+        finally:
+            self._drop(client)
+
+    def _reader(self, client: _Client) -> None:
+        buffer = bytearray()
+        try:
+            while not self._stop.is_set():
+                try:
+                    chunk = client.conn.recv(READ_CHUNK)
+                except OSError:
+                    break
+                if not chunk:
+                    break
+                buffer.extend(chunk)
+                while True:
+                    newline = buffer.find(b"\n")
+                    if newline == -1:
+                        break
+                    line = bytes(buffer[:newline])
+                    del buffer[: newline + 1]
+                    if line.strip():
+                        self._handle_line(client, line)
+        except OSError:
+            pass
+        finally:
+            self._drop(client)
+
+    def _drop(self, client: _Client) -> None:
+        with self._lock:
+            if client in self._clients:
+                self._clients.remove(client)
+        self._shutdown(client)
+
+    def _shutdown(self, client: _Client) -> None:
+        try:
+            client.conn.shutdown(socket.SHUT_RDWR)
+        except OSError:
+            pass
+        try:
+            client.conn.close()
+        except OSError:
+            pass
+
+    # ------------------------------------------------------------------ dispatch
+
+    def _handle_line(self, client: _Client, line: bytes) -> None:
+        try:
+            message = json.loads(line.decode("utf-8"))
+        except (UnicodeDecodeError, json.JSONDecodeError):
+            self._send(client, "error", code="bad_message", message="invalid JSON")
+            return
+        if not isinstance(message, dict) or "type" not in message:
+            self._send(
+                client, "error", code="bad_message", message="expected an object with a type"
+            )
+            return
+        self._dispatch(client, message)
+
+    def _dispatch(self, client: _Client, message: dict) -> None:
+        command = message.get("type")
+        service = self.service
+
+        if command == "subscribe":
+            client.viewer = bool(message.get("viewer", False))
+            self._send_status(client)
+        elif command == "status":
+            self._send_status(client)
+        elif command == "command_start":
+            self._ack_or_error(client, command, service.arm(), "cannot start a command now")
+        elif command == "command_cancel":
+            self._ack_or_error(client, command, service.cancel(), "no command to cancel")
+        elif command == "command_edit":
+            ok = service.edit(str(message.get("text", "")))
+            self._ack_or_error(client, command, ok, "no command to edit")
+        elif command == "command_execute":
+            self._execute(client, command)
+        elif command == "chat_toggle":
+            self._ack_or_error(client, command, service.toggle_chat(), "cannot toggle chat now")
+        else:
+            self._send(client, "error", code="unknown_command", message=f"unknown: {command}")
+
+    def _execute(self, client: _Client, command: str) -> None:
+        if self.cfg.assistant.require_viewer and self.viewer_count() == 0:
+            self._send(client, "error", code="no_viewer", message="no viewer attached")
+            return
+        if self.service.execute() is None:
+            self._send(client, "error", code="not_ready", message="command is not ready")
+            return
+        self._send(client, "ack", command=command, ok=True)
+
+    def _ack_or_error(self, client: _Client, command: str, ok: bool, reason: str) -> None:
+        if ok:
+            self._send(client, "ack", command=command, ok=True)
+        else:
+            self._send(client, "error", code="refused", message=reason)
+
+    def _send_status(self, client: _Client) -> None:
+        self._send(client, "status", **self.service.status())

+ 128 - 0
src/stt_daemon/assistant/executor.py

@@ -0,0 +1,128 @@
+"""Executor seam: turns a command string into an action result.
+
+Phase 3 ships `NullExecutor`; Phase 4 replaces it with the llama.cpp tool-calling
+executor. Executors emit their own `tool_call` event (via the supplied `emit`)
+before running, so a confirmation flow can exist later.
+"""
+
+from dataclasses import dataclass, field
+from typing import Callable
+
+from .llm import LlmError
+
+READ_ONLY = "read_only"
+MUTATING = "mutating"
+
+
+@dataclass
+class ExecutionResult:
+    """Outcome of one command execution."""
+
+    ok: bool
+    result: str
+    tool: str | None = None
+    args: dict = field(default_factory=dict)
+    side_effect: str = READ_ONLY
+    requires_confirmation: bool = False
+    confirmed: bool | None = None
+
+
+class NullExecutor:
+    """Placeholder executor used until the LLM/tool layer is wired."""
+
+    def run(self, command_text: str, emit: Callable[..., None]) -> ExecutionResult:
+        """Always declines: no action backend is configured yet."""
+        return ExecutionResult(ok=False, result="no executor configured")
+
+
+class LlmExecutor:
+    """Runs a command through the model's tool-calling loop."""
+
+    def __init__(self, cfg, client, registry):
+        """Create the executor.
+
+        Args:
+            cfg: An `LlmConfig`.
+            client: An `LlmClient`.
+            registry: A `ToolRegistry`.
+        """
+        self.cfg = cfg
+        self.client = client
+        self.registry = registry
+
+    def run(self, command_text: str, emit: Callable[..., None]) -> ExecutionResult:
+        """Ask the model, validate/execute any tool calls, and return the answer."""
+        messages: list[dict] = [
+            {"role": "system", "content": self.cfg.system_prompt_command},
+            {"role": "user", "content": command_text},
+        ]
+        tools = self.registry.specs()
+        last_tool: str | None = None
+        last_args: dict = {}
+
+        for _ in range(max(1, self.cfg.max_tool_iterations)):
+            try:
+                response = self.client.chat(messages, tools=tools)
+            except LlmError as e:
+                return ExecutionResult(ok=False, result=f"llm error: {e}")
+
+            if not response.tool_calls:
+                return ExecutionResult(
+                    ok=True, result=response.content, tool=last_tool, args=last_args
+                )
+
+            messages.append(
+                {
+                    "role": "assistant",
+                    "content": response.content or "",
+                    "tool_calls": [call.message() for call in response.tool_calls],
+                }
+            )
+
+            for call in response.tool_calls:
+                tool = self.registry.get(call.name)
+                args, error = self.registry.validate(call.name, call.arguments)
+                emit(
+                    "tool_call",
+                    tool=call.name,
+                    args=args or {},
+                    requires_confirmation=bool(tool and tool.requires_confirmation),
+                )
+                if error is not None:
+                    emit("error", code="invalid_tool_call", message=error)
+                    return ExecutionResult(ok=False, result=error, tool=call.name)
+                if tool is None:
+                    message = f"unknown tool: {call.name}"
+                    emit("error", code="invalid_tool_call", message=message)
+                    return ExecutionResult(ok=False, result=message, tool=call.name)
+                if tool.requires_confirmation:
+                    message = f"{call.name} requires confirmation"
+                    emit("error", code="confirmation_required", message=message)
+                    return ExecutionResult(
+                        ok=False,
+                        result="confirmation required",
+                        tool=call.name,
+                        args=args,
+                        side_effect=tool.side_effect,
+                        requires_confirmation=True,
+                    )
+                try:
+                    output = tool.run(args)
+                except Exception as e:  # noqa: BLE001 - a tool crash must not kill the loop
+                    emit("error", code="tool_error", message=str(e))
+                    return ExecutionResult(
+                        ok=False,
+                        result=f"tool error: {e}",
+                        tool=call.name,
+                        args=args,
+                        side_effect=tool.side_effect,
+                    )
+                messages.append(
+                    {"role": "tool", "tool_call_id": call.id, "content": output}
+                )
+                last_tool, last_args = call.name, args
+
+        emit("error", code="tool_loop", message="tool loop limit exceeded")
+        return ExecutionResult(
+            ok=False, result="tool loop limit exceeded", tool=last_tool, args=last_args
+        )

+ 151 - 0
src/stt_daemon/assistant/llm.py

@@ -0,0 +1,151 @@
+"""llama.cpp (OpenAI-compatible) client and the chat session.
+
+Reasoning models return a separate `reasoning_content` field, which is ignored
+by field name — never by scraping it out of `content`.
+"""
+
+import json
+import logging
+import urllib.error
+import urllib.request
+from dataclasses import dataclass, field
+from typing import Any, Callable
+
+logger = logging.getLogger(__name__)
+
+
+class LlmError(RuntimeError):
+    """Raised when the LLM endpoint cannot be reached or returns garbage."""
+
+
+@dataclass
+class ToolCall:
+    """One tool call requested by the model."""
+
+    id: str
+    name: str
+    arguments: str
+
+    def message(self) -> dict:
+        return {
+            "id": self.id,
+            "type": "function",
+            "function": {"name": self.name, "arguments": self.arguments},
+        }
+
+
+@dataclass
+class LlmResponse:
+    """Parsed completion."""
+
+    content: str
+    tool_calls: list[ToolCall] = field(default_factory=list)
+    finish_reason: str = "stop"
+
+
+def _default_opener(payload: dict, timeout: float) -> dict:
+    request = urllib.request.Request(
+        payload["__url__"],
+        data=json.dumps(payload["body"]).encode("utf-8"),
+        headers={"Content-Type": "application/json"},
+    )
+    with urllib.request.urlopen(request, timeout=timeout) as response:
+        return json.loads(response.read().decode("utf-8"))
+
+
+class LlmClient:
+    """Minimal OpenAI-compatible chat client (stdlib only)."""
+
+    def __init__(self, cfg, opener: Callable[[dict, float], dict] | None = None):
+        """Create the client.
+
+        Args:
+            cfg: An `LlmConfig`.
+            opener: Transport override for tests; receives
+                `{"__url__":..., "body":...}` and a timeout, returns the parsed JSON.
+        """
+        self.cfg = cfg
+        self._opener = opener or _default_opener
+
+    def chat(self, messages: list[dict], tools: list[dict] | None = None) -> LlmResponse:
+        """Send one completion request and parse the reply."""
+        body: dict[str, Any] = {
+            "messages": messages,
+            "temperature": self.cfg.temperature,
+            "max_tokens": self.cfg.max_tokens,
+            "cache_prompt": self.cfg.cache_prompt,
+            "stream": False,
+        }
+        if tools:
+            body["tools"] = tools
+            body["tool_choice"] = "auto"
+
+        try:
+            payload = self._opener(
+                {"__url__": self.cfg.endpoint, "body": body}, self.cfg.timeout_s
+            )
+        except (urllib.error.URLError, OSError, json.JSONDecodeError) as e:
+            raise LlmError(str(e)) from e
+
+        choices = payload.get("choices") or []
+        if not choices:
+            raise LlmError("response contained no choices")
+        choice = choices[0]
+        message = choice.get("message") or {}
+
+        # `reasoning_content` is deliberately never read.
+        content = (message.get("content") or "").strip()
+        tool_calls = []
+        for call in message.get("tool_calls") or []:
+            function = call.get("function") or {}
+            tool_calls.append(
+                ToolCall(
+                    id=call.get("id") or "",
+                    name=function.get("name") or "",
+                    arguments=function.get("arguments") or "",
+                )
+            )
+
+        return LlmResponse(
+            content=content,
+            tool_calls=tool_calls,
+            finish_reason=choice.get("finish_reason") or "stop",
+        )
+
+
+class ChatSession:
+    """Append-only conversation history with block trimming.
+
+    The system prompt is fixed (never rebuilt) and history only ever grows,
+    except when it must be trimmed to fit `max_prompt_tokens`; trimming drops
+    the oldest turns in blocks so the cached prefix is invalidated rarely.
+    """
+
+    def __init__(self, cfg, client: LlmClient):
+        self.cfg = cfg
+        self.client = client
+        self.system = cfg.system_prompt_chat
+        self.history: list[dict] = []
+
+    def reset(self) -> None:
+        """Forget the conversation (volatile by design)."""
+        self.history = []
+
+    def send(self, user_text: str) -> str:
+        """Append a user turn, ask the model, append and return the reply."""
+        self.history.append({"role": "user", "content": user_text})
+        self._trim()
+        messages = [{"role": "system", "content": self.system}] + self.history
+        response = self.client.chat(messages)
+        reply = response.content
+        self.history.append({"role": "assistant", "content": reply})
+        return reply
+
+    def _estimate_tokens(self) -> int:
+        characters = len(self.system) + sum(len(m["content"]) for m in self.history)
+        return characters // 4
+
+    def _trim(self) -> None:
+        block = max(1, self.cfg.trim_block_turns) * 2
+        while self._estimate_tokens() > self.cfg.max_prompt_tokens and self.history:
+            del self.history[: min(block, len(self.history))]

+ 70 - 0
src/stt_daemon/assistant/modes.py

@@ -0,0 +1,70 @@
+"""Pure mode state machine for the assistant (act vs talk)."""
+
+from enum import Enum
+
+
+class Mode(str, Enum):
+    """Assistant modes. Command modes and chat mode are mutually exclusive."""
+
+    IDLE = "idle"
+    COMMAND_ARMED = "command_armed"
+    EXECUTING = "executing"
+    CHAT = "chat"
+
+
+class ModeMachine:
+    """Tracks the assistant mode and enforces legal transitions.
+
+    Every transition returns True when accepted and False when refused, so
+    callers can surface refusals without inspecting the mode themselves.
+    """
+
+    def __init__(self) -> None:
+        self.mode = Mode.IDLE
+
+    @property
+    def command_active(self) -> bool:
+        """True while a command is armed or executing."""
+        return self.mode in (Mode.COMMAND_ARMED, Mode.EXECUTING)
+
+    def arm(self) -> bool:
+        """idle -> command_armed."""
+        if self.mode is not Mode.IDLE:
+            return False
+        self.mode = Mode.COMMAND_ARMED
+        return True
+
+    def cancel(self) -> bool:
+        """command_armed -> idle."""
+        if self.mode is not Mode.COMMAND_ARMED:
+            return False
+        self.mode = Mode.IDLE
+        return True
+
+    def execute(self) -> bool:
+        """command_armed -> executing."""
+        if self.mode is not Mode.COMMAND_ARMED:
+            return False
+        self.mode = Mode.EXECUTING
+        return True
+
+    def finish(self) -> bool:
+        """executing -> idle."""
+        if self.mode is not Mode.EXECUTING:
+            return False
+        self.mode = Mode.IDLE
+        return True
+
+    def enter_chat(self) -> bool:
+        """idle -> chat."""
+        if self.mode is not Mode.IDLE:
+            return False
+        self.mode = Mode.CHAT
+        return True
+
+    def exit_chat(self) -> bool:
+        """chat -> idle."""
+        if self.mode is not Mode.CHAT:
+            return False
+        self.mode = Mode.IDLE
+        return True

+ 72 - 0
src/stt_daemon/assistant/runner.py

@@ -0,0 +1,72 @@
+"""Run the assistant process: subscribe to the STT bus and serve the control socket."""
+
+import argparse
+import logging
+import signal
+import threading
+
+from ..bus import EventSubscriber
+from ..config import load_config
+from .audit import AuditLog
+from .control import ControlServer
+from .executor import LlmExecutor, NullExecutor
+from .llm import ChatSession, LlmClient
+from .service import AssistantService
+from .tools import default_registry
+
+
+def main() -> int:
+    """Entry point for the `stt-assistant` process."""
+    parser = argparse.ArgumentParser(
+        description="Assistant controller: subscribe to STT events and serve the control socket"
+    )
+    parser.add_argument("--config", type=str, default=None, help="Path to config.toml")
+    parser.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging")
+    args = parser.parse_args()
+
+    logging.basicConfig(
+        level=logging.DEBUG if args.verbose else logging.INFO,
+        format="[%(levelname)s] %(message)s",
+    )
+
+    cfg = load_config(args.config)
+    subscriber = EventSubscriber(cfg.events.socket or None)
+    audit = AuditLog(enabled=cfg.audit.enabled, directory=cfg.audit.dir)
+
+    executor = NullExecutor()
+    chat_fn = None
+    chat_reset = None
+    if cfg.llm.enabled:
+        client = LlmClient(cfg.llm)
+        session = ChatSession(cfg.llm, client)
+        executor = LlmExecutor(cfg.llm, client, default_registry())
+        chat_fn = session.send
+        chat_reset = session.reset
+
+    service = AssistantService(
+        cfg,
+        subscriber,
+        audit=audit,
+        executor=executor,
+        chat_fn=chat_fn,
+        chat_reset=chat_reset,
+    )
+    control = ControlServer(cfg, service)
+    service.set_sinks(emit=control.broadcast, relay=control.relay)
+
+    control.start()
+    service.start()
+    logging.info("Assistant ready. Control socket: %s", control.path)
+
+    stop = threading.Event()
+
+    def handle_signal(_signum, _frame):
+        stop.set()
+
+    signal.signal(signal.SIGINT, handle_signal)
+    signal.signal(signal.SIGTERM, handle_signal)
+    stop.wait()
+
+    service.stop()
+    control.close()
+    return 0

+ 475 - 0
src/stt_daemon/assistant/service.py

@@ -0,0 +1,475 @@
+"""Assistant controller: turns the STT event stream into mode and command state.
+
+Phase 2 scope: no LLM, no tools, no control socket. The service consumes events,
+maintains the mode machine, assembles command text, and exposes an API that the
+Phase 3 control socket will call.
+"""
+
+import logging
+import threading
+import time
+from typing import Callable
+
+from .. import events
+from ..config import Config
+from .executor import ExecutionResult
+from .modes import Mode, ModeMachine
+
+logger = logging.getLogger(__name__)
+
+MODE_EVENT = "mode"
+COMMAND_TEXT_EVENT = "command_text"
+ERROR_EVENT = "error"
+TOOL_CALL_EVENT = "tool_call"
+TOOL_RESULT_EVENT = "tool_result"
+CHAT_MESSAGE_EVENT = "chat_message"
+HEARTBEAT_EVENT = "heartbeat"
+
+CAPTURING = "capturing"
+READY = "ready"
+EXECUTING = "executing"
+RELAYED_TYPES = (events.TRANSCRIPT, events.SPEECH_START, events.SPEECH_STOP)
+
+
+class AssistantService:
+    """Maintains assistant mode, command text, and readiness from STT events."""
+
+    def __init__(
+        self,
+        cfg: Config,
+        subscriber,
+        emit: Callable[..., None] | None = None,
+        time_fn: Callable[[], float] = time.time,
+        executor=None,
+        audit=None,
+        relay: Callable[[dict], None] | None = None,
+        chat_fn: Callable[[str], str] | None = None,
+        chat_reset: Callable[[], None] | None = None,
+    ):
+        """Create the service.
+
+        Args:
+            cfg: Configuration (uses `[assistant]` and `[events]`).
+            subscriber: An object exposing `events()` (and `close()`).
+            emit: Sink for assistant events (`mode`, `command_text`, `error`, `tool_*`).
+            time_fn: Clock (injectable for tests).
+            executor: Object with `run(command_text, emit) -> ExecutionResult`.
+            audit: Object with `record(**fields)`; called once per execution.
+            relay: Sink for relayed STT events (`transcript`, `speech_*`).
+            chat_fn: Callable sending one chat turn and returning the reply.
+            chat_reset: Callable clearing the chat session on exit.
+        """
+        self.cfg = cfg
+        self._subscriber = subscriber
+        self._emit = emit if emit is not None else (lambda *a, **k: None)
+        self._relay = relay if relay is not None else (lambda event: None)
+        self._now = time_fn
+        self.executor = executor
+        self._audit = audit
+        self.chat_fn = chat_fn
+        self.chat_reset = chat_reset
+        self.modes = ModeMachine()
+
+        # Speech/segment pipeline state (derived from the event stream)
+        self.speech_open = False
+        self.awaited_seg_id: int | None = None
+
+        # Command buffer
+        self.arm_ts: float | None = None
+        self.command_text = ""
+        self.command_edited = False
+
+        # Chat session (volatile)
+        self.chat_history: list[str] = []
+        self._pending_chat: list[str] = []
+
+        # Health
+        self._last_heartbeat_ts: float | None = None
+        self._worker_stall_s = 0.0
+        self._queue_depth = 0
+        self._device_ok = True
+        self._last_heartbeat_emit = 0.0
+        self._last_activity_ts = 0.0
+        self._last_chat_activity = 0.0
+
+        self._last_emitted: tuple = ()
+        self._stop = threading.Event()
+        self._reader: threading.Thread | None = None
+        self._ticker: threading.Thread | None = None
+        self._exec_thread: threading.Thread | None = None
+
+    # ------------------------------------------------------------------ lifecycle
+
+    def start(self) -> None:
+        """Start the event reader and the timeout ticker."""
+        self._reader = threading.Thread(
+            target=self._read_loop, name="assistant-events", daemon=True
+        )
+        self._ticker = threading.Thread(
+            target=self._tick_loop, name="assistant-tick", daemon=True
+        )
+        self._reader.start()
+        self._ticker.start()
+
+    def stop(self) -> None:
+        """Stop the reader/ticker and close the subscription."""
+        self._stop.set()
+        try:
+            self._subscriber.close()
+        except Exception:  # noqa: BLE001 - best-effort shutdown
+            pass
+        for thread in (self._reader, self._ticker):
+            if thread is not None:
+                thread.join(timeout=1)
+
+    def _read_loop(self) -> None:
+        try:
+            for event in self._subscriber.events():
+                if self._stop.is_set():
+                    break
+                self.handle_event(event)
+        except Exception as e:  # noqa: BLE001 - keep the service alive
+            logger.error("Assistant event loop error: %s", e)
+
+    def _tick_loop(self) -> None:
+        while not self._stop.is_set():
+            self.tick()
+            self._stop.wait(0.1)
+
+    # ------------------------------------------------------------------ queries
+
+    def stt_ok(self) -> bool:
+        """Health of the STT side, from heartbeat recency and worker stall."""
+        if self._last_heartbeat_ts is None:
+            return True
+        now = self._now()
+        if now - self._last_heartbeat_ts > self.cfg.assistant.stall_timeout_ms / 1000.0:
+            return False
+        return self._worker_stall_s <= self.cfg.assistant.worker_stall_ms / 1000.0
+
+    def ready(self) -> bool:
+        """True when no speech is open, every finalized segment is done, and STT is healthy."""
+        return (
+            not self.speech_open
+            and self.awaited_seg_id is None
+            and self.stt_ok()
+        )
+
+    def turn_ended(self) -> bool:
+        """True when the pipeline is ready and the room has been quiet for `turn_gap_s`."""
+        if not self.ready():
+            return False
+        return (self._now() - self._last_activity_ts) >= self.cfg.assistant.turn_gap_s
+
+    def command_phase(self) -> str:
+        if self.modes.mode is Mode.EXECUTING:
+            return EXECUTING
+        if self.modes.mode is Mode.COMMAND_ARMED:
+            return READY if self.ready() else CAPTURING
+        return "idle"
+
+    def status(self) -> dict:
+        """Snapshot for the control socket (`status`)."""
+        return {
+            "mode": self.modes.mode.value,
+            "ready": self.ready(),
+            "stt_ok": self.stt_ok(),
+            "phase": self.command_phase(),
+            "command_text": self.command_text,
+            "speech_open": self.speech_open,
+            "awaited_seg_id": self.awaited_seg_id,
+            "chat_turns": len(self.chat_history),
+        }
+
+    # ------------------------------------------------------------------ actions
+
+    def set_sinks(self, emit: Callable[..., None], relay: Callable[[dict], None]) -> None:
+        """Replace the event sinks (used by the runner once the control server exists)."""
+        self._emit = emit
+        self._relay = relay
+
+    def edit(self, text: str) -> bool:
+        """Replace the command buffer text (refused unless command-armed)."""
+        if self.modes.mode is not Mode.COMMAND_ARMED:
+            return False
+        self.command_text = text
+        self.command_edited = True
+        self._emit_command_text(force=True)
+        return True
+
+    def arm(self) -> bool:
+        """Enter command mode (refused unless idle)."""
+        if not self.modes.arm():
+            return False
+        self.arm_ts = self._now()
+        self.command_text = ""
+        self.command_edited = False
+        self._emit_mode()
+        self._emit_command_text(force=True)
+        return True
+
+    def cancel(self) -> bool:
+        """Leave command mode, discarding the buffer."""
+        if not self.modes.cancel():
+            return False
+        self._clear_command()
+        self._emit_mode()
+        self._emit_command_text(force=True)
+        return True
+
+    def execute(self) -> str | None:
+        """Return the command text and enter `executing`, or None if not allowed.
+
+        When an executor is configured, execution runs on a worker thread so the
+        caller (control socket) is never blocked.
+        """
+        if self.modes.mode is not Mode.COMMAND_ARMED or not self.ready():
+            return None
+        text = self.command_text
+        edited = self.command_edited
+        if not self.modes.execute():
+            return None
+        self._emit_mode()
+        self._emit_command_text(force=True)
+        if self.executor is not None:
+            self._exec_thread = threading.Thread(
+                target=self._run_execution,
+                args=(text, edited),
+                name="assistant-exec",
+                daemon=True,
+            )
+            self._exec_thread.start()
+        return text
+
+    def _run_execution(self, text: str, edited: bool) -> None:
+        start = self._now()
+        result = ExecutionResult(ok=False, result="no executor configured")
+        executor = self.executor
+        if executor is not None:
+            try:
+                result = executor.run(text, self._emit)
+            except Exception as e:  # noqa: BLE001 - an executor crash must not kill the service
+                logger.error("Executor error: %s", e)
+                result = ExecutionResult(ok=False, result=f"executor error: {e}")
+
+        latency_ms = int((self._now() - start) * 1000)
+        self._emit(
+            TOOL_RESULT_EVENT,
+            tool=result.tool,
+            ok=result.ok,
+            result=result.result,
+            latency_ms=latency_ms,
+        )
+        if self._audit is not None:
+            self._audit.record(
+                mode="command",
+                text=text,
+                edited=edited,
+                tool=result.tool,
+                args=result.args,
+                side_effect=result.side_effect,
+                requires_confirmation=result.requires_confirmation,
+                confirmed=result.confirmed,
+                ok=result.ok,
+                result=result.result,
+                latency_ms=latency_ms,
+            )
+        self.finish()
+
+    def finish(self) -> bool:
+        """Leave `executing` and discard the buffer."""
+        if not self.modes.finish():
+            return False
+        self._clear_command()
+        self._emit_mode()
+        self._emit_command_text(force=True)
+        return True
+
+    def toggle_chat(self) -> bool:
+        """Enter chat mode from idle, or leave chat mode."""
+        if self.modes.mode is Mode.CHAT:
+            return self._exit_chat("toggle")
+        if not self.modes.enter_chat():
+            return False
+        self.chat_history = []
+        self._pending_chat = []
+        self._last_chat_activity = self._now()
+        self._emit_mode()
+        return True
+
+    # ------------------------------------------------------------------ events
+
+    def handle_event(self, event: dict) -> None:
+        """Consume one STT event."""
+        etype = event.get("type")
+        now = self._now()
+
+        if etype in RELAYED_TYPES:
+            self._relay(event)
+
+        if etype == events.STATUS:
+            self._on_status(now)
+        elif etype == events.SPEECH_START:
+            self.speech_open = True
+            self._last_activity_ts = now
+        elif etype == events.SPEECH_STOP:
+            self.speech_open = False
+            self.awaited_seg_id = event.get("seg_id")
+            self._last_activity_ts = now
+        elif etype == events.SEGMENT_DONE:
+            if event.get("seg_id") == self.awaited_seg_id:
+                self.awaited_seg_id = None
+            self._last_activity_ts = now
+        elif etype == events.TRANSCRIPT:
+            self._on_transcript(event, now)
+        elif etype == events.HEARTBEAT:
+            self._last_heartbeat_ts = now
+            self._worker_stall_s = float(event.get("worker_stall_s", 0.0) or 0.0)
+            self._queue_depth = int(event.get("queue_depth", 0) or 0)
+            self._device_ok = bool(event.get("device_ok", True))
+
+        self._check_timeouts(now)
+        self._emit_command_text()
+
+    def tick(self) -> None:
+        """Evaluate timeouts, dispatch pending chat, and emit a heartbeat."""
+        now = self._now()
+        self._check_timeouts(now)
+        if (
+            self.modes.mode is Mode.CHAT
+            and self._pending_chat
+            and self.chat_fn is not None
+            and self.turn_ended()
+        ):
+            self._dispatch_chat()
+        self._emit_heartbeat(now)
+        self._emit_command_text()
+
+    def _emit_heartbeat(self, now: float) -> None:
+        if now - self._last_heartbeat_emit < 1.0:
+            return
+        self._last_heartbeat_emit = now
+        self._emit(
+            HEARTBEAT_EVENT,
+            mode=self.modes.mode.value,
+            stt_ok=self.stt_ok(),
+            in_flight=self.modes.mode is Mode.EXECUTING,
+            worker_stall_s=round(self._worker_stall_s, 3),
+            queue_depth=self._queue_depth,
+            device_ok=self._device_ok,
+        )
+
+    def _on_status(self, now: float) -> None:
+        # Reconnect rule: the replayed ring is informational. Never rebuild speech
+        # state from it; reset so a stale speech_start cannot pin speech_open.
+        self.speech_open = False
+        self.awaited_seg_id = None
+        self._last_activity_ts = now
+
+    def _on_transcript(self, event: dict, now: float) -> None:
+        text = (event.get("text") or "").strip()
+        if not text:
+            return
+        self._last_activity_ts = now
+
+        if self.modes.mode is Mode.COMMAND_ARMED:
+            ts = float(event.get("ts", now))
+            # Ignore transcripts from before arming (e.g. ring replay on attach).
+            if self.arm_ts is None or ts >= self.arm_ts:
+                self.command_text = f"{self.command_text} {text}".strip()
+        elif self.modes.mode is Mode.CHAT:
+            self.chat_history.append(text)
+            self._pending_chat.append(text)
+            self._last_chat_activity = now
+            if self._is_exit_phrase(text):
+                self._exit_chat("exit_phrase")
+        self._emit_command_text()
+
+    def _is_exit_phrase(self, text: str) -> bool:
+        normalized = text.lower().strip().rstrip(".!?,")
+        return any(phrase in normalized for phrase in self.cfg.assistant.chat_exit_phrases)
+
+    def _check_timeouts(self, now: float) -> None:
+        if self.modes.mode is Mode.COMMAND_ARMED:
+            if (
+                self.arm_ts is not None
+                and now - self.arm_ts > self.cfg.assistant.command_arm_timeout_s
+            ):
+                self._disarm("arm_timeout")
+            elif not self.stt_ok():
+                self._disarm("stt_stall")
+        elif self.modes.mode is Mode.CHAT:
+            if now - self._last_chat_activity > self.cfg.assistant.chat_idle_timeout_s:
+                self._exit_chat("idle_timeout")
+
+    # ------------------------------------------------------------------ helpers
+
+    def _disarm(self, reason: str) -> None:
+        if not self.modes.cancel():
+            return
+        self._clear_command()
+        self._emit_mode()
+        self._emit_command_text(force=True)
+        self._emit(ERROR_EVENT, code=reason, message=f"command {reason}")
+
+    def _exit_chat(self, reason: str) -> bool:
+        if not self.modes.exit_chat():
+            return False
+        self.chat_history = []
+        self._pending_chat = []
+        if self.chat_reset is not None:
+            try:
+                self.chat_reset()
+            except Exception as e:  # noqa: BLE001 - resetting chat must not break the mode change
+                logger.warning("chat reset failed: %s", e)
+        self._emit_mode()
+        if reason != "toggle":
+            self._emit(ERROR_EVENT, code=reason, message=f"chat {reason}")
+        return True
+
+    def _dispatch_chat(self) -> None:
+        text = " ".join(self._pending_chat).strip()
+        self._pending_chat = []
+        if not text:
+            return
+        self._emit(CHAT_MESSAGE_EVENT, role="user", text=text)
+        thread = threading.Thread(
+            target=self._run_chat, args=(text,), name="assistant-chat", daemon=True
+        )
+        thread.start()
+
+    def _run_chat(self, text: str) -> None:
+        chat_fn = self.chat_fn
+        if chat_fn is None:
+            return
+        try:
+            reply = chat_fn(text)
+        except Exception as e:  # noqa: BLE001 - an LLM failure must not kill the service
+            logger.error("Chat error: %s", e)
+            self._emit(ERROR_EVENT, code="llm_error", message=str(e))
+            return
+        if reply:
+            self._emit(CHAT_MESSAGE_EVENT, role="assistant", text=reply)
+
+    def _clear_command(self) -> None:
+        self.arm_ts = None
+        self.command_text = ""
+        self.command_edited = False
+
+    def _emit_mode(self) -> None:
+        self._emit(MODE_EVENT, mode=self.modes.mode.value)
+
+    def _emit_command_text(self, force: bool = False) -> None:
+        if self.modes.mode not in (Mode.COMMAND_ARMED, Mode.EXECUTING):
+            return
+        phase = self.command_phase()
+        key = (self.command_text, self.command_edited, phase)
+        if not force and key == self._last_emitted:
+            return
+        self._last_emitted = key
+        self._emit(
+            COMMAND_TEXT_EVENT,
+            text=self.command_text,
+            edited=self.command_edited,
+            phase=phase,
+        )

+ 124 - 0
src/stt_daemon/assistant/tools.py

@@ -0,0 +1,124 @@
+"""Tool registry: typed tools the assistant may invoke.
+
+Arguments arrive from the model as a JSON string; they are parsed and validated
+against the tool's schema before anything runs. Unknown tools, malformed JSON,
+missing/extra/wrong-typed arguments are all rejected (parse, don't trust).
+"""
+
+import json
+import logging
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Callable
+
+logger = logging.getLogger(__name__)
+
+READ_ONLY = "read_only"
+MUTATING = "mutating"
+
+_TYPE_MAP = {
+    "string": str,
+    "number": (int, float),
+    "integer": int,
+    "boolean": bool,
+    "array": list,
+    "object": dict,
+}
+
+
+@dataclass
+class Tool:
+    """A callable tool with an OpenAI-style JSON schema."""
+
+    name: str
+    description: str
+    parameters: dict
+    run: Callable[[dict], str]
+    side_effect: str = READ_ONLY
+    requires_confirmation: bool = False
+
+    def spec(self) -> dict:
+        """OpenAI `tools` entry for this tool."""
+        return {
+            "type": "function",
+            "function": {
+                "name": self.name,
+                "description": self.description,
+                "parameters": self.parameters,
+            },
+        }
+
+
+def _validate_schema(schema: dict, args: dict) -> str | None:
+    """Return an error message, or None when the arguments are acceptable."""
+    properties = schema.get("properties", {})
+    for key in schema.get("required", []):
+        if key not in args:
+            return f"missing required argument '{key}'"
+    for key, value in args.items():
+        if key not in properties:
+            return f"unexpected argument '{key}'"
+        expected = properties[key].get("type")
+        if expected not in _TYPE_MAP:
+            continue
+        if expected in ("integer", "number") and isinstance(value, bool):
+            return f"argument '{key}' must be {expected}"
+        if not isinstance(value, _TYPE_MAP[expected]):
+            return f"argument '{key}' must be {expected}"
+    return None
+
+
+class ToolRegistry:
+    """Holds the tools the assistant may call."""
+
+    def __init__(self) -> None:
+        self._tools: dict[str, Tool] = {}
+
+    def register(self, tool: Tool) -> None:
+        self._tools[tool.name] = tool
+
+    def get(self, name: str) -> Tool | None:
+        return self._tools.get(name)
+
+    def names(self) -> list[str]:
+        return list(self._tools)
+
+    def specs(self) -> list[dict]:
+        return [tool.spec() for tool in self._tools.values()]
+
+    def validate(self, name: str, arguments) -> tuple[dict | None, str | None]:
+        """Parse and validate model-supplied arguments for `name`."""
+        tool = self.get(name)
+        if tool is None:
+            return None, f"unknown tool: {name}"
+        if isinstance(arguments, str):
+            try:
+                parsed = json.loads(arguments) if arguments.strip() else {}
+            except json.JSONDecodeError:
+                return None, f"invalid JSON arguments for {name}"
+        else:
+            parsed = dict(arguments or {})
+        if not isinstance(parsed, dict):
+            return None, f"arguments must be an object for {name}"
+        error = _validate_schema(tool.parameters, parsed)
+        if error is not None:
+            return None, f"{name}: {error}"
+        return parsed, None
+
+
+def _get_datetime(_args: dict) -> str:
+    return datetime.now().astimezone().isoformat(timespec="seconds")
+
+
+def default_registry() -> ToolRegistry:
+    """Registry with the built-in tools."""
+    registry = ToolRegistry()
+    registry.register(
+        Tool(
+            name="get_datetime",
+            description="Get the current local date and time.",
+            parameters={"type": "object", "properties": {}, "required": []},
+            run=_get_datetime,
+        )
+    )
+    return registry

+ 12 - 0
src/stt_daemon/audio.py

@@ -63,6 +63,10 @@ class AudioCapture(ABC):
         """Close and clean up."""
         pass
 
+    def healthy(self) -> bool:
+        """Best-effort liveness of the capture backend."""
+        return True
+
 
 class ParecCapture(AudioCapture):
     """Capture via parec (PulseAudio command-line tool)."""
@@ -140,6 +144,10 @@ class ParecCapture(AudioCapture):
         audio = np.clip(audio, -32768, 32767).astype(np.int16)
         return audio.tobytes()
 
+    def healthy(self) -> bool:
+        """True while the parec subprocess is still running."""
+        return self._proc is not None and self._proc.poll() is None
+
     def close(self) -> None:
         """Terminate parec process."""
         if self._proc:
@@ -240,6 +248,10 @@ class SoundDeviceCapture(AudioCapture):
         audio = np.clip(audio, -32768, 32767).astype(np.int16)
         return audio.tobytes()
 
+    def healthy(self) -> bool:
+        """True while the input stream is active."""
+        return self._stream is not None and bool(self._stream.active)
+
     def close(self) -> None:
         """Close the stream."""
         if self._stream:

+ 351 - 0
src/stt_daemon/bus.py

@@ -0,0 +1,351 @@
+"""Publish-only NDJSON event bus over a UNIX domain socket.
+
+The capture thread is the hot path: it must never block. `EventPublisher.publish()`
+only appends to a lock-free `deque`; a single dispatcher thread owns the ring, the
+client set, and every client's outbound buffer, so registration/replay and fan-out
+never interleave. Slow subscribers are bounded by `deque(maxlen=N)` (drop-oldest)
+and never block the publisher.
+
+The daemon never accepts inbound messages on this socket.
+"""
+
+import collections
+import itertools
+import logging
+import os
+import socket
+import threading
+import time
+from pathlib import Path
+from typing import Callable
+
+from . import events
+
+logger = logging.getLogger(__name__)
+
+CLIENT_QUEUE_SIZE = 1000
+POLL_INTERVAL = 0.005
+RECONNECT_DELAY = 0.5
+DEDUPE_CAP = 4096
+ACCEPT_TIMEOUT = 0.2
+READ_CHUNK = 65536
+
+
+def default_socket_path() -> str:
+    """Default publish socket path under $XDG_RUNTIME_DIR (fallback /tmp)."""
+    runtime = os.environ.get("XDG_RUNTIME_DIR") or "/tmp"
+    return str(Path(runtime) / "stt-daemon.sock")
+
+
+class _Client:
+    __slots__ = ("conn", "queue", "thread")
+
+    def __init__(self, conn: socket.socket, maxlen: int):
+        self.conn = conn
+        self.queue: collections.deque[bytes] = collections.deque(maxlen=maxlen)
+        self.thread: threading.Thread | None = None
+
+
+class _SeenSet:
+    """Bounded set used to drop duplicate sequence numbers within a connection."""
+
+    __slots__ = ("_cap", "_order", "_set")
+
+    def __init__(self, cap: int):
+        self._cap = cap
+        self._order: collections.deque[int] = collections.deque()
+        self._set: set[int] = set()
+
+    def check_and_add(self, seq: int) -> bool:
+        """Return True if `seq` was already seen; otherwise record it."""
+        if seq in self._set:
+            return True
+        self._set.add(seq)
+        self._order.append(seq)
+        if len(self._order) > self._cap:
+            self._set.discard(self._order.popleft())
+        return False
+
+
+class EventPublisher:
+    """Broadcasts events to subscribers over a UNIX socket."""
+
+    def __init__(
+        self,
+        path: str | None = None,
+        ring_size: int = 200,
+        status_provider: Callable[[], dict] | None = None,
+    ):
+        """Create a publisher (call `start()` to bind and begin serving).
+
+        Args:
+            path: Socket path; defaults to `default_socket_path()`.
+            ring_size: Number of recent events replayed to new subscribers.
+            status_provider: Returns the fields for the `status` event sent on attach.
+        """
+        self.path = path or default_socket_path()
+        ring_size = max(1, ring_size)
+        self._ring: collections.deque[dict] = collections.deque(maxlen=ring_size)
+        self._queue_maxlen = max(CLIENT_QUEUE_SIZE, ring_size + 16)
+        self._status_provider = status_provider
+
+        self._seq = itertools.count(1)
+        self._pending: collections.deque[dict] = collections.deque()
+        self._new_conns: collections.deque[socket.socket] = collections.deque()
+        self._dead: collections.deque[_Client] = collections.deque()
+        self._clients: list[_Client] = []
+
+        self._stop = threading.Event()
+        self._server: socket.socket | None = None
+        self._accept_thread: threading.Thread | None = None
+        self._dispatch_thread: threading.Thread | None = None
+
+    def start(self) -> None:
+        """Bind the socket and start the accept + dispatch threads."""
+        self._bind()
+        self._accept_thread = threading.Thread(
+            target=self._accept_loop, name="bus-accept", daemon=True
+        )
+        self._dispatch_thread = threading.Thread(
+            target=self._dispatch_loop, name="bus-dispatch", daemon=True
+        )
+        self._accept_thread.start()
+        self._dispatch_thread.start()
+
+    def _bind(self) -> None:
+        path = Path(self.path)
+        path.parent.mkdir(parents=True, exist_ok=True)
+        if path.exists():
+            path.unlink()
+        server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+        server.bind(str(path))
+        os.chmod(str(path), 0o600)
+        server.listen(16)
+        server.settimeout(ACCEPT_TIMEOUT)
+        self._server = server
+
+    def publish(self, event: dict) -> None:
+        """Queue a pre-built event. Non-blocking (hot path)."""
+        self._pending.append(event)
+
+    def emit(self, type_: str, **fields) -> None:
+        """Build and publish an event with the next monotonic `seq`."""
+        self.publish(events.make_event(type_, next(self._seq), **fields))
+
+    def client_count(self) -> int:
+        """Number of currently connected subscribers."""
+        return len(self._clients)
+
+    def ring_len(self) -> int:
+        """Number of events currently held in the replay ring."""
+        return len(self._ring)
+
+    def _accept_loop(self) -> None:
+        server = self._server
+        if server is None:
+            return
+        while not self._stop.is_set():
+            try:
+                conn, _ = server.accept()
+            except socket.timeout:
+                continue
+            except OSError:
+                break
+            self._new_conns.append(conn)
+
+    def _dispatch_loop(self) -> None:
+        while not self._stop.is_set():
+            handled = self._drain_dead()
+            handled |= self._fan_out()
+            handled |= self._drain_new()
+            if not handled:
+                time.sleep(POLL_INTERVAL)
+
+    def _drain_dead(self) -> bool:
+        handled = False
+        while True:
+            try:
+                client = self._dead.popleft()
+            except IndexError:
+                break
+            handled = True
+            try:
+                client.conn.close()
+            except OSError:
+                pass
+            try:
+                self._clients.remove(client)
+            except ValueError:
+                pass
+        return handled
+
+    def _fan_out(self) -> bool:
+        handled = False
+        while True:
+            try:
+                event = self._pending.popleft()
+            except IndexError:
+                break
+            handled = True
+            self._ring.append(event)
+            line = events.encode(event)
+            for client in self._clients:
+                client.queue.append(line)
+        return handled
+
+    def _drain_new(self) -> bool:
+        handled = False
+        while True:
+            try:
+                conn = self._new_conns.popleft()
+            except IndexError:
+                break
+            handled = True
+            client = _Client(conn, self._queue_maxlen)
+            # Seed replay (status -> ring snapshot -> ring_end) BEFORE the client is
+            # registered, so live fan-out can never interleave and never duplicates.
+            client.queue.append(events.encode(self._status_event()))
+            for event in list(self._ring):
+                client.queue.append(events.encode(event))
+            client.queue.append(
+                events.encode(events.make_event(events.RING_END, next(self._seq)))
+            )
+            client.thread = threading.Thread(
+                target=self._client_writer, args=(client,), name="bus-client", daemon=True
+            )
+            self._clients.append(client)
+            client.thread.start()
+        return handled
+
+    def _status_event(self) -> dict:
+        fields: dict = {}
+        if self._status_provider is not None:
+            fields = dict(self._status_provider() or {})
+        fields.setdefault("ring_seq", self._ring[-1]["seq"] if self._ring else 0)
+        return events.make_event(events.STATUS, next(self._seq), **fields)
+
+    def _client_writer(self, client: _Client) -> None:
+        try:
+            while not self._stop.is_set():
+                try:
+                    line = client.queue.popleft()
+                except IndexError:
+                    time.sleep(POLL_INTERVAL)
+                    continue
+                client.conn.sendall(line)
+        except OSError:
+            pass
+        finally:
+            self._dead.append(client)
+
+    def close(self) -> None:
+        """Stop serving, drop subscribers, and remove the socket."""
+        self._stop.set()
+        if self._server is not None:
+            try:
+                self._server.close()
+            except OSError:
+                pass
+        for client in list(self._clients):
+            try:
+                client.conn.shutdown(socket.SHUT_RDWR)
+            except OSError:
+                pass
+            try:
+                client.conn.close()
+            except OSError:
+                pass
+        for thread in (self._accept_thread, self._dispatch_thread):
+            if thread is not None:
+                thread.join(timeout=1)
+        try:
+            Path(self.path).unlink()
+        except OSError:
+            pass
+
+
+class EventSubscriber:
+    """Read-only NDJSON subscriber with automatic reconnect."""
+
+    def __init__(self, path: str | None = None, reconnect_delay: float = RECONNECT_DELAY):
+        """Create a subscriber (events are not read until `events()` is iterated)."""
+        self.path = path or default_socket_path()
+        self.reconnect_delay = reconnect_delay
+        self._stop = threading.Event()
+        self._sock: socket.socket | None = None
+        self._buf = bytearray()
+
+    def connect(self) -> bool:
+        """Block until connected or stopped; return True if connected."""
+        while not self._stop.is_set():
+            try:
+                sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+                sock.connect(self.path)
+                self._sock = sock
+                self._buf = bytearray()
+                return True
+            except OSError:
+                self._stop.wait(self.reconnect_delay)
+        return False
+
+    def events(self):
+        """Yield decoded events, reconnecting on EOF or error.
+
+        The dedupe set is reset on each (re)connect, so a replayed ring is
+        re-delivered by design (consumers treat it as informational).
+        """
+        seen = _SeenSet(DEDUPE_CAP)
+        while not self._stop.is_set():
+            if self._sock is None:
+                if not self.connect():
+                    break
+                seen = _SeenSet(DEDUPE_CAP)
+            line = self._read_line()
+            if line is None:
+                self._close_sock()
+                continue
+            try:
+                event = events.decode(line)
+            except events.DecodeError:
+                logger.warning("dropping malformed event line")
+                continue
+            if seen.check_and_add(event["seq"]):
+                continue
+            yield event
+
+    def _read_line(self) -> bytes | None:
+        while True:
+            newline = self._buf.find(b"\n")
+            if newline != -1:
+                line = bytes(self._buf[:newline])
+                del self._buf[: newline + 1]
+                return line
+            sock = self._sock
+            if sock is None:
+                return None
+            try:
+                chunk = sock.recv(READ_CHUNK)
+            except OSError:
+                return None
+            if not chunk:
+                return None
+            self._buf.extend(chunk)
+
+    def _close_sock(self) -> None:
+        sock = self._sock
+        self._sock = None
+        self._buf = bytearray()
+        if sock is not None:
+            try:
+                sock.shutdown(socket.SHUT_RDWR)
+            except OSError:
+                pass
+            try:
+                sock.close()
+            except OSError:
+                pass
+
+    def close(self) -> None:
+        """Stop iterating and close the connection (safe from another thread)."""
+        self._stop.set()
+        self._close_sock()

+ 128 - 0
src/stt_daemon/config.py

@@ -60,6 +60,56 @@ class DaemonConfig:
     backlog_warn_at: int = 5
 
 
+@dataclass
+class EventsConfig:
+    """Publish-only event bus configuration."""
+    socket: str = ""
+    heartbeat_ms: int = 1000
+    ring_size: int = 200
+
+
+@dataclass
+class AssistantConfig:
+    """Assistant (controller) configuration."""
+    control_socket: str = ""
+    stall_timeout_ms: int = 3000
+    worker_stall_ms: int = 15000
+    command_arm_timeout_s: float = 10.0
+    chat_idle_timeout_s: float = 300.0
+    chat_exit_phrases: list[str] = field(
+        default_factory=lambda: ["end conversation", "that's all", "stop listening"]
+    )
+    require_viewer: bool = True
+    turn_gap_s: float = 1.5
+
+
+@dataclass
+class AuditConfig:
+    """Action audit configuration (daily JSONL, like transcripts)."""
+    enabled: bool = True
+    dir: str = "audit"
+
+
+@dataclass
+class LlmConfig:
+    """llama.cpp (OpenAI-compatible) configuration."""
+    enabled: bool = True
+    endpoint: str = "http://localhost:9931/v1/chat/completions"
+    temperature: float = 0.7
+    max_tokens: int = 1024
+    timeout_s: float = 120.0
+    cache_prompt: bool = True
+    max_prompt_tokens: int = 64000
+    trim_block_turns: int = 4
+    max_tool_iterations: int = 3
+    system_prompt_chat: str = (
+        "You are a concise voice assistant. Reply in 1-3 sentences."
+    )
+    system_prompt_command: str = (
+        "You translate a spoken request into a single tool call when one applies."
+    )
+
+
 @dataclass
 class SinkConfig:
     """Virtual audio sink configuration."""
@@ -78,6 +128,10 @@ class Config:
     output: OutputConfig = field(default_factory=OutputConfig)
     sink: SinkConfig = field(default_factory=SinkConfig)
     daemon: DaemonConfig = field(default_factory=DaemonConfig)
+    events: EventsConfig = field(default_factory=EventsConfig)
+    assistant: AssistantConfig = field(default_factory=AssistantConfig)
+    audit: AuditConfig = field(default_factory=AuditConfig)
+    llm: LlmConfig = field(default_factory=LlmConfig)
 
 
 def load_config(path: str | None = None) -> Config:
@@ -189,4 +243,78 @@ def load_config(path: str | None = None) -> Config:
                 ),
             )
 
+        # Events section
+        if "events" in data:
+            events_data = data["events"]
+            cfg.events = EventsConfig(
+                socket=events_data.get("socket", cfg.events.socket),
+                heartbeat_ms=events_data.get("heartbeat_ms", cfg.events.heartbeat_ms),
+                ring_size=events_data.get("ring_size", cfg.events.ring_size),
+            )
+
+        # Assistant section
+        if "assistant" in data:
+            assistant_data = data["assistant"]
+            cfg.assistant = AssistantConfig(
+                control_socket=assistant_data.get(
+                    "control_socket", cfg.assistant.control_socket
+                ),
+                stall_timeout_ms=assistant_data.get(
+                    "stall_timeout_ms", cfg.assistant.stall_timeout_ms
+                ),
+                worker_stall_ms=assistant_data.get(
+                    "worker_stall_ms", cfg.assistant.worker_stall_ms
+                ),
+                command_arm_timeout_s=assistant_data.get(
+                    "command_arm_timeout_s", cfg.assistant.command_arm_timeout_s
+                ),
+                chat_idle_timeout_s=assistant_data.get(
+                    "chat_idle_timeout_s", cfg.assistant.chat_idle_timeout_s
+                ),
+                chat_exit_phrases=list(
+                    assistant_data.get(
+                        "chat_exit_phrases", cfg.assistant.chat_exit_phrases
+                    )
+                ),
+                require_viewer=assistant_data.get(
+                    "require_viewer", cfg.assistant.require_viewer
+                ),
+                turn_gap_s=assistant_data.get("turn_gap_s", cfg.assistant.turn_gap_s),
+            )
+
+        # Audit section
+        if "audit" in data:
+            audit_data = data["audit"]
+            cfg.audit = AuditConfig(
+                enabled=audit_data.get("enabled", cfg.audit.enabled),
+                dir=audit_data.get("dir", cfg.audit.dir),
+            )
+
+        # LLM section
+        if "llm" in data:
+            llm_data = data["llm"]
+            cfg.llm = LlmConfig(
+                enabled=llm_data.get("enabled", cfg.llm.enabled),
+                endpoint=llm_data.get("endpoint", cfg.llm.endpoint),
+                temperature=llm_data.get("temperature", cfg.llm.temperature),
+                max_tokens=llm_data.get("max_tokens", cfg.llm.max_tokens),
+                timeout_s=llm_data.get("timeout_s", cfg.llm.timeout_s),
+                cache_prompt=llm_data.get("cache_prompt", cfg.llm.cache_prompt),
+                max_prompt_tokens=llm_data.get(
+                    "max_prompt_tokens", cfg.llm.max_prompt_tokens
+                ),
+                trim_block_turns=llm_data.get(
+                    "trim_block_turns", cfg.llm.trim_block_turns
+                ),
+                max_tool_iterations=llm_data.get(
+                    "max_tool_iterations", cfg.llm.max_tool_iterations
+                ),
+                system_prompt_chat=llm_data.get(
+                    "system_prompt_chat", cfg.llm.system_prompt_chat
+                ),
+                system_prompt_command=llm_data.get(
+                    "system_prompt_command", cfg.llm.system_prompt_command
+                ),
+            )
+
     return cfg

+ 96 - 0
src/stt_daemon/ctl.py

@@ -0,0 +1,96 @@
+"""One-shot control client for hotkeys (`stt-ctl`).
+
+Connects to the assistant control socket, sends a single command, prints the
+replies it receives, and exits. Intended to be bound to global hotkeys.
+"""
+
+import argparse
+import json
+import socket
+import sys
+
+from .assistant.control import default_control_path
+from .config import load_config
+
+COMMAND_MAP = {
+    "command-start": "command_start",
+    "command-execute": "command_execute",
+    "command-cancel": "command_cancel",
+    "command-edit": "command_edit",
+    "chat-toggle": "chat_toggle",
+    "status": "status",
+}
+
+READ_TIMEOUT_S = 1.0
+READ_CHUNK = 65536
+
+
+def build_message(command: str, text: str | None = None) -> dict:
+    """Map a CLI verb to a control-socket message."""
+    if command not in COMMAND_MAP:
+        raise ValueError(f"unknown command: {command}")
+    message = {"type": COMMAND_MAP[command]}
+    if text is not None:
+        message["text"] = text
+    return message
+
+
+def main() -> int:
+    """Entry point for `stt-ctl`."""
+    parser = argparse.ArgumentParser(description="Send one command to the running assistant")
+    parser.add_argument("command", choices=sorted(COMMAND_MAP))
+    parser.add_argument("--text", default=None, help="Text for command-edit")
+    parser.add_argument("--config", default=None, help="Path to config.toml")
+    parser.add_argument("--socket", default=None, help="Override the control socket path")
+    args = parser.parse_args()
+
+    path = args.socket
+    if path is None:
+        cfg = load_config(args.config)
+        path = cfg.assistant.control_socket or default_control_path()
+
+    try:
+        message = build_message(args.command, args.text)
+    except ValueError as e:
+        print(f"error: {e}", file=sys.stderr)
+        return 2
+
+    sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+    try:
+        sock.connect(path)
+    except OSError as e:
+        print(f"error: cannot connect to {path}: {e}", file=sys.stderr)
+        return 1
+
+    sock.sendall((json.dumps(message) + "\n").encode("utf-8"))
+    sock.settimeout(READ_TIMEOUT_S)
+    buffer = bytearray()
+    try:
+        while True:
+            try:
+                chunk = sock.recv(READ_CHUNK)
+            except socket.timeout:
+                break
+            if not chunk:
+                break
+            buffer.extend(chunk)
+            while True:
+                newline = buffer.find(b"\n")
+                if newline == -1:
+                    break
+                line = bytes(buffer[:newline])
+                del buffer[: newline + 1]
+                if not line.strip():
+                    continue
+                try:
+                    reply = json.loads(line.decode("utf-8"))
+                except (UnicodeDecodeError, json.JSONDecodeError):
+                    continue
+                print(json.dumps(reply, ensure_ascii=False))
+    finally:
+        sock.close()
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 245 - 51
src/stt_daemon/daemon.py

@@ -1,28 +1,53 @@
 """Main daemon that orchestrates capture, segmentation, and transcription."""
 
+import collections
+import itertools
 import logging
+import math
 import queue
 import threading
-from typing import Optional
+import time
+from typing import Any, Callable, Optional
 
+import numpy as np
+
+from . import events
 from .audio import AudioCapture, open_capture
+from .bus import EventPublisher
 from .config import Config
 from .output import TranscriptSink
-from .segmenter import FrameSegmenter, make_vad
+from .segmenter import FrameSegmenter, Utterance, make_vad
 from .transcriber import Transcriber
 
 
 class Daemon:
     """Always-on STT daemon with worker threads."""
 
-    def __init__(self, cfg: Config, log_level: int = logging.INFO):
+    def __init__(
+        self,
+        cfg: Config,
+        log_level: int = logging.INFO,
+        *,
+        capture_factory: Callable[[], Any] | None = None,
+        vad_factory: Callable[[int], Any] | None = None,
+        transcriber_factory: Callable[[], Any] | None = None,
+        sink_factory: Callable[[], Any] | None = None,
+    ):
         """Initialize the daemon.
 
         Args:
             cfg: Configuration object.
             log_level: Logging level (e.g., logging.INFO, logging.DEBUG).
+            capture_factory: Override audio capture creation (used by tests).
+            vad_factory: Override VAD creation (used by tests).
+            transcriber_factory: Override transcriber creation (used by tests).
+            sink_factory: Override transcript sink creation (used by tests).
         """
         self.cfg = cfg
+        self._capture_factory = capture_factory or (lambda: open_capture(self.cfg.audio))
+        self._vad_factory = vad_factory or make_vad
+        self._transcriber_factory = transcriber_factory or self._build_transcriber
+        self._sink_factory = sink_factory or self._build_sink
 
         # Set up logging
         self.logger = logging.getLogger("stt_daemon")
@@ -37,6 +62,7 @@ class Daemon:
         self.segmenter: Optional[FrameSegmenter] = None
         self.transcriber: Optional[Transcriber] = None
         self.sink: Optional[TranscriptSink] = None
+        self.publisher: Optional[EventPublisher] = None
 
         # Threading
         self.segment_queue: queue.Queue = queue.Queue(maxsize=cfg.daemon.queue_size)
@@ -46,6 +72,18 @@ class Daemon:
         self._reader_thread: Optional[threading.Thread] = None
         self._worker_thread: Optional[threading.Thread] = None
 
+        # Event bus state (shared across reader/worker threads)
+        self._seg_counter = itertools.count(1)
+        self._segments_done = 0
+        self._last_segment_done_ts = 0.0
+        self._last_worker_activity_ts = 0.0
+        self._last_transcript_ts = 0.0
+        self._in_flight = False
+        self._frames_since_heartbeat = 0
+        self._started_at = 0.0
+        rms_window = max(1, round(1000 / max(1, cfg.audio.frame_ms)))
+        self._rms_window: collections.deque[float] = collections.deque(maxlen=rms_window)
+
     def run(self) -> None:
         """Run the daemon (blocks until stopped)."""
         try:
@@ -55,13 +93,34 @@ class Daemon:
         finally:
             self.stop()
 
+    def _build_transcriber(self) -> Transcriber:
+        return Transcriber(
+            model=self.cfg.whisper.model,
+            device=self.cfg.whisper.device,
+            compute_type=self.cfg.whisper.compute_type,
+            language=self.cfg.whisper.language,
+            beam_size=self.cfg.whisper.beam_size,
+            vad_filter=self.cfg.whisper.vad_filter,
+            no_speech_prob_max=self.cfg.whisper.no_speech_prob_max,
+            avg_logprob_min=self.cfg.whisper.avg_logprob_min,
+        )
+
+    def _build_sink(self) -> TranscriptSink:
+        return TranscriptSink(
+            stdout=self.cfg.output.stdout,
+            jsonl_dir=self.cfg.output.jsonl_dir,
+            save_audio=self.cfg.output.save_audio,
+            audio_dir=self.cfg.output.audio_dir,
+            sample_rate=self.cfg.audio.sample_rate,
+        )
+
     def _initialize(self) -> None:
-        """Initialize all components."""
+        """Initialize all components and start publishing events."""
         self.logger.info("Initializing audio capture...")
-        self.capture = open_capture(self.cfg.audio)
+        self.capture = self._capture_factory()
 
         self.logger.info("Initializing VAD...")
-        vad = make_vad(self.cfg.vad.aggressiveness)
+        vad = self._vad_factory(self.cfg.vad.aggressiveness)
 
         self.logger.info("Initializing segmenter...")
         self.segmenter = FrameSegmenter(
@@ -80,28 +139,43 @@ class Daemon:
         )
 
         self.logger.info(f"Loading Whisper model '{self.cfg.whisper.model}'...")
-        self.transcriber = Transcriber(
-            model=self.cfg.whisper.model,
-            device=self.cfg.whisper.device,
-            compute_type=self.cfg.whisper.compute_type,
-            language=self.cfg.whisper.language,
-            beam_size=self.cfg.whisper.beam_size,
-            vad_filter=self.cfg.whisper.vad_filter,
-            no_speech_prob_max=self.cfg.whisper.no_speech_prob_max,
-            avg_logprob_min=self.cfg.whisper.avg_logprob_min,
-        )
+        self.transcriber = self._transcriber_factory()
 
         self.logger.info("Initializing output sink...")
-        self.sink = TranscriptSink(
-            stdout=self.cfg.output.stdout,
-            jsonl_dir=self.cfg.output.jsonl_dir,
-            save_audio=self.cfg.output.save_audio,
-            audio_dir=self.cfg.output.audio_dir,
-            sample_rate=self.cfg.audio.sample_rate,
-        )
+        self.sink = self._sink_factory()
+
+        self._started_at = time.time()
+        self._start_publisher()
 
         self.logger.info("Daemon ready. Listening...")
 
+    def _start_publisher(self) -> None:
+        """Start the publish-only event bus (best effort; never fatal)."""
+        publisher = EventPublisher(
+            path=self.cfg.events.socket or None,
+            ring_size=self.cfg.events.ring_size,
+            status_provider=self._status_fields,
+        )
+        try:
+            publisher.start()
+        except OSError as e:
+            self.logger.warning("Event bus disabled (%s)", e)
+            self.publisher = None
+            return
+        self.publisher = publisher
+
+    def _status_fields(self) -> dict:
+        return {
+            "capture": self.cfg.audio.device,
+            "model_loaded": self.transcriber is not None,
+            "segments_done": self._segments_done,
+            "heartbeat_ms": self.cfg.events.heartbeat_ms,
+        }
+
+    def _publish(self, type_: str, **fields) -> None:
+        if self.publisher is not None:
+            self.publisher.emit(type_, **fields)
+
     def _start_workers(self) -> None:
         """Start reader and worker threads."""
         self._reader_thread = threading.Thread(target=self._reader_loop, daemon=False)
@@ -110,36 +184,41 @@ class Daemon:
         self._worker_thread.start()
 
     def _reader_loop(self) -> None:
-        """Reader thread: capture frames and segment them."""
+        """Reader thread: capture frames, segment them, and publish events."""
         try:
             if not self.capture or not self.segmenter:
                 return
 
+            heartbeat_s = max(0.05, self.cfg.events.heartbeat_ms / 1000.0)
+            next_heartbeat = time.time() + heartbeat_s
+
             for frame in self.capture.frames():
                 if self._stop_event.is_set():
                     break
 
+                self._track_level(frame)
+                self._frames_since_heartbeat += 1
+
+                was_speaking = self.segmenter.speaking
                 utt = self.segmenter.push(frame)
-                if utt:
-                    try:
-                        self.segment_queue.put(utt, block=False)
-                    except queue.Full:
-                        self.logger.warning("Segment queue full, dropping oldest")
-                        try:
-                            self.segment_queue.get_nowait()
-                            self.segment_queue.put(utt, block=False)
-                        except queue.Empty:
-                            pass
-                    self._check_backlog()
+
+                if not was_speaking and self.segmenter.speaking:
+                    self._publish(events.SPEECH_START)
+
+                if utt is not None:
+                    self._on_segment_finalized(utt)
+
+                now = time.time()
+                if now >= next_heartbeat:
+                    self._publish_heartbeat()
+                    next_heartbeat = now + heartbeat_s
 
             # Shutdown: flush any in-progress utterance
             if self.segmenter:
                 utt = self.segmenter.flush()
-                if utt:
-                    try:
-                        self.segment_queue.put(utt, block=False)
-                    except queue.Full:
-                        pass
+                if utt is not None:
+                    self._on_segment_finalized(utt)
+            self._publish_heartbeat()
 
         except Exception as e:
             if self._stop_event.is_set():
@@ -150,6 +229,74 @@ class Daemon:
             if self.capture:
                 self.capture.close()
 
+    def _on_segment_finalized(self, utt: Utterance) -> None:
+        """Assign a seg_id at finalization, announce the stop, and enqueue."""
+        seg_id = next(self._seg_counter)
+        utt.seg_id = seg_id
+        self._publish(events.SPEECH_STOP, seg_id=seg_id, end_reason=utt.reason)
+        if not self._enqueue(utt):
+            self._publish(
+                events.SEGMENT_DONE, seg_id=seg_id, emitted=False, end_reason=utt.reason
+            )
+        self._check_backlog()
+
+    def _enqueue(self, utt: Utterance) -> bool:
+        """Queue a segment, dropping the oldest if full. False if it could not be queued."""
+        try:
+            self.segment_queue.put(utt, block=False)
+            return True
+        except queue.Full:
+            self.logger.warning("Segment queue full, dropping oldest")
+            try:
+                dropped = self.segment_queue.get_nowait()
+            except queue.Empty:
+                dropped = None
+            if dropped is not None:
+                self._publish(
+                    events.SEGMENT_DONE,
+                    seg_id=dropped.seg_id,
+                    emitted=False,
+                    end_reason=dropped.reason,
+                )
+            try:
+                self.segment_queue.put(utt, block=False)
+                return True
+            except queue.Full:
+                return False
+
+    def _track_level(self, frame: bytes) -> None:
+        samples = np.frombuffer(frame, dtype=np.int16).astype(np.float32) / 32768.0
+        if samples.size:
+            self._rms_window.append(float(np.mean(samples * samples)))
+
+    def _publish_heartbeat(self) -> None:
+        now = time.time()
+        rms = math.sqrt(sum(self._rms_window) / len(self._rms_window)) if self._rms_window else 0.0
+        busy = self._in_flight or self.segment_queue.qsize() > 0
+        self._publish(
+            events.HEARTBEAT,
+            in_flight=self._in_flight,
+            segments_done=self._segments_done,
+            worker_stall_s=(
+                round(now - self._last_worker_activity_ts, 3)
+                if busy and self._last_worker_activity_ts
+                else 0.0
+            ),
+            queue_depth=self.segment_queue.qsize(),
+            frames_since_last=self._frames_since_heartbeat,
+            rms=round(rms, 5),
+            capture=self.cfg.audio.device,
+            device_ok=self.capture.healthy() if self.capture else False,
+            model_loaded=self.transcriber is not None,
+            uptime_s=round(now - self._started_at, 3),
+            last_transcript_age_s=(
+                round(now - self._last_transcript_ts, 3)
+                if self._last_transcript_ts
+                else 0.0
+            ),
+        )
+        self._frames_since_heartbeat = 0
+
     def _check_backlog(self) -> None:
         """Warn once per overload episode; re-arm only after the queue fully drains."""
         size = self.segment_queue.qsize()
@@ -164,25 +311,70 @@ class Daemon:
             self._backlog_warned = False
 
     def _worker_loop(self) -> None:
-        """Worker thread: transcribe segments and emit results."""
+        """Worker thread: transcribe segments and publish results."""
         try:
             while not self._stop_event.is_set():
                 try:
                     utt = self.segment_queue.get(timeout=1)
                 except queue.Empty:
                     continue
-
-                if self.transcriber and self.sink:
-                    try:
-                        result = self.transcriber.transcribe(utt.audio)
-                        self.sink.emit(
-                            result, utt.duration_s, utt.rms, utt.reason, utt.audio
-                        )
-                    except Exception as e:
-                        self.logger.error(f"Transcription error: {e}")
-
+                self._process(utt)
         except Exception as e:
             self.logger.error(f"Worker thread error: {e}")
+        finally:
+            self._drain_remaining()
+
+    def _process(self, utt: Utterance) -> None:
+        """Transcribe one segment, then always publish its segment_done."""
+        emitted = False
+        self._in_flight = True
+        self._last_worker_activity_ts = time.time()
+        try:
+            if self.transcriber and self.sink:
+                result = self.transcriber.transcribe(utt.audio)
+                audio_path = self.sink.emit(
+                    result, utt.duration_s, utt.rms, utt.reason, utt.audio
+                )
+                if result.text:
+                    emitted = True
+                    self._last_transcript_ts = time.time()
+                    self._publish(
+                        events.TRANSCRIPT,
+                        seg_id=utt.seg_id,
+                        text=result.text,
+                        lang=result.language,
+                        lang_prob=result.language_probability,
+                        duration_s=utt.duration_s,
+                        rms=utt.rms,
+                        end_reason=utt.reason,
+                        audio=audio_path,
+                    )
+        except Exception as e:
+            self.logger.error(f"Transcription error: {e}")
+        finally:
+            self._in_flight = False
+            self._segments_done += 1
+            self._last_segment_done_ts = time.time()
+            self._publish(
+                events.SEGMENT_DONE,
+                seg_id=utt.seg_id,
+                emitted=emitted,
+                end_reason=utt.reason,
+            )
+
+    def _drain_remaining(self) -> None:
+        """Announce any queued-but-unprocessed segments on shutdown."""
+        while True:
+            try:
+                utt = self.segment_queue.get_nowait()
+            except queue.Empty:
+                break
+            self._publish(
+                events.SEGMENT_DONE,
+                seg_id=utt.seg_id,
+                emitted=False,
+                end_reason=utt.reason,
+            )
 
     def stop(self) -> None:
         """Stop the daemon. Safe to call more than once."""
@@ -207,5 +399,7 @@ class Daemon:
             self.transcriber.close()
         if self.sink:
             self.sink.close()
+        if self.publisher:
+            self.publisher.close()
 
         self.logger.info("Daemon stopped.")

+ 76 - 0
src/stt_daemon/events.py

@@ -0,0 +1,76 @@
+"""NDJSON event envelope and codec shared by the STT daemon and its subscribers."""
+
+import json
+import time
+
+PROTOCOL_VERSION = 1
+
+TRANSCRIPT = "transcript"
+SEGMENT_DONE = "segment_done"
+SPEECH_START = "speech_start"
+SPEECH_STOP = "speech_stop"
+HEARTBEAT = "heartbeat"
+STATUS = "status"
+RING_END = "ring_end"
+
+ALL_TYPES = frozenset(
+    {
+        TRANSCRIPT,
+        SEGMENT_DONE,
+        SPEECH_START,
+        SPEECH_STOP,
+        HEARTBEAT,
+        STATUS,
+        RING_END,
+    }
+)
+
+_REQUIRED_FIELDS = ("v", "type", "seq", "ts")
+
+
+class DecodeError(ValueError):
+    """Raised when a line cannot be decoded into a valid event envelope."""
+
+
+def make_event(type_: str, seq: int, ts: float | None = None, **fields) -> dict:
+    """Build an event envelope, adding any extra keyword arguments as fields."""
+    event = {
+        "v": PROTOCOL_VERSION,
+        "type": type_,
+        "seq": int(seq),
+        "ts": time.time() if ts is None else float(ts),
+    }
+    event.update(fields)
+    return event
+
+
+def encode(event: dict) -> bytes:
+    """Encode an event as one newline-terminated UTF-8 NDJSON line."""
+    return (json.dumps(event, ensure_ascii=False) + "\n").encode("utf-8")
+
+
+def decode(line: bytes | bytearray | str) -> dict:
+    """Decode one NDJSON line into an event dict, validating the envelope."""
+    if isinstance(line, (bytes, bytearray)):
+        try:
+            text = bytes(line).decode("utf-8")
+        except UnicodeDecodeError as e:
+            raise DecodeError(f"invalid utf-8: {e}") from e
+    elif isinstance(line, str):
+        text = line
+    else:
+        raise DecodeError(f"expected bytes or str, got {type(line).__name__}")
+
+    try:
+        event = json.loads(text)
+    except json.JSONDecodeError as e:
+        raise DecodeError(f"invalid json: {e}") from e
+
+    if not isinstance(event, dict):
+        raise DecodeError("event must be a JSON object")
+
+    for field in _REQUIRED_FIELDS:
+        if field not in event:
+            raise DecodeError(f"missing required field: {field}")
+
+    return event

+ 7 - 2
src/stt_daemon/output.py

@@ -56,7 +56,7 @@ class TranscriptSink:
         rms: float = 0.0,
         reason: str = "unknown",
         audio: np.ndarray | None = None,
-    ) -> None:
+    ) -> str | None:
         """Emit a transcript result.
 
         Args:
@@ -65,9 +65,12 @@ class TranscriptSink:
             rms: Root-mean-square level of the utterance audio.
             reason: Why the utterance ended (end_of_turn/pause/hard_cap/flush).
             audio: Utterance samples (float32, mono); saved when save_audio is enabled.
+
+        Returns:
+            Path of the saved audio file, or None if nothing was saved/emitted.
         """
         if not result.text:
-            return
+            return None
 
         audio_path = None
         if self.save_audio and audio is not None:
@@ -106,6 +109,8 @@ class TranscriptSink:
                 json.dump(record, f, ensure_ascii=False)
                 f.write("\n")
 
+        return str(audio_path) if audio_path is not None else None
+
     def _save_audio(self, audio: np.ndarray, reason: str) -> Path:
         now = datetime.now()
         day_dir = self.audio_dir / now.strftime("%Y-%m-%d")

+ 1 - 0
src/stt_daemon/segmenter.py

@@ -22,6 +22,7 @@ class Utterance:
     started_at: float      # time.time() when confirmed
     rms: float = 0.0       # root-mean-square level of the emitted audio
     reason: str = "unknown"  # why the utterance ended (END_OF_TURN/PAUSE/HARD_CAP/FLUSH)
+    seg_id: int = 0        # assigned by the daemon at finalization (bus correlation)
 
 
 def make_vad(aggressiveness: int) -> webrtcvad.Vad:

+ 1 - 0
src/stt_daemon/tui/__init__.py

@@ -0,0 +1 @@
+"""TUI package: an independent viewer/controller for the assistant."""

+ 188 - 0
src/stt_daemon/tui/app.py

@@ -0,0 +1,188 @@
+"""Textual TUI: watch transcriptions and drive the assistant."""
+
+import argparse
+import queue
+import sys
+import time
+
+from textual.app import App, ComposeResult
+from textual.binding import Binding
+from textual.widgets import Footer, Input, RichLog, Static
+
+from ..assistant.control import default_control_path
+from ..config import load_config
+from .client import ControlClient
+from .state import ViewState
+
+
+class AssistantTUI(App):
+    """Live view of transcriptions, commands, and chat."""
+
+    CSS = """
+    #status { height: 1; }
+    #transcript { height: 1fr; border: solid $accent; }
+    #command { height: 3; border: solid $accent; }
+    #chat { height: 1fr; border: solid $accent; }
+    #edit { display: none; }
+    """
+
+    BINDINGS = [
+        Binding("a", "arm", "arm command"),
+        Binding("enter", "execute", "execute"),
+        Binding("escape", "cancel", "cancel"),
+        Binding("e", "edit", "edit"),
+        Binding("t", "chat", "chat toggle"),
+        Binding("q", "quit", "quit"),
+    ]
+
+    def __init__(self, path: str):
+        super().__init__()
+        self.path = path
+        self.state = ViewState()
+        self.client = ControlClient(path)
+        self._events: queue.Queue = queue.Queue()
+        self._seen_transcripts = 0
+        self._seen_chat = 0
+
+    def compose(self) -> ComposeResult:
+        yield Static(id="status")
+        yield RichLog(id="transcript", wrap=True, markup=False)
+        yield Static(id="command")
+        yield RichLog(id="chat", wrap=True, markup=False)
+        yield Input(placeholder="edit the command, then press Enter", id="edit")
+        yield Footer()
+
+    def on_mount(self) -> None:
+        try:
+            self.client.connect()
+            self.client.subscribe(viewer=True)
+        except OSError as e:
+            self.exit(message=f"cannot connect to {self.path}: {e}")
+            return
+        self.client.start(lambda event: self._events.put(event))
+        self.set_interval(0.1, self._drain)
+
+    def _drain(self) -> None:
+        while True:
+            try:
+                event = self._events.get_nowait()
+            except queue.Empty:
+                break
+            self.state.ingest(event)
+        self._render()
+
+    def _render(self) -> None:
+        state = self.state
+        health = "ok" if state.health_ok() else "DOWN"
+        status = (
+            f"[{state.mode_style()} bold]{state.mode}[/]  "
+            f"stt:{health}  worker_stall:{state.worker_stall_s:.1f}s  "
+            f"backlog:{state.queue_depth}  phase:{state.phase}"
+        )
+        self.query_one("#status", Static).update(status)
+
+        edited = " (edited)" if state.command_edited else ""
+        self.query_one("#command", Static).update(
+            f"command{edited}: {state.command_text or '<empty>'}"
+        )
+
+        transcript_log = self.query_one("#transcript", RichLog)
+        lines = state.transcript_lines()
+        while self._seen_transcripts < len(lines):
+            transcript_log.write(lines[self._seen_transcripts])
+            self._seen_transcripts += 1
+
+        chat_log = self.query_one("#chat", RichLog)
+        chat_lines = state.chat_lines()
+        while self._seen_chat < len(chat_lines):
+            chat_log.write(chat_lines[self._seen_chat])
+            self._seen_chat += 1
+
+    def _send(self, type_: str, **fields) -> None:
+        try:
+            self.client.send(type_, **fields)
+        except (OSError, RuntimeError) as e:
+            self.query_one("#status", Static).update(f"send failed: {e}")
+
+    def action_arm(self) -> None:
+        self._send("command_start")
+
+    def action_execute(self) -> None:
+        self._send("command_execute")
+
+    def action_cancel(self) -> None:
+        self._send("command_cancel")
+
+    def action_chat(self) -> None:
+        self._send("chat_toggle")
+
+    def action_edit(self) -> None:
+        edit = self.query_one("#edit", Input)
+        edit.value = self.state.command_text
+        edit.display = True
+        edit.focus()
+
+    def on_input_submitted(self, event: Input.Submitted) -> None:
+        self._send("command_edit", text=event.value)
+        edit = self.query_one("#edit", Input)
+        edit.display = False
+
+    def on_unmount(self) -> None:
+        self.client.close()
+
+
+def _dump(path: str, seconds: float) -> int:
+    """Headless: print the event stream for a while (verification / debugging)."""
+    client = ControlClient(path)
+    try:
+        client.connect()
+    except OSError as e:
+        print(f"error: cannot connect to {path}: {e}", file=sys.stderr)
+        return 1
+
+    state = ViewState()
+    client.subscribe(viewer=True)
+
+    def on_event(event):
+        state.ingest(event)
+        print(f"{event.get('type'):<12} {event}", flush=True)
+
+    client.start(on_event)
+    try:
+        time.sleep(seconds)
+    except KeyboardInterrupt:
+        pass
+    finally:
+        client.close()
+    print(f"mode={state.mode} stt_ok={state.stt_ok} transcripts={len(state.transcripts)}")
+    return 0
+
+
+def main() -> int:
+    """Entry point for `stt-tui`."""
+    parser = argparse.ArgumentParser(description="Assistant TUI (watch + drive)")
+    parser.add_argument("--config", default=None, help="Path to config.toml")
+    parser.add_argument("--socket", default=None, help="Override the control socket path")
+    parser.add_argument(
+        "--dump",
+        type=float,
+        default=None,
+        metavar="SECONDS",
+        help="Headless: print the event stream for N seconds and exit",
+    )
+    args = parser.parse_args()
+
+    path = args.socket
+    if path is None:
+        cfg = load_config(args.config)
+        path = cfg.assistant.control_socket or default_control_path()
+
+    if args.dump is not None:
+        return _dump(path, args.dump)
+
+    AssistantTUI(path).run()
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 86 - 0
src/stt_daemon/tui/client.py

@@ -0,0 +1,86 @@
+"""Control-socket client used by the TUI and the headless dump mode."""
+
+import json
+import logging
+import socket
+import threading
+from typing import Callable
+
+logger = logging.getLogger(__name__)
+
+READ_CHUNK = 65536
+
+
+class ControlClient:
+    """Connects to the assistant control socket, sends commands, reads events."""
+
+    def __init__(self, path: str):
+        self.path = path
+        self._sock: socket.socket | None = None
+        self._stop = threading.Event()
+        self._thread: threading.Thread | None = None
+
+    def connect(self) -> None:
+        """Connect to the control socket (raises OSError on failure)."""
+        sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+        sock.connect(self.path)
+        self._sock = sock
+
+    def send(self, type_: str, **fields) -> None:
+        """Send one command message."""
+        if self._sock is None:
+            raise RuntimeError("not connected")
+        payload = {"type": type_, **fields}
+        self._sock.sendall((json.dumps(payload) + "\n").encode("utf-8"))
+
+    def subscribe(self, viewer: bool = True) -> None:
+        """Subscribe as a viewer to receive broadcasts."""
+        self.send("subscribe", viewer=viewer)
+
+    def start(self, on_event: Callable[[dict], None]) -> None:
+        """Start the background reader, invoking `on_event` per message."""
+        self._thread = threading.Thread(
+            target=self._read_loop, args=(on_event,), name="tui-client", daemon=True
+        )
+        self._thread.start()
+
+    def _read_loop(self, on_event: Callable[[dict], None]) -> None:
+        sock = self._sock
+        if sock is None:
+            return
+        buffer = bytearray()
+        while not self._stop.is_set():
+            try:
+                chunk = sock.recv(READ_CHUNK)
+            except OSError:
+                break
+            if not chunk:
+                break
+            buffer.extend(chunk)
+            while True:
+                newline = buffer.find(b"\n")
+                if newline == -1:
+                    break
+                line = bytes(buffer[:newline])
+                del buffer[: newline + 1]
+                if not line.strip():
+                    continue
+                try:
+                    on_event(json.loads(line.decode("utf-8")))
+                except (UnicodeDecodeError, json.JSONDecodeError) as e:
+                    logger.debug("dropping malformed event: %s", e)
+
+    def close(self) -> None:
+        """Stop the reader and close the socket."""
+        self._stop.set()
+        sock = self._sock
+        self._sock = None
+        if sock is not None:
+            try:
+                sock.shutdown(socket.SHUT_RDWR)
+            except OSError:
+                pass
+            try:
+                sock.close()
+            except OSError:
+                pass

+ 90 - 0
src/stt_daemon/tui/state.py

@@ -0,0 +1,90 @@
+"""Pure reduction of assistant/STT events into TUI view state.
+
+Kept free of Textual so the behaviour is unit-testable: attach, mode
+self-correction from the heartbeat, command editing, bounded buffers.
+"""
+
+from dataclasses import dataclass, field
+
+MAX_TRANSCRIPTS = 200
+MAX_CHAT = 200
+
+MODE_STYLES = {
+    "idle": "green",
+    "command_armed": "yellow",
+    "executing": "cyan",
+    "chat": "magenta",
+}
+
+
+@dataclass
+class ViewState:
+    """Everything the TUI renders."""
+
+    mode: str = "idle"
+    stt_ok: bool = True
+    device_ok: bool = True
+    worker_stall_s: float = 0.0
+    queue_depth: int = 0
+    ready: bool = False
+    phase: str = "idle"
+    command_text: str = ""
+    command_edited: bool = False
+    in_flight: bool = False
+    transcripts: list[dict] = field(default_factory=list)
+    chat: list[dict] = field(default_factory=list)
+    tool: dict | None = None
+    tool_result: dict | None = None
+    last_error: dict | None = None
+
+    def ingest(self, event: dict) -> None:
+        """Fold one event into the view state."""
+        etype = event.get("type")
+
+        if etype == "mode":
+            self.mode = event.get("mode", self.mode)
+        elif etype == "status":
+            self.mode = event.get("mode", self.mode)
+            self.ready = bool(event.get("ready", self.ready))
+            self.phase = event.get("phase", self.phase)
+            self.command_text = event.get("command_text", self.command_text)
+            self.stt_ok = bool(event.get("stt_ok", self.stt_ok))
+        elif etype == "heartbeat":
+            # The heartbeat re-states the mode: a missed `mode` event self-corrects.
+            self.mode = event.get("mode", self.mode)
+            self.stt_ok = bool(event.get("stt_ok", self.stt_ok))
+            self.in_flight = bool(event.get("in_flight", self.in_flight))
+            self.worker_stall_s = float(event.get("worker_stall_s", self.worker_stall_s) or 0.0)
+            if "queue_depth" in event:
+                self.queue_depth = int(event["queue_depth"] or 0)
+            if "device_ok" in event:
+                self.device_ok = bool(event["device_ok"])
+        elif etype == "command_text":
+            self.command_text = event.get("text", "")
+            self.command_edited = bool(event.get("edited", False))
+            self.phase = event.get("phase", self.phase)
+        elif etype == "transcript":
+            self.transcripts.append(event)
+            del self.transcripts[:-MAX_TRANSCRIPTS]
+        elif etype == "chat_message":
+            self.chat.append(event)
+            del self.chat[:-MAX_CHAT]
+        elif etype == "tool_call":
+            self.tool = event
+        elif etype == "tool_result":
+            self.tool_result = event
+        elif etype == "error":
+            self.last_error = event
+
+    def health_ok(self) -> bool:
+        """False when the header should go red."""
+        return self.stt_ok and self.device_ok
+
+    def mode_style(self) -> str:
+        return MODE_STYLES.get(self.mode, "white")
+
+    def transcript_lines(self) -> list[str]:
+        return [f"[{e.get('lang', '?')}] {e.get('text', '')}" for e in self.transcripts]
+
+    def chat_lines(self) -> list[str]:
+        return [f"{m.get('role', '?')}: {m.get('text', '')}" for m in self.chat]

+ 88 - 0
tests/test_assistant_integration.py

@@ -0,0 +1,88 @@
+"""Integration: the assistant service consuming a real publish-only bus."""
+
+import os
+import shutil
+import tempfile
+import time
+
+import pytest
+
+from stt_daemon import events
+from stt_daemon.assistant.service import AssistantService
+from stt_daemon.bus import EventPublisher, EventSubscriber
+from stt_daemon.config import Config
+
+
+@pytest.fixture
+def sock_path():
+    directory = tempfile.mkdtemp(prefix="sttb-")
+    try:
+        yield os.path.join(directory, "s.sock")
+    finally:
+        shutil.rmtree(directory, ignore_errors=True)
+
+
+def _wait(predicate, timeout=5.0):
+    deadline = time.time() + timeout
+    while time.time() < deadline:
+        if predicate():
+            return True
+        time.sleep(0.01)
+    return predicate()
+
+
+def _heartbeat_fields():
+    return {
+        "in_flight": False,
+        "segments_done": 0,
+        "worker_stall_s": 0.0,
+        "queue_depth": 0,
+        "frames_since_last": 1,
+        "rms": 0.0,
+        "capture": "test",
+        "device_ok": True,
+        "model_loaded": True,
+        "uptime_s": 1.0,
+        "last_transcript_age_s": 0.0,
+    }
+
+
+def test_service_consumes_real_bus(sock_path):
+    cfg = Config()
+    publisher = EventPublisher(sock_path, ring_size=10)
+    publisher.start()
+
+    subscriber = EventSubscriber(sock_path, reconnect_delay=0.05)
+    emitted: list[dict] = []
+    service = AssistantService(
+        cfg,
+        subscriber,
+        emit=lambda type_, **fields: emitted.append({"type": type_, **fields}),
+    )
+    service.start()
+
+    try:
+        publisher.emit(events.HEARTBEAT, **_heartbeat_fields())
+        assert _wait(lambda: service.stt_ok() is True)
+
+        assert service.arm() is True
+        assert _wait(lambda: any(e.get("mode") == "command_armed" for e in emitted))
+
+        publisher.emit(events.SPEECH_START)
+        assert _wait(lambda: service.speech_open is True)
+
+        publisher.emit(events.SPEECH_STOP, seg_id=5)
+        assert _wait(lambda: service.awaited_seg_id == 5)
+        assert service.ready() is False
+
+        publisher.emit(events.SEGMENT_DONE, seg_id=5, emitted=True)
+        assert _wait(lambda: service.ready() is True)
+
+        publisher.emit(events.TRANSCRIPT, seg_id=5, text="hello", lang="en", lang_prob=0.9)
+        assert _wait(lambda: service.command_text == "hello")
+
+        assert service.execute() == "hello"
+        assert service.finish() is True
+    finally:
+        service.stop()
+        publisher.close()

+ 244 - 0
tests/test_assistant_service.py

@@ -0,0 +1,244 @@
+"""Tests for the assistant service (Phase 2) using synthetic event streams."""
+
+import itertools
+
+from stt_daemon import events
+from stt_daemon.assistant.service import AssistantService
+from stt_daemon.config import Config
+
+_sequence = itertools.count(1)
+
+
+class Clock:
+    def __init__(self, start=1000.0):
+        self.t = float(start)
+
+    def __call__(self):
+        return self.t
+
+    def advance(self, dt):
+        self.t += float(dt)
+
+
+class FakeSubscriber:
+    def events(self):
+        return iter(())
+
+    def close(self):
+        pass
+
+
+def _service():
+    cfg = Config()
+    clock = Clock()
+    emitted: list[dict] = []
+
+    def emit(type_, **fields):
+        emitted.append({"type": type_, **fields})
+
+    service = AssistantService(cfg, FakeSubscriber(), emit=emit, time_fn=clock)
+    return service, emitted, clock
+
+
+def _ev(type_, clock, **fields):
+    event = {"v": 1, "type": type_, "seq": next(_sequence), "ts": clock()}
+    event.update(fields)
+    return event
+
+
+def _transcript(clock, text, ts=None):
+    return {
+        "v": 1,
+        "type": events.TRANSCRIPT,
+        "seq": next(_sequence),
+        "ts": clock() if ts is None else ts,
+        "text": text,
+    }
+
+
+def test_ready_requires_matching_segment_done():
+    service, _, clock = _service()
+
+    service.handle_event(_ev(events.SPEECH_START, clock))
+    assert service.ready() is False
+
+    service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
+    assert service.ready() is False  # awaiting segment 1
+
+    service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
+    assert service.ready() is True
+
+
+def test_stale_segment_done_does_not_satisfy_later_turn():
+    service, _, clock = _service()
+
+    service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
+    service.handle_event(_ev(events.SPEECH_START, clock))
+    service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=2))
+
+    service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
+    assert service.ready() is False  # still awaiting segment 2
+
+    service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=2, emitted=True))
+    assert service.ready() is True
+
+
+def test_gated_segment_done_clears_readiness():
+    service, _, clock = _service()
+
+    service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=3))
+    service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=3, emitted=False))
+
+    assert service.ready() is True
+
+
+def test_status_resets_stale_speech_state_on_reconnect():
+    service, _, clock = _service()
+
+    service.handle_event(_ev(events.SPEECH_START, clock))
+    assert service.speech_open is True
+
+    service.handle_event(_ev(events.STATUS, clock, capture="x", segments_done=0))
+    assert service.speech_open is False
+    assert service.awaited_seg_id is None
+    assert service.ready() is True
+
+
+def test_command_buffer_uses_arm_timestamp():
+    service, _, clock = _service()
+    clock.advance(10)
+    assert service.arm() is True
+    arm_ts = service.arm_ts
+    assert arm_ts is not None
+
+    # Replayed transcript from before arming is ignored.
+    service.handle_event(_transcript(clock, "old", ts=arm_ts - 5))
+    assert service.command_text == ""
+
+    service.handle_event(_transcript(clock, "what time is it"))
+    assert service.command_text == "what time is it"
+
+
+def test_mutual_exclusion_between_command_and_chat():
+    service, _, _ = _service()
+
+    assert service.toggle_chat() is True
+    assert service.arm() is False  # cannot arm in chat
+
+    assert service.toggle_chat() is True  # back to idle
+    assert service.arm() is True
+    assert service.toggle_chat() is False  # cannot chat while armed
+
+
+def test_arm_timeout_disarms_and_emits_error():
+    service, emitted, clock = _service()
+    service.arm()
+
+    clock.advance(service.cfg.assistant.command_arm_timeout_s + 0.1)
+    service.tick()
+
+    assert service.modes.mode.value == "idle"
+    assert service.command_text == ""
+    assert any(e["type"] == "error" and e["code"] == "arm_timeout" for e in emitted)
+
+
+def test_stt_stall_cancels_armed_command():
+    service, emitted, clock = _service()
+    service.handle_event(_ev(events.HEARTBEAT, clock, worker_stall_s=0.0))
+    service.arm()
+
+    clock.advance(service.cfg.assistant.stall_timeout_ms / 1000.0 + 0.5)
+    service.tick()
+
+    assert service.modes.mode.value == "idle"
+    assert any(e["type"] == "error" and e["code"] == "stt_stall" for e in emitted)
+
+
+def test_worker_stall_marks_stt_degraded():
+    service, _, clock = _service()
+    long_stall = service.cfg.assistant.worker_stall_ms / 1000.0 + 1.0
+
+    service.handle_event(_ev(events.HEARTBEAT, clock, worker_stall_s=long_stall))
+    assert service.stt_ok() is False
+    assert service.ready() is False
+
+
+def test_chat_idle_timeout_exits_chat():
+    service, _, clock = _service()
+    assert service.toggle_chat() is True
+
+    clock.advance(service.cfg.assistant.chat_idle_timeout_s + 0.1)
+    service.tick()
+
+    assert service.modes.mode.value == "idle"
+
+
+def test_chat_exit_phrase_exits_chat():
+    service, _, clock = _service()
+    assert service.toggle_chat() is True
+
+    service.handle_event(_transcript(clock, "That's all."))
+
+    assert service.modes.mode.value == "idle"
+    assert service.chat_history == []
+
+
+def test_chat_accumulates_turns():
+    service, _, clock = _service()
+    service.toggle_chat()
+
+    for text in ("hello there", "how are you"):
+        service.handle_event(_transcript(clock, text))
+
+    assert service.chat_history == ["hello there", "how are you"]
+
+
+def test_execute_requires_readiness():
+    service, _, clock = _service()
+    service.arm()
+    service.handle_event(_transcript(clock, "do it"))
+
+    service.handle_event(_ev(events.SPEECH_START, clock))
+    assert service.execute() is None  # speech still open
+    assert service.modes.mode.value == "command_armed"
+
+    service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
+    assert service.execute() is None  # awaiting segment 1
+
+    service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
+    assert service.execute() == "do it"
+    assert service.modes.mode.value == "executing"
+
+    assert service.finish() is True
+    assert service.modes.mode.value == "idle"
+
+
+def test_turn_ended_requires_quiet_gap():
+    service, _, clock = _service()
+    service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
+    service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
+
+    assert service.ready() is True
+    assert service.turn_ended() is False
+
+    clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
+    assert service.turn_ended() is True
+
+
+def test_emits_mode_and_command_text():
+    service, emitted, clock = _service()
+    service.arm()
+    assert {"type": "mode", "mode": "command_armed"} in emitted
+
+    service.handle_event(_transcript(clock, "hi"))
+    assert any(e["type"] == "command_text" and e["text"] == "hi" for e in emitted)
+
+
+def test_cancel_clears_command_buffer():
+    service, _, clock = _service()
+    service.arm()
+    service.handle_event(_transcript(clock, "hi"))
+    assert service.cancel() is True
+    assert service.modes.mode.value == "idle"
+    assert service.command_text == ""
+    assert service.arm_ts is None

+ 42 - 0
tests/test_audit.py

@@ -0,0 +1,42 @@
+"""Tests for the daily action audit log."""
+
+import json
+
+from stt_daemon.assistant.audit import AuditLog
+
+
+def test_record_writes_daily_file(tmp_path):
+    log = AuditLog(enabled=True, directory=str(tmp_path))
+    log.record(mode="command", text="what time is it", tool="get_datetime", ok=True, result="12:00")
+
+    path = log.path_for_today()
+    assert path.exists()
+    record = json.loads(path.read_text().splitlines()[0])
+    assert record["mode"] == "command"
+    assert record["tool"] == "get_datetime"
+    assert record["ok"] is True
+    assert "ts" in record
+
+
+def test_disabled_writes_nothing(tmp_path):
+    log = AuditLog(enabled=False, directory=str(tmp_path))
+    log.record(mode="command")
+    assert list(tmp_path.iterdir()) == []
+
+
+def test_appends_one_line_per_record(tmp_path):
+    log = AuditLog(enabled=True, directory=str(tmp_path))
+    log.record(n=1)
+    log.record(n=2)
+
+    lines = log.path_for_today().read_text().splitlines()
+    assert len(lines) == 2
+    assert json.loads(lines[1])["n"] == 2
+
+
+def test_best_effort_never_raises(tmp_path):
+    blocker = tmp_path / "blocker"
+    blocker.write_text("not a directory")
+
+    log = AuditLog(enabled=True, directory=str(blocker / "sub"))
+    log.record(x=1)  # must not raise

+ 223 - 0
tests/test_bus.py

@@ -0,0 +1,223 @@
+"""Tests for the publish-only event bus and subscriber."""
+
+import os
+import shutil
+import socket
+import tempfile
+import threading
+import time
+
+import pytest
+
+from stt_daemon import events
+from stt_daemon.bus import EventPublisher, EventSubscriber
+
+
+@pytest.fixture
+def sock_path():
+    # Short path: AF_UNIX paths are limited to ~108 bytes.
+    directory = tempfile.mkdtemp(prefix="sttb-")
+    try:
+        yield os.path.join(directory, "s.sock")
+    finally:
+        shutil.rmtree(directory, ignore_errors=True)
+
+
+def _wait(predicate, timeout=3.0):
+    deadline = time.time() + timeout
+    while time.time() < deadline:
+        if predicate():
+            return True
+        time.sleep(0.01)
+    return predicate()
+
+
+class _Collector:
+    def __init__(self, subscriber):
+        self.events = []
+        self._thread = threading.Thread(target=self._run, args=(subscriber,), daemon=True)
+        self._thread.start()
+
+    def _run(self, subscriber):
+        try:
+            for event in subscriber.events():
+                self.events.append(event)
+        except Exception:  # noqa: BLE001 - collector must never crash the test thread
+            pass
+
+    def types(self):
+        return [e["type"] for e in self.events]
+
+    def wait_for(self, predicate, timeout=3.0):
+        return _wait(lambda: predicate(self.events), timeout)
+
+
+def _has(event_list, type_):
+    return any(e["type"] == type_ for e in event_list)
+
+
+def _count(event_list, type_):
+    return sum(1 for e in event_list if e["type"] == type_)
+
+
+def test_publish_subscribe_in_order(sock_path):
+    pub = EventPublisher(sock_path, ring_size=10)
+    pub.start()
+    sub = EventSubscriber(sock_path, reconnect_delay=0.05)
+    collector = _Collector(sub)
+
+    assert collector.wait_for(lambda e: _has(e, events.STATUS))
+
+    for _ in range(5):
+        pub.emit(events.SPEECH_START)
+
+    assert collector.wait_for(lambda e: _count(e, events.SPEECH_START) == 5)
+    seqs = [e["seq"] for e in collector.events if e["type"] == events.SPEECH_START]
+    assert seqs == sorted(seqs)
+
+    sub.close()
+    pub.close()
+
+
+def test_emit_assigns_monotonic_seq(sock_path):
+    pub = EventPublisher(sock_path, ring_size=10)
+    pub.start()
+    sub = EventSubscriber(sock_path, reconnect_delay=0.05)
+    collector = _Collector(sub)
+    assert collector.wait_for(lambda e: _has(e, events.STATUS))
+
+    for _ in range(3):
+        pub.emit(events.HEARTBEAT)
+    assert collector.wait_for(lambda e: _count(e, events.HEARTBEAT) == 3)
+
+    seqs = [e["seq"] for e in collector.events if e["type"] == events.HEARTBEAT]
+    assert seqs == sorted(seqs)
+    assert len(set(seqs)) == 3
+
+    sub.close()
+    pub.close()
+
+
+def test_ring_replay_on_attach(sock_path):
+    pub = EventPublisher(sock_path, ring_size=10)
+    pub.start()
+
+    for i in range(3):
+        pub.emit(events.TRANSCRIPT, text=f"t{i}")
+    assert _wait(lambda: pub.ring_len() == 3)
+
+    sub = EventSubscriber(sock_path, reconnect_delay=0.05)
+    collector = _Collector(sub)
+    assert collector.wait_for(lambda e: _has(e, events.RING_END))
+
+    types = collector.types()
+    assert types[0] == events.STATUS
+    ring_end_index = types.index(events.RING_END)
+    assert types[1:ring_end_index] == [events.TRANSCRIPT] * 3
+
+    sub.close()
+    pub.close()
+
+
+def test_two_subscribers_each_get_their_own_replay(sock_path):
+    pub = EventPublisher(sock_path, ring_size=10)
+    pub.start()
+    pub.emit(events.TRANSCRIPT, text="a")
+    assert _wait(lambda: pub.ring_len() == 1)
+
+    subs = [EventSubscriber(sock_path, reconnect_delay=0.05) for _ in range(2)]
+    collectors = [_Collector(s) for s in subs]
+
+    for collector in collectors:
+        assert collector.wait_for(lambda e: _has(e, events.RING_END))
+        types = collector.types()
+        assert types[0] == events.STATUS
+        assert types[1:types.index(events.RING_END)] == [events.TRANSCRIPT]
+
+    for sub in subs:
+        sub.close()
+    pub.close()
+
+
+def test_publisher_never_blocks_on_slow_subscriber(sock_path):
+    pub = EventPublisher(sock_path, ring_size=50)
+    pub.start()
+
+    # A raw client that connects and never reads: its writer blocks in sendall.
+    slow = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+    slow.connect(sock_path)
+    assert _wait(lambda: pub.client_count() == 1)
+
+    total = 20000
+    start = time.perf_counter()
+    for _ in range(total):
+        pub.emit(events.HEARTBEAT, rms=0.01)
+    elapsed = time.perf_counter() - start
+
+    assert elapsed < 2.0, f"publish path blocked: {elapsed:.3f}s for {total} events"
+
+    slow.close()
+    assert _wait(lambda: pub.client_count() == 0)
+
+    # The publisher is still healthy: a fresh subscriber attaches promptly.
+    sub = EventSubscriber(sock_path, reconnect_delay=0.05)
+    collector = _Collector(sub)
+    assert collector.wait_for(lambda e: _has(e, events.RING_END))
+
+    sub.close()
+    pub.close()
+
+
+def test_subscriber_reconnects_after_restart(sock_path):
+    pub = EventPublisher(sock_path, ring_size=10)
+    pub.start()
+    sub = EventSubscriber(sock_path, reconnect_delay=0.05)
+    collector = _Collector(sub)
+    assert collector.wait_for(lambda e: _has(e, events.STATUS))
+
+    pub.emit(events.TRANSCRIPT, text="one")
+    assert collector.wait_for(lambda e: _count(e, events.TRANSCRIPT) == 1)
+
+    pub.close()
+
+    pub2 = EventPublisher(sock_path, ring_size=10)
+    pub2.start()
+    assert collector.wait_for(lambda e: _count(e, events.STATUS) >= 2, timeout=5.0)
+
+    pub2.emit(events.TRANSCRIPT, text="two")
+    assert collector.wait_for(lambda e: _count(e, events.TRANSCRIPT) == 2, timeout=5.0)
+
+    sub.close()
+    pub2.close()
+
+
+def test_socket_is_private_and_removed_on_close(sock_path):
+    pub = EventPublisher(sock_path, ring_size=10)
+    pub.start()
+
+    assert os.path.exists(sock_path)
+    assert os.stat(sock_path).st_mode & 0o777 == 0o600
+
+    pub.close()
+    assert not os.path.exists(sock_path)
+
+
+def test_status_carries_provider_fields(sock_path):
+    pub = EventPublisher(
+        sock_path,
+        ring_size=10,
+        status_provider=lambda: {"capture": "VirtualMicSink.monitor", "segments_done": 7},
+    )
+    pub.start()
+
+    sub = EventSubscriber(sock_path, reconnect_delay=0.05)
+    collector = _Collector(sub)
+    assert collector.wait_for(lambda e: _has(e, events.STATUS))
+
+    status = next(e for e in collector.events if e["type"] == events.STATUS)
+    assert status["capture"] == "VirtualMicSink.monitor"
+    assert status["segments_done"] == 7
+    assert "ring_seq" in status
+
+    sub.close()
+    pub.close()

+ 266 - 0
tests/test_control.py

@@ -0,0 +1,266 @@
+"""Tests for the assistant control socket (Phase 3)."""
+
+import json
+import os
+import shutil
+import socket
+import tempfile
+import time
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+from stt_daemon.assistant.audit import AuditLog
+from stt_daemon.assistant.control import ControlServer
+from stt_daemon.assistant.executor import ExecutionResult
+from stt_daemon.assistant.service import AssistantService
+from stt_daemon.config import Config
+
+
+@pytest.fixture
+def sock_path():
+    directory = tempfile.mkdtemp(prefix="sttc-")
+    try:
+        yield os.path.join(directory, "c.sock")
+    finally:
+        shutil.rmtree(directory, ignore_errors=True)
+
+
+class FakeSubscriber:
+    def events(self):
+        return iter(())
+
+    def close(self):
+        pass
+
+
+class FakeExecutor:
+    def __init__(self):
+        self.calls = []
+
+    def run(self, command_text, emit):
+        self.calls.append(command_text)
+        emit("tool_call", tool="get_datetime", args={}, requires_confirmation=False)
+        return ExecutionResult(ok=True, result="2026-09-20T12:00:00", tool="get_datetime")
+
+
+class Client:
+    def __init__(self, path):
+        self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+        self.sock.connect(path)
+        self.sock.settimeout(0.2)
+        self.buffer = bytearray()
+        self.messages: list[dict] = []
+
+    def send(self, **message):
+        self.sock.sendall((json.dumps(message) + "\n").encode("utf-8"))
+
+    def read(self, timeout=0.2):
+        deadline = time.time() + timeout
+        while time.time() < deadline:
+            newline = self.buffer.find(b"\n")
+            if newline != -1:
+                line = bytes(self.buffer[:newline])
+                del self.buffer[: newline + 1]
+                return json.loads(line.decode("utf-8"))
+            try:
+                chunk = self.sock.recv(65536)
+            except socket.timeout:
+                continue
+            if not chunk:
+                return None
+            self.buffer.extend(chunk)
+        return None
+
+    def wait_for(self, predicate, timeout=5.0):
+        """Return the first message matching `predicate`, accumulating the rest."""
+        deadline = time.time() + timeout
+        while time.time() < deadline:
+            match = next((m for m in self.messages if predicate(m)), None)
+            if match is not None:
+                return match
+            message = self.read()
+            if message is not None:
+                self.messages.append(message)
+        return next((m for m in self.messages if predicate(m)), None)
+
+    def close(self):
+        try:
+            self.sock.close()
+        except OSError:
+            pass
+
+
+@pytest.fixture
+def harness(sock_path, tmp_path):
+    cfg = Config()
+    cfg.assistant.control_socket = sock_path
+    cfg.assistant.require_viewer = True
+
+    audit_dir = tmp_path / "audit"
+    audit = AuditLog(enabled=True, directory=str(audit_dir))
+    executor = FakeExecutor()
+    service = AssistantService(cfg, FakeSubscriber(), audit=audit, executor=executor)
+    server = ControlServer(cfg, service, path=sock_path)
+    service.set_sinks(emit=server.broadcast, relay=server.relay)
+    server.start()
+
+    yield SimpleNamespace(
+        cfg=cfg,
+        service=service,
+        server=server,
+        audit_dir=audit_dir,
+        path=sock_path,
+        executor=executor,
+    )
+
+    server.close()
+    service.stop()
+
+
+def _transcript(text, ts=None):
+    return {
+        "v": 1,
+        "type": "transcript",
+        "seq": 99,
+        "ts": time.time() if ts is None else ts,
+        "text": text,
+    }
+
+
+def test_subscribe_returns_status_and_counts_viewer(harness):
+    client = Client(harness.path)
+    client.send(type="subscribe", viewer=True)
+
+    status = client.wait_for(lambda m: m["type"] == "status")
+    assert status is not None
+    assert status["mode"] == "idle"
+    assert status["ready"] is True
+    assert harness.server.viewer_count() == 1
+
+    client.close()
+
+
+def test_command_start_flips_mode_and_broadcasts(harness):
+    client = Client(harness.path)
+    client.send(type="subscribe", viewer=True)
+    assert client.wait_for(lambda m: m["type"] == "status") is not None
+
+    client.send(type="command_start")
+    assert client.wait_for(lambda m: m["type"] == "ack" and m["command"] == "command_start")
+    assert client.wait_for(lambda m: m["type"] == "mode" and m["mode"] == "command_armed")
+    assert harness.service.modes.mode.value == "command_armed"
+
+    client.close()
+
+
+def test_command_execute_refused_without_viewer(harness):
+    client = Client(harness.path)  # connected but not subscribed as a viewer
+
+    client.send(type="command_execute")
+    error = client.wait_for(lambda m: m["type"] == "error")
+    assert error is not None
+    assert error["code"] == "no_viewer"
+    assert harness.executor.calls == []
+
+    client.close()
+
+
+def test_command_execute_runs_executor_and_writes_audit(harness):
+    client = Client(harness.path)
+    client.send(type="subscribe", viewer=True)
+    assert client.wait_for(lambda m: m["type"] == "status") is not None
+
+    client.send(type="command_start")
+    assert client.wait_for(lambda m: m["type"] == "ack" and m["command"] == "command_start")
+
+    service = harness.service
+    service.handle_event({"v": 1, "type": "speech_stop", "seq": 1, "ts": time.time(), "seg_id": 1})
+    service.handle_event(
+        {"v": 1, "type": "segment_done", "seq": 2, "ts": time.time(), "seg_id": 1, "emitted": True}
+    )
+    service.handle_event(_transcript("what time is it"))
+    assert service.ready() is True
+
+    client.send(type="command_execute")
+    assert client.wait_for(lambda m: m["type"] == "ack" and m["command"] == "command_execute")
+
+    result = client.wait_for(lambda m: m["type"] == "tool_result")
+    assert result is not None
+    assert result["ok"] is True
+    assert result["tool"] == "get_datetime"
+    assert harness.executor.calls == ["what time is it"]
+
+    files = list(harness.audit_dir.glob("*.jsonl"))
+    assert len(files) == 1
+    record = json.loads(files[0].read_text().splitlines()[0])
+    assert record["tool"] == "get_datetime"
+    assert record["ok"] is True
+    assert record["text"] == "what time is it"
+
+    client.close()
+
+
+def test_command_edit_updates_buffer(harness):
+    client = Client(harness.path)
+    client.send(type="subscribe", viewer=True)
+    assert client.wait_for(lambda m: m["type"] == "status") is not None
+
+    client.send(type="command_start")
+    assert client.wait_for(lambda m: m["type"] == "ack")
+
+    client.send(type="command_edit", text="corrected text")
+    assert client.wait_for(lambda m: m["type"] == "command_text" and m["text"] == "corrected text")
+    assert harness.service.command_text == "corrected text"
+    assert harness.service.command_edited is True
+
+    client.close()
+
+
+def test_chat_toggle_broadcasts_mode(harness):
+    client = Client(harness.path)
+    client.send(type="subscribe", viewer=True)
+    assert client.wait_for(lambda m: m["type"] == "status") is not None
+
+    client.send(type="chat_toggle")
+    assert client.wait_for(lambda m: m["type"] == "mode" and m["mode"] == "chat")
+
+    client.close()
+
+
+def test_status_reply_contains_fields(harness):
+    client = Client(harness.path)
+    client.send(type="status")
+
+    status = client.wait_for(lambda m: m["type"] == "status")
+    assert status is not None
+    for field in ("mode", "ready", "stt_ok", "phase", "command_text", "speech_open", "chat_turns"):
+        assert field in status
+
+    client.close()
+
+
+def test_unknown_command_is_rejected(harness):
+    client = Client(harness.path)
+    client.send(type="bogus")
+
+    error = client.wait_for(lambda m: m["type"] == "error")
+    assert error is not None
+    assert error["code"] == "unknown_command"
+
+    client.close()
+
+
+def test_control_socket_is_private_and_removed(tmp_path):
+    cfg = Config()
+    path = str(Path(tempfile.mkdtemp(prefix="sttc-")) / "c.sock")
+    service = AssistantService(cfg, FakeSubscriber())
+    server = ControlServer(cfg, service, path=path)
+    server.start()
+
+    assert os.stat(path).st_mode & 0o777 == 0o600
+
+    server.close()
+    assert not os.path.exists(path)
+    service.stop()

+ 33 - 0
tests/test_ctl.py

@@ -0,0 +1,33 @@
+"""Tests for the stt-ctl command mapping."""
+
+import pytest
+
+from stt_daemon.ctl import build_message
+
+
+@pytest.mark.parametrize(
+    ("verb", "expected"),
+    [
+        ("command-start", "command_start"),
+        ("command-execute", "command_execute"),
+        ("command-cancel", "command_cancel"),
+        ("command-edit", "command_edit"),
+        ("chat-toggle", "chat_toggle"),
+        ("status", "status"),
+    ],
+)
+def test_build_message_maps_verbs(verb, expected):
+    assert build_message(verb) == {"type": expected}
+
+
+def test_build_message_includes_text_only_when_given():
+    assert build_message("command-edit", "hello") == {
+        "type": "command_edit",
+        "text": "hello",
+    }
+    assert "text" not in build_message("command-start")
+
+
+def test_build_message_rejects_unknown():
+    with pytest.raises(ValueError):
+        build_message("nope")

+ 324 - 0
tests/test_daemon_events.py

@@ -0,0 +1,324 @@
+"""Tests for the daemon's publish-only event stream (Phase 1)."""
+
+import logging
+import os
+import shutil
+import socket
+import tempfile
+import threading
+import time
+
+import numpy as np
+import pytest
+
+from stt_daemon import events
+from stt_daemon.bus import EventSubscriber
+from stt_daemon.config import Config
+from stt_daemon.daemon import Daemon
+from stt_daemon.transcriber import TranscriptResult
+
+FRAME_SAMPLES = 480
+FRAME_BYTES = FRAME_SAMPLES * 2
+
+
+@pytest.fixture
+def sock_path():
+    directory = tempfile.mkdtemp(prefix="sttb-")
+    try:
+        yield os.path.join(directory, "s.sock")
+    finally:
+        shutil.rmtree(directory, ignore_errors=True)
+
+
+def _frame(value=0):
+    return np.full(FRAME_SAMPLES, value, dtype=np.int16).tobytes()
+
+
+class FakeVad:
+    def __init__(self, decisions):
+        self._decisions = list(decisions)
+        self._index = 0
+
+    def is_speech(self, frame, sample_rate):
+        if self._index < len(self._decisions):
+            decision = self._decisions[self._index]
+            self._index += 1
+            return decision
+        return False
+
+
+class FakeCapture:
+    def __init__(self, frames, delay=0.0, stall_after=None, healthy=True):
+        self._frames = list(frames)
+        self._delay = delay
+        self._stall_after = stall_after
+        self._healthy = healthy
+        self._closed = threading.Event()
+        self.frame_bytes = FRAME_BYTES
+
+    def frames(self):
+        for index, frame in enumerate(self._frames):
+            if self._closed.is_set():
+                return
+            if self._delay:
+                time.sleep(self._delay)
+            yield frame
+            if self._stall_after is not None and index + 1 >= self._stall_after:
+                self._closed.wait()
+                return
+
+    def healthy(self):
+        return self._healthy
+
+    def close(self):
+        self._closed.set()
+
+
+class FakeTranscriber:
+    def __init__(self, text="hello", language="en", probability=0.99):
+        self.text = text
+        self.language = language
+        self.probability = probability
+
+    def transcribe(self, audio):
+        return TranscriptResult(
+            language=self.language,
+            language_probability=self.probability,
+            text=self.text,
+        )
+
+    def close(self):
+        pass
+
+
+class FakeSink:
+    def __init__(self):
+        self.emitted = []
+
+    def emit(self, result, duration_s, rms=0.0, reason="unknown", audio=None):
+        if not result.text:
+            return None
+        self.emitted.append(result.text)
+        return None
+
+    def close(self):
+        pass
+
+
+class _Collector:
+    def __init__(self, subscriber):
+        self.events = []
+        self._thread = threading.Thread(target=self._run, args=(subscriber,), daemon=True)
+        self._thread.start()
+
+    def _run(self, subscriber):
+        try:
+            for event in subscriber.events():
+                self.events.append(event)
+        except Exception:  # noqa: BLE001 - collector must not crash the test thread
+            pass
+
+    def types(self):
+        return [e["type"] for e in self.events]
+
+    def of(self, type_):
+        return [e for e in self.events if e["type"] == type_]
+
+    def wait_for(self, predicate, timeout=5.0):
+        deadline = time.time() + timeout
+        while time.time() < deadline:
+            if predicate(self.events):
+                return True
+            time.sleep(0.01)
+        return predicate(self.events)
+
+
+def _base_config(sock_path):
+    cfg = Config()
+    cfg.events.socket = sock_path
+    cfg.events.heartbeat_ms = 50
+    cfg.audio.frame_ms = 30
+    cfg.vad.pre_roll_ms = 60
+    cfg.vad.speech_start_ms = 30
+    cfg.vad.silence_ms = 60
+    cfg.vad.min_utterance_ms = 30
+    cfg.vad.chunk_pause_ms = 300
+    cfg.vad.soft_max_s = 10.0
+    cfg.vad.max_utterance_s = 30.0
+    cfg.vad.rms_floor = 0.0
+    cfg.daemon.queue_size = 50
+    cfg.output.stdout = False
+    cfg.output.jsonl_dir = None
+    cfg.output.save_audio = False
+    return cfg
+
+
+def _start_daemon(cfg, capture, vad, transcriber):
+    sink = FakeSink()
+    daemon = Daemon(
+        cfg,
+        log_level=logging.WARNING,
+        capture_factory=lambda: capture,
+        vad_factory=lambda _aggr: vad,
+        transcriber_factory=lambda: transcriber,
+        sink_factory=lambda: sink,
+    )
+    daemon._initialize()
+    subscriber = EventSubscriber(cfg.events.socket, reconnect_delay=0.05)
+    collector = _Collector(subscriber)
+    daemon._start_workers()
+    return daemon, collector, subscriber, sink
+
+
+def test_transcript_and_segment_done_share_seg_id(sock_path):
+    cfg = _base_config(sock_path)
+    frames = [_frame() for _ in range(5)]
+    vad = FakeVad([False, True, True, False, False])
+    transcriber = FakeTranscriber(text="hello")
+    daemon, collector, subscriber, _ = _start_daemon(cfg, FakeCapture(frames), vad, transcriber)
+
+    collector.wait_for(lambda e: any(x["type"] == events.SEGMENT_DONE for x in e))
+
+    starts = collector.of(events.SPEECH_START)
+    stops = collector.of(events.SPEECH_STOP)
+    transcripts = collector.of(events.TRANSCRIPT)
+    dones = collector.of(events.SEGMENT_DONE)
+
+    assert len(starts) == 1
+    assert "seg_id" not in starts[0]  # speech_start carries no seg_id
+
+    assert len(stops) == 1
+    assert len(transcripts) == 1
+    assert len(dones) == 1
+    assert stops[0]["seg_id"] == transcripts[0]["seg_id"] == dones[0]["seg_id"] == 1
+    assert transcripts[0]["text"] == "hello"
+    assert dones[0]["emitted"] is True
+    assert dones[0]["end_reason"] == "end_of_turn"
+
+    subscriber.close()
+    daemon.stop()
+
+
+def test_gated_segment_reports_emitted_false(sock_path):
+    cfg = _base_config(sock_path)
+    frames = [_frame() for _ in range(5)]
+    vad = FakeVad([False, True, True, False, False])
+    transcriber = FakeTranscriber(text="")  # gated / no text
+    daemon, collector, subscriber, _ = _start_daemon(cfg, FakeCapture(frames), vad, transcriber)
+
+    collector.wait_for(lambda e: any(x["type"] == events.SEGMENT_DONE for x in e))
+
+    assert collector.of(events.TRANSCRIPT) == []
+    dones = collector.of(events.SEGMENT_DONE)
+    assert len(dones) == 1
+    assert dones[0]["emitted"] is False
+    assert len(collector.of(events.SPEECH_STOP)) == 1
+
+    subscriber.close()
+    daemon.stop()
+
+
+def test_chunked_utterance_yields_one_pair_per_chunk(sock_path):
+    cfg = _base_config(sock_path)
+    cfg.vad.soft_max_s = 0.3  # 10 frames
+    cfg.vad.chunk_pause_ms = 60  # 2 frames
+    cfg.vad.silence_ms = 600  # 20 frames (never reached here)
+    frames = [_frame() for _ in range(47)]
+    decisions = [False] + [True] * 12 + [False] * 2 + [True] * 12 + [False] * 20
+    transient = FakeTranscriber(text="chunk")
+    daemon, collector, subscriber, _ = _start_daemon(
+        cfg, FakeCapture(frames), FakeVad(decisions), transient
+    )
+
+    collector.wait_for(
+        lambda e: len([x for x in e if x["type"] == events.SEGMENT_DONE]) >= 2
+    )
+
+    stops = collector.of(events.SPEECH_STOP)
+    dones = collector.of(events.SEGMENT_DONE)
+    assert len(stops) == len(dones) >= 2
+
+    stop_ids = [s["seg_id"] for s in stops]
+    done_ids = [d["seg_id"] for d in dones]
+    assert stop_ids == sorted(stop_ids)
+    assert len(set(stop_ids)) == len(stop_ids)  # unique per chunk
+    assert set(stop_ids) == set(done_ids)
+    assert all(d["end_reason"] == "pause" for d in dones)
+
+    subscriber.close()
+    daemon.stop()
+
+
+def test_heartbeats_advance_and_carry_health(sock_path):
+    cfg = _base_config(sock_path)
+    cfg.events.heartbeat_ms = 40
+    frames = [_frame() for _ in range(40)]
+    daemon, collector, subscriber, _ = _start_daemon(
+        cfg, FakeCapture(frames, delay=0.02), FakeVad([]), FakeTranscriber()
+    )
+
+    collector.wait_for(lambda e: len([x for x in e if x["type"] == events.HEARTBEAT]) >= 3)
+    heartbeats = collector.of(events.HEARTBEAT)
+
+    assert len(heartbeats) >= 3
+    seqs = [h["seq"] for h in heartbeats]
+    assert seqs == sorted(seqs)
+    assert len(set(seqs)) == len(seqs)
+
+    last = heartbeats[-1]
+    assert last["model_loaded"] is True
+    assert last["device_ok"] is True
+    assert last["capture"] == cfg.audio.device
+    for field in ("in_flight", "segments_done", "worker_stall_s", "queue_depth",
+                  "frames_since_last", "rms", "uptime_s", "last_transcript_age_s"):
+        assert field in last
+
+    subscriber.close()
+    daemon.stop()
+
+
+def test_stalled_capture_stops_heartbeats(sock_path):
+    cfg = _base_config(sock_path)
+    cfg.events.heartbeat_ms = 60
+    frames = [_frame() for _ in range(30)]
+    capture = FakeCapture(frames, delay=0.01, stall_after=10)
+    daemon, collector, subscriber, _ = _start_daemon(
+        cfg, capture, FakeVad([]), FakeTranscriber()
+    )
+
+    assert collector.wait_for(
+        lambda e: any(x["type"] == events.HEARTBEAT for x in e), timeout=3.0
+    )
+    time.sleep(0.2)  # let it stall
+    before = len(collector.of(events.HEARTBEAT))
+
+    time.sleep(0.45)  # ~7 heartbeat intervals at 60ms
+    after = len(collector.of(events.HEARTBEAT))
+    assert after == before, f"heartbeats continued during a stall ({before} -> {after})"
+
+    subscriber.close()
+    daemon.stop()
+
+
+def test_inbound_data_on_subscriber_socket_is_ignored(sock_path):
+    cfg = _base_config(sock_path)
+    cfg.events.heartbeat_ms = 40
+    frames = [_frame() for _ in range(60)]
+    daemon, collector, subscriber, _ = _start_daemon(
+        cfg, FakeCapture(frames, delay=0.02), FakeVad([]), FakeTranscriber()
+    )
+
+    # The bus is publish-only: a client writing data must have no effect.
+    raw = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+    raw.connect(sock_path)
+    raw.sendall(b'{"v":1,"type":"evil","seq":1,"ts":0}\n')
+    time.sleep(0.1)
+
+    assert collector.wait_for(
+        lambda e: len([x for x in e if x["type"] == events.HEARTBEAT]) >= 2
+    )
+    assert all(x["type"] != "evil" for x in collector.events)
+
+    raw.close()
+    subscriber.close()
+    daemon.stop()

+ 76 - 0
tests/test_events.py

@@ -0,0 +1,76 @@
+"""Tests for the NDJSON event envelope and codec."""
+
+import json
+
+import pytest
+
+from stt_daemon import events
+
+
+def test_make_event_envelope():
+    event = events.make_event(events.HEARTBEAT, 7, uptime_s=12.5)
+    assert event["v"] == events.PROTOCOL_VERSION
+    assert event["type"] == events.HEARTBEAT
+    assert event["seq"] == 7
+    assert isinstance(event["ts"], float)
+    assert event["uptime_s"] == 12.5
+
+
+def test_make_event_explicit_timestamp():
+    event = events.make_event(events.STATUS, 1, ts=1000.5)
+    assert event["ts"] == 1000.5
+
+
+@pytest.mark.parametrize("type_", sorted(events.ALL_TYPES))
+def test_round_trip_every_type(type_):
+    event = events.make_event(type_, 42, payload={"á": "ñ"}, n=1)
+    line = events.encode(event)
+    assert line.endswith(b"\n")
+    assert events.decode(line) == event
+
+
+def test_encode_is_utf8_not_escaped():
+    line = events.encode(events.make_event(events.TRANSCRIPT, 1, text="café"))
+    assert "café".encode("utf-8") in line
+
+
+def test_decode_accepts_str_and_bytes():
+    event = events.make_event(events.SPEECH_START, 3)
+    assert events.decode(events.encode(event)) == event
+    assert events.decode(events.encode(event).decode("utf-8")) == event
+
+
+@pytest.mark.parametrize(
+    "bad",
+    [
+        b"not json\n",
+        b"[1,2,3]\n",
+        b'"a string"\n',
+        b"42\n",
+        b'{"type":"heartbeat","seq":1,"ts":1.0}\n',  # missing v
+        b'{"v":1,"seq":1,"ts":1.0}\n',  # missing type
+        b'{"v":1,"type":"heartbeat","ts":1.0}\n',  # missing seq
+        b'{"v":1,"type":"heartbeat","seq":1}\n',  # missing ts
+        b"\xff\xfe\n",  # invalid utf-8
+    ],
+)
+def test_decode_rejects_malformed(bad):
+    with pytest.raises(events.DecodeError):
+        events.decode(bad)
+
+
+def test_decode_rejects_wrong_argument_type():
+    with pytest.raises(events.DecodeError):
+        events.decode(json.dumps([]).encode() + b"\n")  # object required
+
+
+def test_all_types_is_complete():
+    assert events.ALL_TYPES == {
+        events.TRANSCRIPT,
+        events.SEGMENT_DONE,
+        events.SPEECH_START,
+        events.SPEECH_STOP,
+        events.HEARTBEAT,
+        events.STATUS,
+        events.RING_END,
+    }

+ 91 - 0
tests/test_executor.py

@@ -0,0 +1,91 @@
+"""Tests for the LLM executor's tool-calling loop."""
+
+from stt_daemon.assistant.executor import LlmExecutor
+from stt_daemon.assistant.llm import LlmResponse, ToolCall
+from stt_daemon.assistant.tools import default_registry
+from stt_daemon.config import LlmConfig
+
+
+class FakeClient:
+    def __init__(self, responses):
+        self.responses = list(responses)
+        self.calls: list[dict] = []
+
+    def chat(self, messages, tools=None):
+        self.calls.append({"messages": messages, "tools": tools})
+        return self.responses.pop(0)
+
+
+def _executor(responses, cfg=None):
+    cfg = cfg or LlmConfig(max_tool_iterations=3)
+    client = FakeClient(responses)
+    return LlmExecutor(cfg, client, default_registry()), client
+
+
+def _collector():
+    emitted: list[dict] = []
+    return emitted, (lambda type_, **fields: emitted.append({"type": type_, **fields}))
+
+
+def test_tool_call_then_final_answer():
+    call = ToolCall(id="1", name="get_datetime", arguments="{}")
+    executor, client = _executor(
+        [
+            LlmResponse(content="", tool_calls=[call], finish_reason="tool_calls"),
+            LlmResponse(content="It is 12:00."),
+        ]
+    )
+    emitted, emit = _collector()
+    result = executor.run("what time is it", emit)
+
+    assert result.ok is True
+    assert result.tool == "get_datetime"
+    assert result.result == "It is 12:00."
+    assert any(e["type"] == "tool_call" and e["tool"] == "get_datetime" for e in emitted)
+    assert client.calls[1]["messages"][-1]["role"] == "tool"
+
+
+def test_no_tool_call_returns_answer():
+    executor, _ = _executor([LlmResponse(content="Just an answer.")])
+    result = executor.run("hello", lambda *a, **k: None)
+    assert result.ok is True
+    assert result.result == "Just an answer."
+
+
+def test_unknown_tool_is_rejected():
+    call = ToolCall(id="1", name="nope", arguments="{}")
+    executor, _ = _executor(
+        [LlmResponse(content="", tool_calls=[call], finish_reason="tool_calls")]
+    )
+    emitted, emit = _collector()
+    result = executor.run("do it", emit)
+
+    assert result.ok is False
+    assert any(e["type"] == "error" and e["code"] == "invalid_tool_call" for e in emitted)
+
+
+def test_malformed_arguments_are_rejected():
+    call = ToolCall(id="1", name="get_datetime", arguments="{not json")
+    executor, _ = _executor(
+        [LlmResponse(content="", tool_calls=[call], finish_reason="tool_calls")]
+    )
+    emitted, emit = _collector()
+    result = executor.run("do it", emit)
+
+    assert result.ok is False
+    assert any(e["type"] == "error" and e["code"] == "invalid_tool_call" for e in emitted)
+
+
+def test_tool_loop_is_capped():
+    call = ToolCall(id="1", name="get_datetime", arguments="{}")
+    responses = [
+        LlmResponse(content="", tool_calls=[call], finish_reason="tool_calls")
+        for _ in range(10)
+    ]
+    executor, client = _executor(responses, cfg=LlmConfig(max_tool_iterations=3))
+    emitted, emit = _collector()
+    result = executor.run("keep going", emit)
+
+    assert result.ok is False
+    assert any(e["type"] == "error" and e["code"] == "tool_loop" for e in emitted)
+    assert len(client.calls) == 3

+ 121 - 0
tests/test_llm.py

@@ -0,0 +1,121 @@
+"""Tests for the LLM client and chat session (no network)."""
+
+import pytest
+
+from stt_daemon.assistant.llm import ChatSession, LlmClient, LlmError
+from stt_daemon.config import LlmConfig
+
+
+def _make_client(responder, cfg=None):
+    cfg = cfg or LlmConfig()
+    calls: list[dict] = []
+
+    def opener(payload, timeout):
+        calls.append(payload)
+        return responder(payload)
+
+    return LlmClient(cfg, opener=opener), calls
+
+
+def _reply(content="", reasoning=None, tool_calls=None, finish="stop"):
+    message = {"role": "assistant", "content": content}
+    if reasoning is not None:
+        message["reasoning_content"] = reasoning
+    if tool_calls:
+        message["tool_calls"] = tool_calls
+    return {"choices": [{"message": message, "finish_reason": finish}]}
+
+
+def test_reasoning_content_is_ignored():
+    client, _ = _make_client(
+        lambda _p: _reply(content="It is noon.", reasoning="thinking hard about it")
+    )
+    response = client.chat([{"role": "user", "content": "hi"}])
+    assert response.content == "It is noon."
+    assert "thinking hard" not in response.content
+
+
+def test_cache_prompt_and_endpoint_are_sent():
+    client, calls = _make_client(lambda _p: _reply(content="ok"))
+    client.chat([{"role": "user", "content": "hi"}])
+
+    assert calls[0]["body"]["cache_prompt"] is True
+    assert calls[0]["body"]["stream"] is False
+    assert calls[0]["__url__"] == LlmConfig().endpoint
+
+
+def test_tools_sent_only_when_provided():
+    client, calls = _make_client(lambda _p: _reply(content="ok"))
+    client.chat([{"role": "user", "content": "hi"}])
+    assert "tools" not in calls[0]["body"]
+
+    client.chat(
+        [{"role": "user", "content": "hi"}],
+        tools=[{"type": "function", "function": {"name": "get_datetime"}}],
+    )
+    assert calls[1]["body"]["tools"][0]["function"]["name"] == "get_datetime"
+    assert calls[1]["body"]["tool_choice"] == "auto"
+
+
+def test_tool_calls_are_parsed():
+    client, _ = _make_client(
+        lambda _p: _reply(
+            tool_calls=[
+                {
+                    "id": "1",
+                    "type": "function",
+                    "function": {"name": "get_datetime", "arguments": "{}"},
+                }
+            ],
+            finish="tool_calls",
+        )
+    )
+    response = client.chat([{"role": "user", "content": "time?"}], tools=[{"type": "function"}])
+    assert len(response.tool_calls) == 1
+    assert response.tool_calls[0].name == "get_datetime"
+    assert response.finish_reason == "tool_calls"
+
+
+def test_missing_choices_raises():
+    client, _ = _make_client(lambda _p: {"choices": []})
+    with pytest.raises(LlmError):
+        client.chat([{"role": "user", "content": "hi"}])
+
+
+def test_chat_session_appends_and_keeps_prefix():
+    client, calls = _make_client(lambda _p: _reply(content="hello"))
+    session = ChatSession(LlmConfig(), client)
+
+    assert session.send("hi") == "hello"
+    session.send("again")
+
+    assert [m["role"] for m in session.history] == [
+        "user",
+        "assistant",
+        "user",
+        "assistant",
+    ]
+    first = calls[0]["body"]["messages"]
+    second = calls[1]["body"]["messages"]
+    assert second[: len(first)] == first  # append-only, prefix preserved
+    assert second[0]["content"] == LlmConfig().system_prompt_chat
+
+
+def test_chat_session_trims_in_blocks_and_keeps_system_prompt():
+    cfg = LlmConfig(max_prompt_tokens=50, trim_block_turns=1)
+    client, calls = _make_client(lambda _p: _reply(content="ok"))
+    session = ChatSession(cfg, client)
+
+    for _ in range(10):
+        session.send("x" * 40)
+
+    assert len(session.history) <= 6
+    assert calls[-1]["body"]["messages"][0]["content"] == cfg.system_prompt_chat
+
+
+def test_chat_session_reset_forgets_history():
+    client, _ = _make_client(lambda _p: _reply(content="ok"))
+    session = ChatSession(LlmConfig(), client)
+    session.send("hi")
+    session.reset()
+    assert session.history == []

+ 71 - 0
tests/test_modes.py

@@ -0,0 +1,71 @@
+"""Tests for the assistant mode state machine."""
+
+from stt_daemon.assistant.modes import Mode, ModeMachine
+
+
+def test_initial_mode_is_idle():
+    assert ModeMachine().mode is Mode.IDLE
+
+
+def test_arm_execute_finish_cycle():
+    machine = ModeMachine()
+    assert machine.arm() is True
+    assert machine.mode is Mode.COMMAND_ARMED
+    assert machine.execute() is True
+    assert machine.mode is Mode.EXECUTING
+    assert machine.finish() is True
+    assert machine.mode is Mode.IDLE
+
+
+def test_cancel_from_armed_returns_to_idle():
+    machine = ModeMachine()
+    machine.arm()
+    assert machine.cancel() is True
+    assert machine.mode is Mode.IDLE
+
+
+def test_chat_enter_exit_cycle():
+    machine = ModeMachine()
+    assert machine.enter_chat() is True
+    assert machine.mode is Mode.CHAT
+    assert machine.exit_chat() is True
+    assert machine.mode is Mode.IDLE
+
+
+def test_cannot_enter_chat_while_command_active():
+    machine = ModeMachine()
+    machine.arm()
+    assert machine.enter_chat() is False
+    assert machine.mode is Mode.COMMAND_ARMED
+    machine.execute()
+    assert machine.enter_chat() is False
+    assert machine.mode is Mode.EXECUTING
+
+
+def test_cannot_arm_while_in_chat():
+    machine = ModeMachine()
+    machine.enter_chat()
+    assert machine.arm() is False
+    assert machine.mode is Mode.CHAT
+
+
+def test_refusals_leave_mode_unchanged():
+    machine = ModeMachine()
+    assert machine.execute() is False
+    assert machine.cancel() is False
+    assert machine.finish() is False
+    assert machine.exit_chat() is False
+    assert machine.mode is Mode.IDLE
+
+
+def test_command_active_property():
+    machine = ModeMachine()
+    assert machine.command_active is False
+    machine.arm()
+    assert machine.command_active is True
+    machine.execute()
+    assert machine.command_active is True
+    machine.finish()
+    assert machine.command_active is False
+    machine.enter_chat()
+    assert machine.command_active is False

+ 71 - 0
tests/test_tools.py

@@ -0,0 +1,71 @@
+"""Tests for the tool registry and argument validation."""
+
+from stt_daemon.assistant.tools import Tool, ToolRegistry, default_registry
+
+
+def test_default_registry_exposes_get_datetime():
+    registry = default_registry()
+    tool = registry.get("get_datetime")
+    assert tool is not None
+    assert tool.side_effect == "read_only"
+
+    spec = registry.specs()[0]
+    assert spec["type"] == "function"
+    assert spec["function"]["name"] == "get_datetime"
+
+
+def test_get_datetime_returns_iso_timestamp():
+    tool = default_registry().get("get_datetime")
+    assert tool is not None
+    assert "T" in tool.run({})
+
+
+def test_validate_rejects_unknown_tool():
+    args, error = default_registry().validate("nope", "{}")
+    assert args is None
+    assert error is not None and "unknown tool" in error
+
+
+def test_validate_rejects_malformed_json():
+    args, error = default_registry().validate("get_datetime", "{not json")
+    assert args is None
+    assert error is not None and "invalid JSON" in error
+
+
+def test_validate_rejects_non_object_arguments():
+    args, error = default_registry().validate("get_datetime", "[1, 2]")
+    assert args is None
+    assert error is not None and "object" in error
+
+
+def test_validate_accepts_empty_object():
+    args, error = default_registry().validate("get_datetime", "{}")
+    assert args == {}
+    assert error is None
+
+
+def test_schema_requires_checks_unknown_and_types():
+    registry = ToolRegistry()
+    registry.register(
+        Tool(
+            name="set_volume",
+            description="Set the volume",
+            parameters={
+                "type": "object",
+                "properties": {"level": {"type": "integer"}},
+                "required": ["level"],
+            },
+            run=lambda args: "ok",
+        )
+    )
+
+    def reject(arguments, fragment):
+        args, error = registry.validate("set_volume", arguments)
+        assert args is None
+        assert error is not None and fragment in error
+
+    reject("{}", "missing required")
+    reject('{"level": 1, "x": 2}', "unexpected argument")
+    reject('{"level": "loud"}', "must be integer")
+    reject('{"level": true}', "must be integer")
+    assert registry.validate("set_volume", '{"level": 5}') == ({"level": 5}, None)

+ 60 - 0
tests/test_tui_app.py

@@ -0,0 +1,60 @@
+"""Smoke test: the Textual app mounts, subscribes, and ingests broadcasts."""
+
+import asyncio
+import os
+import shutil
+import tempfile
+
+import pytest
+
+from stt_daemon.assistant.control import ControlServer
+from stt_daemon.assistant.service import AssistantService
+from stt_daemon.config import Config
+
+
+class FakeSubscriber:
+    def events(self):
+        return iter(())
+
+    def close(self):
+        pass
+
+
+@pytest.fixture
+def server():
+    directory = tempfile.mkdtemp(prefix="stta-")
+    path = os.path.join(directory, "c.sock")
+    cfg = Config()
+    cfg.assistant.control_socket = path
+
+    service = AssistantService(cfg, FakeSubscriber())
+    control = ControlServer(cfg, service, path=path)
+    service.set_sinks(emit=control.broadcast, relay=control.relay)
+    control.start()
+
+    yield path, service
+
+    control.close()
+    service.stop()
+    shutil.rmtree(directory, ignore_errors=True)
+
+
+def test_tui_mounts_subscribes_and_ingests_broadcasts(server):
+    pytest.importorskip("textual")
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, service = server
+    app = AssistantTUI(path)
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            service.arm()  # broadcast to the subscribed viewer
+            for _ in range(60):
+                await pilot.pause()
+                if app.state.mode == "command_armed":
+                    break
+                await asyncio.sleep(0.05)
+
+    asyncio.run(scenario())
+    assert app.state.mode == "command_armed"

+ 99 - 0
tests/test_tui_client.py

@@ -0,0 +1,99 @@
+"""Tests for the TUI control client against a real control server."""
+
+import os
+import shutil
+import tempfile
+import time
+
+import pytest
+
+from stt_daemon.assistant.control import ControlServer
+from stt_daemon.assistant.service import AssistantService
+from stt_daemon.config import Config
+from stt_daemon.tui.client import ControlClient
+
+
+class FakeSubscriber:
+    def events(self):
+        return iter(())
+
+    def close(self):
+        pass
+
+
+@pytest.fixture
+def server():
+    directory = tempfile.mkdtemp(prefix="sttt-")
+    path = os.path.join(directory, "c.sock")
+    cfg = Config()
+    cfg.assistant.control_socket = path
+
+    service = AssistantService(cfg, FakeSubscriber())
+    control = ControlServer(cfg, service, path=path)
+    service.set_sinks(emit=control.broadcast, relay=control.relay)
+    control.start()
+
+    yield path, service, control
+
+    control.close()
+    service.stop()
+    shutil.rmtree(directory, ignore_errors=True)
+
+
+def _wait(predicate, timeout=5.0):
+    deadline = time.time() + timeout
+    while time.time() < deadline:
+        if predicate():
+            return True
+        time.sleep(0.01)
+    return predicate()
+
+
+def test_client_subscribes_and_receives_status(server):
+    path, _service, _control = server
+    received: list[dict] = []
+
+    client = ControlClient(path)
+    client.connect()
+    client.subscribe(viewer=True)
+    client.start(received.append)
+
+    assert _wait(lambda: any(e["type"] == "status" for e in received))
+    client.close()
+
+
+def test_client_command_start_flips_mode_and_broadcasts(server):
+    path, service, _control = server
+    received: list[dict] = []
+
+    client = ControlClient(path)
+    client.connect()
+    client.subscribe(viewer=True)
+    client.start(received.append)
+    assert _wait(lambda: any(e["type"] == "status" for e in received))
+
+    client.send("command_start")
+
+    assert _wait(lambda: service.modes.mode.value == "command_armed")
+    assert _wait(
+        lambda: any(e["type"] == "mode" and e["mode"] == "command_armed" for e in received)
+    )
+    client.close()
+
+
+def test_client_receives_relayed_transcript(server):
+    path, service, _control = server
+    received: list[dict] = []
+
+    client = ControlClient(path)
+    client.connect()
+    client.subscribe(viewer=True)
+    client.start(received.append)
+    assert _wait(lambda: any(e["type"] == "status" for e in received))
+
+    service.handle_event(
+        {"v": 1, "type": "transcript", "seq": 1, "ts": time.time(), "text": "hello", "lang": "en"}
+    )
+
+    assert _wait(lambda: any(e["type"] == "transcript" and e["text"] == "hello" for e in received))
+    client.close()

+ 95 - 0
tests/test_tui_state.py

@@ -0,0 +1,95 @@
+"""Tests for the TUI view-state reducer."""
+
+from stt_daemon.tui.state import MAX_TRANSCRIPTS, ViewState
+
+
+def test_mode_event_updates_mode_and_style():
+    state = ViewState()
+    state.ingest({"type": "mode", "mode": "command_armed"})
+    assert state.mode == "command_armed"
+    assert state.mode_style() == "yellow"
+
+
+def test_heartbeat_self_corrects_missed_mode():
+    state = ViewState()
+    state.ingest(
+        {
+            "type": "heartbeat",
+            "mode": "chat",
+            "stt_ok": True,
+            "in_flight": False,
+            "worker_stall_s": 0.0,
+            "queue_depth": 2,
+            "device_ok": True,
+        }
+    )
+    assert state.mode == "chat"
+    assert state.queue_depth == 2
+
+
+def test_health_red_when_stt_not_ok():
+    state = ViewState()
+    state.ingest({"type": "heartbeat", "mode": "idle", "stt_ok": False})
+    assert state.health_ok() is False
+
+
+def test_health_red_when_device_down():
+    state = ViewState()
+    state.ingest({"type": "heartbeat", "mode": "idle", "stt_ok": True, "device_ok": False})
+    assert state.health_ok() is False
+
+
+def test_worker_stall_recorded_and_degrades():
+    state = ViewState()
+    state.ingest({"type": "heartbeat", "stt_ok": False, "worker_stall_s": 16.5})
+    assert state.worker_stall_s == 16.5
+    assert state.health_ok() is False
+
+
+def test_command_text_and_edited_flag():
+    state = ViewState()
+    state.ingest({"type": "command_text", "text": "hello", "edited": True, "phase": "ready"})
+    assert state.command_text == "hello"
+    assert state.command_edited is True
+    assert state.phase == "ready"
+
+
+def test_status_populates_fields():
+    state = ViewState()
+    state.ingest(
+        {
+            "type": "status",
+            "mode": "command_armed",
+            "ready": True,
+            "phase": "ready",
+            "command_text": "x",
+            "stt_ok": True,
+        }
+    )
+    assert state.mode == "command_armed"
+    assert state.ready is True
+    assert state.command_text == "x"
+
+
+def test_transcripts_are_bounded():
+    state = ViewState()
+    for i in range(MAX_TRANSCRIPTS + 25):
+        state.ingest({"type": "transcript", "text": f"t{i}", "lang": "en"})
+
+    assert len(state.transcripts) == MAX_TRANSCRIPTS
+    assert state.transcripts[-1]["text"] == f"t{MAX_TRANSCRIPTS + 24}"
+    assert state.transcript_lines()[-1].startswith("[en]")
+
+
+def test_chat_and_tool_events_recorded():
+    state = ViewState()
+    state.ingest({"type": "chat_message", "role": "user", "text": "hi"})
+    state.ingest({"type": "chat_message", "role": "assistant", "text": "hello"})
+    state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
+    state.ingest({"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "x"})
+    state.ingest({"type": "error", "code": "no_viewer", "message": "m"})
+
+    assert state.chat_lines() == ["user: hi", "assistant: hello"]
+    assert state.tool is not None and state.tool["tool"] == "get_datetime"
+    assert state.tool_result is not None and state.tool_result["ok"] is True
+    assert state.last_error is not None and state.last_error["code"] == "no_viewer"

+ 88 - 0
uv.lock

@@ -348,6 +348,53 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
 ]
 
+[[package]]
+name = "linkify-it-py"
+version = "2.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/45/98/7a1a5f31fd5c7ba93e963b168e244b8e3dd705b3d2a718e3c3307583bf57/linkify_it_py-2.2.0.tar.gz", hash = "sha256:907acd2d17ac1fbb9ddb62c8957ccbd6158cac602231a15c3b0cd1e215f03cee", size = 32939, upload-time = "2026-08-29T07:07:08.305Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl", hash = "sha256:3adc40eb5af300b2605fcfdb968c24e1d780a90f1f2221af7c15e5111e94d443", size = 21971, upload-time = "2026-08-29T07:07:07.164Z" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "mdurl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
+]
+
+[package.optional-dependencies]
+linkify = [
+    { name = "linkify-it-py" },
+]
+
+[[package]]
+name = "mdit-py-plugins"
+version = "0.6.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "markdown-it-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
+]
+
 [[package]]
 name = "numpy"
 version = "2.4.6"
@@ -553,6 +600,15 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
 ]
 
+[[package]]
+name = "platformdirs"
+version = "4.11.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f8/13/f870dd0b42690138e4e37a76b5138e5690ed4365a77071bb092d59037da0/platformdirs-4.11.11.tar.gz", hash = "sha256:b0befe8a90759e4a9a8b9820d434ae226a6549063210b596da0038a7a05aede4", size = 39949, upload-time = "2026-09-19T01:18:47.259Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/39/be/3ae887e84c38c4dd1c549cd5825adc93f48069a94fe1cb8ac52a9da16f15/platformdirs-4.11.11-py3-none-any.whl", hash = "sha256:972ea6b2b387155a536226a0750e46923d731d459a387c4a255c583ed2f64547", size = 24992, upload-time = "2026-09-19T01:18:45.825Z" },
+]
+
 [[package]]
 name = "pluggy"
 version = "1.6.0"
@@ -690,6 +746,19 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
 ]
 
+[[package]]
+name = "rich"
+version = "15.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "markdown-it-py" },
+    { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
+]
+
 [[package]]
 name = "ruff"
 version = "0.16.8"
@@ -740,6 +809,7 @@ dependencies = [
     { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
     { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
     { name = "questionary" },
+    { name = "textual" },
     { name = "webrtcvad-wheels" },
 ]
 
@@ -760,6 +830,7 @@ requires-dist = [
     { name = "numpy", specifier = ">=1.26" },
     { name = "questionary", specifier = ">=2.0" },
     { name = "sounddevice", marker = "extra == 'portaudio'", specifier = ">=0.4.6" },
+    { name = "textual", specifier = ">=0.80" },
     { name = "webrtcvad-wheels", specifier = ">=2.0.14" },
 ]
 provides-extras = ["portaudio"]
@@ -770,6 +841,23 @@ dev = [
     { name = "ruff" },
 ]
 
+[[package]]
+name = "textual"
+version = "8.2.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "markdown-it-py", extra = ["linkify"] },
+    { name = "mdit-py-plugins" },
+    { name = "platformdirs" },
+    { name = "pygments" },
+    { name = "rich" },
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" },
+]
+
 [[package]]
 name = "tokenizers"
 version = "0.23.2"