ソースを参照

Added simple run

Efren Yevale Varela 1 ヶ月 前
親
コミット
18cb56294e
5 ファイル変更、944 行追加、1 行削除
  1. 59 1
      README.md
  2. 48 0
      src/stt_daemon/cli.py
  3. 353 0
      src/stt_daemon/supervisor.py
  4. 8 0
      src/stt_daemon/tui/__main__.py
  5. 476 0
      tests/test_supervisor.py

+ 59 - 1
README.md

@@ -345,6 +345,44 @@ them in reverse order (loopbacks → remap-source → null-sink), so the
 command is idempotent — safe to re-run and to recover from
 `Sink 'VirtualMicSink' already exists but is not tracked`.
 
+#### `up` — Launch daemon, assistant, and optionally TUI
+
+```bash
+uv run stt-daemon up              # Start daemon + assistant (separate processes)
+uv run stt-daemon up --with-tui   # Also launch the interactive TUI viewer
+```
+
+This subcommand runs the entire system as a supervised set of child processes:
+
+- **daemon** — transcription engine (listens to audio, publishes transcriptions)
+- **assistant** — controller (subscribes to transcriptions, serves the control socket)
+- **TUI** (optional) — viewer/controller UI (connects to the assistant via control socket)
+
+The supervisor manages their lifetimes: if any child exits or Ctrl+C is pressed, all
+remaining children are cleanly shut down. Without `--with-tui`, you can still run
+a separate `uv run stt-tui` in another terminal to view and control the assistant.
+
+With `--with-tui`, the daemon and assistant log to
+`~/.local/state/stt-daemon/up.log` (or `$XDG_STATE_HOME/stt-daemon/up.log`) so their
+output cannot corrupt the screen, while the TUI keeps the terminal. Without `--with-tui`,
+all output goes to the terminal.
+
+Like all subcommands, `up` also accepts `--config`, `--device`, `--backend`,
+`--model`, `--language`, and `-v` (verbose) flags.
+
+Example workflow:
+
+```bash
+# One-command startup with TUI (everything stops when you quit the TUI)
+uv run stt-daemon up --with-tui
+
+# Or, start daemon + assistant only
+uv run stt-daemon up
+
+# Then in another terminal, launch the TUI whenever you want
+uv run stt-tui
+```
+
 #### (Legacy) `--list-sources` flag
 
 ```bash
@@ -438,7 +476,19 @@ conversation. The daemon stays a dumb, reliable publisher; the assistant owns al
 - `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)
+### Running
+
+#### Simplest: single command with supervisor
+
+```bash
+uv run stt-daemon up --with-tui
+```
+
+This starts daemon, assistant, and TUI as child processes under a supervisor.
+When you quit the TUI, everything stops cleanly. Daemon and assistant output is
+logged to `~/.local/state/stt-daemon/up.log`.
+
+#### Traditional: three terminals
 
 ```bash
 uv run stt-daemon        # transcription + event bus
@@ -446,6 +496,14 @@ uv run stt-assistant     # controller: modes, LLM, tools, audit, control socket
 uv run stt-tui           # viewer/controller TUI  (or `stt-tui --dump 10` headless)
 ```
 
+Or start daemon + assistant once, then connect viewers as needed:
+
+```bash
+uv run stt-daemon up             # daemon + assistant
+uv run stt-tui                   # optional: viewer in another terminal
+uv run stt-tui --dump 5          # optional: headless event dump (5 seconds)
+```
+
 ### Requirements
 
 A local llama.cpp server exposing an OpenAI-compatible `/v1/chat/completions` with a

+ 48 - 0
src/stt_daemon/cli.py

@@ -15,6 +15,7 @@ from .provisioner import Provisioner
 from .pulse import PulseAudio
 from .state import StateStore
 from .transcriber import Transcriber
+from .supervisor import Supervisor
 
 
 def _own_device_names(cfg) -> set[str]:
@@ -139,6 +140,18 @@ def main() -> int:
         help="Skip confirmation",
     )
 
+    # up subcommand
+    up_parser = subparsers.add_parser(
+        "up",
+        parents=[common],
+        help="Launch daemon, assistant, and optionally TUI as supervised child processes",
+    )
+    up_parser.add_argument(
+        "--with-tui",
+        action="store_true",
+        help="Also launch the TUI (quitting the TUI stops everything)",
+    )
+
     args = parser.parse_args()
 
     # Handle legacy compatibility: --list-sources at top level
@@ -158,6 +171,8 @@ def main() -> int:
         return _handle_list(args)
     elif args.command == "clear":
         return _handle_clear(args)
+    elif args.command == "up":
+        return _handle_up(args)
     else:
         # No subcommand given: run daemon with optional check
         if hasattr(args, "check") and args.check:
@@ -373,6 +388,39 @@ def _handle_clear(args) -> int:
         return 1
 
 
+def _handle_up(args) -> int:
+    """Up: launch daemon, assistant, and optionally TUI as supervised child processes."""
+    try:
+        cfg = load_config(args.config if hasattr(args, "config") else None)
+
+        # Build overrides dict from parsed args
+        overrides = {}
+        if hasattr(args, "device") and args.device:
+            overrides["device"] = args.device
+        if hasattr(args, "backend") and args.backend:
+            overrides["backend"] = args.backend
+        if hasattr(args, "model") and args.model:
+            overrides["model"] = args.model
+        if hasattr(args, "language") and args.language:
+            overrides["language"] = args.language
+        if hasattr(args, "config") and args.config:
+            overrides["config"] = args.config
+
+        verbose = hasattr(args, "verbose") and args.verbose
+
+        supervisor = Supervisor(
+            cfg,
+            with_tui=args.with_tui,
+            overrides=overrides,
+            verbose=verbose,
+        )
+        return supervisor.run()
+
+    except Exception as e:
+        print(f"Up error: {e}", file=sys.stderr)
+        return 1
+
+
 def _check(args) -> int:
     """Test audio capture and transcriber (legacy --check or run --check)."""
     cfg = load_config(args.config)

+ 353 - 0
src/stt_daemon/supervisor.py

@@ -0,0 +1,353 @@
+"""Supervisor: launch daemon, assistant, and optionally TUI as child processes.
+
+Process topology is preserved: stt-daemon is publish-only on stt-daemon.sock;
+stt-assistant subscribes and serves stt-assistant.sock; stt-tui is a client of
+the assistant control socket.
+
+Supervisor lifetime rule: if any child process exits on its own (poll() becomes
+non-None), or if Ctrl+C/SIGTERM is received, shut down ALL remaining children
+(terminate → wait(grace_s) → kill → wait). Return the first child's exit code,
+or 0 if shutdown was signal-driven only.
+
+Control socket waiting (with_tui mode only) is bounded by wait_timeout_s and
+breaks early if a child exits. With --with-tui, TUI startup is mandatory: if the
+control socket does not appear in time or a child exits early, all children are
+terminated and a non-zero code is returned. Silent headless fallback is not allowed.
+"""
+
+import logging
+import os
+import signal
+import subprocess
+import sys
+import threading
+import time
+from pathlib import Path
+
+try:
+    import termios
+except ImportError:
+    termios = None
+
+from .assistant import control as assistant_control
+from .config import Config
+
+logger = logging.getLogger(__name__)
+
+_TERMIOS_ERRORS = (OSError, ValueError, AttributeError)
+if termios is not None:
+    _TERMIOS_ERRORS = _TERMIOS_ERRORS + (termios.error,)
+
+
+def _get_state_home() -> Path:
+    """Return $XDG_STATE_HOME/stt-daemon or ~/.local/state/stt-daemon."""
+    env_path = os.environ.get("XDG_STATE_HOME")
+    if env_path:
+        return Path(env_path) / "stt-daemon"
+    home = os.environ.get("HOME", "~")
+    return Path(home).expanduser() / ".local" / "state" / "stt-daemon"
+
+
+class Supervisor:
+    """Orchestrate daemon, assistant, and optional TUI as child processes."""
+
+    def __init__(
+        self,
+        cfg: Config,
+        *,
+        with_tui: bool,
+        overrides: dict[str, str] | None = None,
+        verbose: bool = False,
+        spawn=None,
+        sleep=time.sleep,
+        clock=time.monotonic,
+        wait_timeout_s: float = 15.0,
+        grace_s: float = 5.0,
+        poll_s: float = 0.1,
+        log_path: str | None = None,
+    ):
+        """Initialize the supervisor.
+
+        Args:
+            cfg: Configuration object.
+            with_tui: If True, also launch the TUI after control socket is ready.
+            overrides: Dict with optional keys 'config', 'device', 'backend',
+                'model', 'language' (all strings). None/missing keys are omitted.
+            verbose: If True, pass -v to daemon and assistant.
+            spawn: Function spawn(argv, *, redirect_output=False) -> Popen.
+                Default wraps subprocess.Popen, handling log file open/close.
+            sleep: Callable for sleeping (injected for testing).
+            clock: Callable for monotonic time (injected for testing).
+            wait_timeout_s: Timeout for waiting on assistant control socket.
+            grace_s: Time to wait for children to exit after terminate().
+            poll_s: Time between child poll() checks.
+            log_path: File path for child stdout/stderr. Default is
+                $XDG_STATE_HOME/stt-daemon/up.log.
+        """
+        self.cfg = cfg
+        self.with_tui = with_tui
+        self.overrides = overrides or {}
+        self.verbose = verbose
+        self.wait_timeout_s = wait_timeout_s
+        self.grace_s = grace_s
+        self.poll_s = poll_s
+        self.sleep = sleep
+        self.clock = clock
+
+        # Default spawn wraps subprocess.Popen
+        if spawn is None:
+            def default_spawn(argv, *, redirect_output=False):
+                if redirect_output:
+                    log_file = open(self.log_path, "a")
+                    proc = subprocess.Popen(argv, stdout=log_file, stderr=log_file)
+                    log_file.close()
+                    return proc
+                return subprocess.Popen(argv)
+            self.spawn = default_spawn
+        else:
+            self.spawn = spawn
+
+        # Resolve log path
+        if log_path is None:
+            log_dir = _get_state_home()
+            log_dir.mkdir(parents=True, exist_ok=True)
+            log_path = str(log_dir / "up.log")
+        self.log_path = log_path
+
+    def run(self) -> int:
+        """Run the supervisor until a child exits or a signal is received.
+
+        Returns:
+            Exit code of the first child that exited on its own, or 0 if
+            shutdown was only due to a signal (no child exited).
+        """
+        stop = threading.Event()
+
+        def handle_signal(_signum, _frame):
+            stop.set()
+
+        signal.signal(signal.SIGINT, handle_signal)
+        signal.signal(signal.SIGTERM, handle_signal)
+
+        saved_termios = self._save_terminal() if self.with_tui else None
+        try:
+            daemon_proc = self._spawn_daemon()
+            if daemon_proc is None:
+                return 1
+
+            assistant_proc = self._spawn_assistant()
+            if assistant_proc is None:
+                self._terminate_all([daemon_proc])
+                return 1
+
+            tui_proc = None
+            if self.with_tui:
+                # Wait for control socket before spawning TUI
+                if not self._wait_for_control_socket(stop, [daemon_proc, assistant_proc]):
+                    # Timeout or child exited early: check if child already exited
+                    child_exit_code = None
+                    for proc in [daemon_proc, assistant_proc]:
+                        code = proc.poll()
+                        if code is not None:
+                            child_exit_code = code
+                            break
+                    logger.error("Failed to bring up TUI; terminating all children")
+                    self._terminate_all([daemon_proc, assistant_proc])
+                    return child_exit_code if child_exit_code is not None else 1
+                tui_proc = self._spawn_tui()
+                if tui_proc is None:
+                    logger.error("Failed to spawn TUI; terminating all children")
+                    self._terminate_all([daemon_proc, assistant_proc])
+                    return 1
+
+            procs = [daemon_proc, assistant_proc]
+            if tui_proc:
+                procs.append(tui_proc)
+
+            # Main loop: check children until one exits or signal is received
+            first_exit_code = None
+            while not stop.is_set():
+                for proc in procs:
+                    code = proc.poll()
+                    if code is not None:
+                        logger.info(
+                            "Child process exited with code %d; shutting down", code
+                        )
+                        first_exit_code = code
+                        stop.set()
+                        break
+
+                if not stop.is_set():
+                    self.sleep(self.poll_s)
+
+            # Shutdown all remaining children
+            self._terminate_all(procs)
+
+            # Return first child's exit code or 0 for signal-driven shutdown
+            return first_exit_code if first_exit_code is not None else 0
+
+        except Exception as e:
+            logger.exception("Supervisor error: %s", e)
+            return 1
+        finally:
+            self._restore_terminal(saved_termios)
+
+    def _save_terminal(self):
+        """Snapshot terminal attributes so a killed TUI cannot leave the tty in raw mode."""
+        if termios is None:
+            return None
+        try:
+            return termios.tcgetattr(sys.stdin.fileno())
+        except _TERMIOS_ERRORS:
+            return None
+
+    def _restore_terminal(self, saved) -> None:
+        """Restore attributes from `_save_terminal`; safe to call with None.
+
+        ISIG is forced back on because Textual clears it in raw mode and a killed
+        TUI never restores it, which would leave Ctrl+C dead in the shell.
+        """
+        if saved is None or termios is None:
+            return
+        try:
+            attrs = list(saved)
+            attrs[3] |= termios.ISIG
+            termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, attrs)
+        except _TERMIOS_ERRORS:
+            pass
+
+    def _spawn_daemon(self) -> subprocess.Popen | None:
+        """Spawn the daemon child process."""
+        try:
+            argv = [sys.executable, "-m", "stt_daemon", "run"]
+            self._add_common_flags(argv)
+            logger.info("Spawning daemon: %s", " ".join(argv))
+            return self._spawn_with_output(argv)
+        except Exception as e:
+            logger.error("Failed to spawn daemon: %s", e)
+            return None
+
+    def _spawn_assistant(self) -> subprocess.Popen | None:
+        """Spawn the assistant child process."""
+        try:
+            argv = [sys.executable, "-m", "stt_daemon.assistant"]
+            if "config" in self.overrides:
+                argv.extend(["--config", self.overrides["config"]])
+            if self.verbose:
+                argv.append("-v")
+            logger.info("Spawning assistant: %s", " ".join(argv))
+            return self._spawn_with_output(argv)
+        except Exception as e:
+            logger.error("Failed to spawn assistant: %s", e)
+            return None
+
+    def _spawn_tui(self) -> subprocess.Popen | None:
+        """Spawn the TUI child process."""
+        try:
+            argv = [sys.executable, "-m", "stt_daemon.tui"]
+            if "config" in self.overrides:
+                argv.extend(["--config", self.overrides["config"]])
+            logger.info("Spawning TUI: %s", " ".join(argv))
+            return self._spawn_with_output(argv, redirect_output=False)
+        except Exception as e:
+            logger.error("Failed to spawn TUI: %s", e)
+            return None
+
+    def _add_common_flags(self, argv: list[str]) -> None:
+        """Add common flags to daemon argv."""
+        if "config" in self.overrides:
+            argv.extend(["--config", self.overrides["config"]])
+        if "device" in self.overrides:
+            argv.extend(["--device", self.overrides["device"]])
+        if "backend" in self.overrides:
+            argv.extend(["--backend", self.overrides["backend"]])
+        if "model" in self.overrides:
+            argv.extend(["--model", self.overrides["model"]])
+        if "language" in self.overrides:
+            argv.extend(["--language", self.overrides["language"]])
+        if self.verbose:
+            argv.append("-v")
+
+    def _spawn_with_output(
+        self, argv: list[str], *, redirect_output: bool | None = None
+    ) -> subprocess.Popen:
+        """Spawn a child; default logs to file in with-tui mode, TUI keeps the terminal."""
+        if redirect_output is None:
+            redirect_output = self.with_tui
+        return self.spawn(argv, redirect_output=redirect_output)
+
+    def _wait_for_control_socket(
+        self, stop: threading.Event, procs: list
+    ) -> bool:
+        """Wait for assistant control socket to exist.
+
+        Returns True if socket exists before timeout/stop; False otherwise.
+        Breaks early if a child exits.
+        """
+        assistant_socket = (
+            self.cfg.assistant.control_socket
+            or assistant_control.default_control_path()
+        )
+
+        start = self.clock()
+        while self.clock() - start < self.wait_timeout_s:
+            if Path(assistant_socket).exists():
+                logger.info("Control socket ready")
+                return True
+
+            # Check if any child exited early
+            for proc in procs:
+                if proc.poll() is not None:
+                    logger.warning("Child exited during control socket wait")
+                    return False
+
+            if stop.is_set():
+                logger.info("Stop signal received during control socket wait")
+                return False
+
+            self.sleep(self.poll_s)
+
+        logger.warning("Timeout waiting for control socket: %s", assistant_socket)
+        return False
+
+    def _terminate_all(self, procs: list) -> None:
+        """Terminate all processes: terminate, wait, then kill."""
+        for proc in procs:
+            if proc.poll() is None:
+                logger.info("Terminating process %d", proc.pid)
+                proc.terminate()
+
+        # Wait for grace period
+        start = self.clock()
+        while self.clock() - start < self.grace_s:
+            all_done = all(proc.poll() is not None for proc in procs)
+            if all_done:
+                logger.info("All processes terminated gracefully")
+                return
+            self.sleep(0.01)
+
+        # Force kill remaining
+        for proc in procs:
+            if proc.poll() is None:
+                logger.warning("Killing process %d", proc.pid)
+                proc.kill()
+
+        # Wait for all to exit
+        for proc in procs:
+            try:
+                proc.wait(timeout=1.0)
+            except subprocess.TimeoutExpired:
+                logger.error("Process %d did not exit after kill", proc.pid)
+
+
+def run_up(
+    cfg: Config,
+    with_tui: bool = False,
+    overrides: dict[str, str] | None = None,
+    verbose: bool = False,
+) -> int:
+    """Convenience function to run the supervisor."""
+    supervisor = Supervisor(
+        cfg, with_tui=with_tui, overrides=overrides, verbose=verbose
+    )
+    return supervisor.run()

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

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

+ 476 - 0
tests/test_supervisor.py

@@ -0,0 +1,476 @@
+"""Tests for the Supervisor class (daemon, assistant, TUI orchestration)."""
+
+import signal
+import sys
+import types
+
+from stt_daemon.config import Config
+from stt_daemon.supervisor import Supervisor
+
+
+class FakePopen:
+    """Fake subprocess.Popen for testing without spawning real processes."""
+
+    _pid_counter = 10000
+
+    def __init__(self, argv, redirect_output=False):
+        self.argv = argv
+        self.redirect_output = redirect_output
+        self.pid = FakePopen._pid_counter
+        FakePopen._pid_counter += 1
+        self.returncode = None
+        self._terminated = False
+        self._killed = False
+
+    def poll(self):
+        """Return None (running) or a returncode."""
+        return self.returncode
+
+    def terminate(self):
+        """Simulate termination."""
+        self._terminated = True
+        if self.returncode is None:
+            self.returncode = -15
+
+    def kill(self):
+        """Simulate kill."""
+        self._killed = True
+        if self.returncode is None:
+            self.returncode = -9
+
+    def wait(self, timeout=None):
+        """Wait for process to exit."""
+        if self.returncode is None:
+            self.returncode = 0
+        return self.returncode
+
+
+def _cfg():
+    """Create a test Config object."""
+    return Config()
+
+
+class FakeTermios:
+    """Records tcgetattr/tcsetattr calls instead of touching a real terminal."""
+
+    TCSADRAIN = 1
+    ISIG = 1
+
+    def __init__(self, log):
+        self._log = log
+
+    def tcgetattr(self, fd):
+        self._log.append(("get", fd))
+        return [0, 0, 0, 0, 0, 0, []]
+
+    def tcsetattr(self, fd, when, attrs):
+        self._log.append(("set", fd, when, attrs))
+
+
+class FakeStdin:
+    """Stand-in for sys.stdin so the helpers never touch pytest's captured stdin."""
+
+    def fileno(self):
+        return 7
+
+
+def test_supervisor_run_spawns_daemon_then_assistant_no_tui():
+    """run() spawns daemon then assistant, not TUI when with_tui=False."""
+    cfg = _cfg()
+    spawned = []
+
+    def fake_spawn(argv, *, redirect_output=False):
+        spawned.append((argv, redirect_output))
+        proc = FakePopen(argv, redirect_output=redirect_output)
+        if len(spawned) == 1:
+            proc.returncode = 0
+        return proc
+
+    supervisor = Supervisor(cfg, with_tui=False, spawn=fake_spawn, poll_s=0.001)
+    result = supervisor.run()
+
+    assert len(spawned) == 2
+    assert "stt_daemon" in " ".join(spawned[0][0]) and "run" in spawned[0][0]
+    assert "stt_daemon.assistant" in " ".join(spawned[1][0])
+    assert "stt_daemon.tui" not in " ".join(str(x) for x in spawned)
+    assert result == 0
+
+
+def test_supervisor_run_fails_when_tui_socket_timeout():
+    """run() with_tui=True fails and terminates all if control socket timeout."""
+    cfg = _cfg()
+    cfg.assistant.control_socket = "/nonexistent/socket.sock"
+
+    spawned = []
+
+    def fake_spawn(argv, *, redirect_output=False):
+        proc = FakePopen(argv, redirect_output=redirect_output)
+        spawned.append(proc)
+        return proc
+
+    clock_val = [0.0]
+
+    def fake_clock():
+        return clock_val[0]
+
+    def fake_sleep(s):
+        clock_val[0] += 0.1
+
+    supervisor = Supervisor(
+        cfg,
+        with_tui=True,
+        spawn=fake_spawn,
+        sleep=fake_sleep,
+        clock=fake_clock,
+        poll_s=0.001,
+        wait_timeout_s=0.05,
+    )
+    result = supervisor.run()
+
+    assert result == 1
+    assert len(spawned) == 2
+    for proc in spawned:
+        assert proc._terminated or proc._killed
+
+
+def test_supervisor_run_returns_child_exit_code():
+    """run() returns first child's exit code when it exits."""
+    cfg = _cfg()
+    spawned = []
+
+    def fake_spawn(argv, *, redirect_output=False):
+        proc = FakePopen(argv, redirect_output=redirect_output)
+        spawned.append(proc)
+        if len(spawned) == 1:
+            proc.returncode = 42
+        return proc
+
+    supervisor = Supervisor(cfg, with_tui=False, spawn=fake_spawn, poll_s=0.001)
+    result = supervisor.run()
+
+    assert result == 42
+    assert spawned[0].returncode == 42
+    assert spawned[1]._terminated or spawned[1]._killed
+
+
+def test_supervisor_forwards_overrides_to_daemon():
+    """Daemon receives device, backend, model, language overrides."""
+    cfg = _cfg()
+    overrides = {
+        "device": "microphone.usb",
+        "backend": "sounddevice",
+        "model": "base",
+        "language": "es",
+        "config": "/path/to/config.toml",
+    }
+    spawned = []
+
+    def fake_spawn(argv, *, redirect_output=False):
+        spawned.append(argv)
+        proc = FakePopen(argv, redirect_output=redirect_output)
+        if len(spawned) == 1:
+            proc.returncode = 0
+        return proc
+
+    supervisor = Supervisor(
+        cfg, with_tui=False, overrides=overrides, spawn=fake_spawn, poll_s=0.001
+    )
+    supervisor.run()
+
+    daemon_argv = spawned[0]
+    assert "--device" in daemon_argv and "microphone.usb" in daemon_argv
+    assert "--backend" in daemon_argv and "sounddevice" in daemon_argv
+    assert "--model" in daemon_argv and "base" in daemon_argv
+    assert "--language" in daemon_argv and "es" in daemon_argv
+    assert "--config" in daemon_argv and "/path/to/config.toml" in daemon_argv
+
+
+def test_supervisor_forwards_verbose():
+    """Verbose flag forwarded to daemon and assistant."""
+    cfg = _cfg()
+    spawned = []
+
+    def fake_spawn(argv, *, redirect_output=False):
+        spawned.append(argv)
+        proc = FakePopen(argv, redirect_output=redirect_output)
+        if len(spawned) == 1:
+            proc.returncode = 0
+        return proc
+
+    supervisor = Supervisor(cfg, with_tui=False, verbose=True, spawn=fake_spawn, poll_s=0.001)
+    supervisor.run()
+
+    assert "-v" in spawned[0]
+    assert "-v" in spawned[1]
+
+
+def test_supervisor_skips_config_when_not_given():
+    """--config flag omitted when not in overrides."""
+    cfg = _cfg()
+    overrides = {"device": "test"}
+    spawned = []
+
+    def fake_spawn(argv, *, redirect_output=False):
+        spawned.append(argv)
+        proc = FakePopen(argv, redirect_output=redirect_output)
+        if len(spawned) == 1:
+            proc.returncode = 0
+        return proc
+
+    supervisor = Supervisor(
+        cfg, with_tui=False, overrides=overrides, spawn=fake_spawn, poll_s=0.001
+    )
+    supervisor.run()
+
+    assert "--config" not in spawned[0]
+    assert "--device" in spawned[0]
+
+
+def test_supervisor_assistant_receives_only_allowed_flags():
+    """Assistant gets config and verbose, but not device/backend/model."""
+    cfg = _cfg()
+    overrides = {
+        "config": "/config.toml",
+        "device": "ignore",
+        "backend": "ignore",
+        "model": "ignore",
+    }
+    spawned = []
+
+    def fake_spawn(argv, *, redirect_output=False):
+        spawned.append(argv)
+        proc = FakePopen(argv, redirect_output=redirect_output)
+        if len(spawned) == 1:
+            proc.returncode = 0
+        return proc
+
+    supervisor = Supervisor(
+        cfg, with_tui=False, overrides=overrides, verbose=True, spawn=fake_spawn, poll_s=0.001
+    )
+    supervisor.run()
+
+    assistant_argv = spawned[1]
+    assert "--config" in assistant_argv
+    assert "/config.toml" in assistant_argv
+    assert "-v" in assistant_argv
+    assert "--device" not in assistant_argv
+    assert "--backend" not in assistant_argv
+    assert "--model" not in assistant_argv
+
+
+def test_supervisor_spawn_redirect_output_false_without_tui():
+    """spawn() is called with redirect_output=False when with_tui=False."""
+    cfg = _cfg()
+    spawned = []
+
+    def fake_spawn(argv, *, redirect_output=False):
+        spawned.append((argv, redirect_output))
+        proc = FakePopen(argv, redirect_output=redirect_output)
+        if len(spawned) == 1:
+            proc.returncode = 0
+        return proc
+
+    supervisor = Supervisor(cfg, with_tui=False, spawn=fake_spawn, poll_s=0.001)
+    supervisor.run()
+
+    for argv, redirect_output in spawned:
+        assert redirect_output is False
+
+
+def test_supervisor_spawn_redirect_output_true_with_tui():
+    """spawn() is called with redirect_output=True when with_tui=True."""
+    cfg = _cfg()
+    spawned = []
+
+    def fake_spawn(argv, *, redirect_output=False):
+        spawned.append((argv, redirect_output))
+        proc = FakePopen(argv, redirect_output=redirect_output)
+        if len(spawned) == 1:
+            proc.returncode = 0
+        return proc
+
+    supervisor = Supervisor(cfg, with_tui=True, spawn=fake_spawn, wait_timeout_s=0.01, poll_s=0.001)
+    supervisor.run()
+
+    for argv, redirect_output in spawned:
+        assert redirect_output is True
+
+
+def test_supervisor_run_spawns_tui_after_control_socket(tmp_path):
+    """With_tui=True spawns the TUI once the control socket exists; TUI exit stops all."""
+    cfg = _cfg()
+    cfg.assistant.control_socket = str(tmp_path / "assistant.sock")
+    (tmp_path / "assistant.sock").touch()
+
+    spawned = []
+
+    def fake_spawn(argv, *, redirect_output=False):
+        proc = FakePopen(argv, redirect_output=redirect_output)
+        spawned.append((argv, proc))
+        if len(spawned) == 3:
+            proc.returncode = 0
+        return proc
+
+    clock_val = [0.0]
+
+    def fake_clock():
+        return clock_val[0]
+
+    def fake_sleep(_s):
+        clock_val[0] += 0.1
+
+    supervisor = Supervisor(
+        cfg,
+        with_tui=True,
+        spawn=fake_spawn,
+        sleep=fake_sleep,
+        clock=fake_clock,
+        poll_s=0.001,
+    )
+    result = supervisor.run()
+
+    assert len(spawned) == 3
+    assert "stt_daemon.tui" in " ".join(spawned[2][0])
+    assert spawned[0][1].redirect_output is True
+    assert spawned[1][1].redirect_output is True
+    assert spawned[2][1].redirect_output is False
+    assert result == 0
+    for _argv, proc in spawned[:2]:
+        assert proc._terminated or proc._killed
+
+
+def test_supervisor_force_kills_stubborn_children():
+    """A child that ignores SIGTERM is force-killed after the grace period."""
+    cfg = _cfg()
+
+    class StubbornPopen(FakePopen):
+        def terminate(self):
+            self._terminated = True
+
+    daemon = StubbornPopen(["daemon"])
+
+    clock_val = [0.0]
+
+    def fake_clock():
+        return clock_val[0]
+
+    def fake_sleep(_s):
+        clock_val[0] += 0.1
+
+    supervisor = Supervisor(
+        cfg, with_tui=False, grace_s=0.05, clock=fake_clock, sleep=fake_sleep
+    )
+    supervisor._terminate_all([daemon])
+
+    assert daemon._killed
+
+
+def test_supervisor_run_signal_shutdown_returns_zero(monkeypatch):
+    """SIGINT stops the loop, terminates every child, and returns 0."""
+    cfg = _cfg()
+    handlers = {}
+    monkeypatch.setattr(
+        "stt_daemon.supervisor.signal.signal",
+        lambda signum, handler: handlers.__setitem__(signum, handler),
+    )
+
+    spawned = []
+
+    def fake_spawn(argv, *, redirect_output=False):
+        proc = FakePopen(argv, redirect_output=redirect_output)
+        spawned.append(proc)
+        return proc
+
+    clock_val = [0.0]
+
+    def fake_clock():
+        return clock_val[0]
+
+    def fake_sleep(_s):
+        clock_val[0] += 0.1
+        if signal.SIGINT in handlers:
+            handlers[signal.SIGINT](signal.SIGINT, None)
+
+    supervisor = Supervisor(
+        cfg,
+        with_tui=False,
+        spawn=fake_spawn,
+        sleep=fake_sleep,
+        clock=fake_clock,
+        poll_s=0.001,
+    )
+    result = supervisor.run()
+
+    assert len(spawned) == 2
+    assert result == 0
+    for proc in spawned:
+        assert proc._terminated or proc._killed
+
+
+def test_supervisor_restores_terminal_after_tui(monkeypatch, tmp_path):
+    """A killed TUI must not leave the terminal in raw mode."""
+    cfg = _cfg()
+    cfg.assistant.control_socket = str(tmp_path / "assistant.sock")
+    (tmp_path / "assistant.sock").touch()
+
+    log = []
+    monkeypatch.setattr("stt_daemon.supervisor.termios", FakeTermios(log))
+    monkeypatch.setattr(
+        "stt_daemon.supervisor.sys",
+        types.SimpleNamespace(stdin=FakeStdin(), executable=sys.executable),
+    )
+
+    spawned = []
+
+    def fake_spawn(argv, *, redirect_output=False):
+        proc = FakePopen(argv, redirect_output=redirect_output)
+        spawned.append(proc)
+        if len(spawned) == 3:
+            proc.returncode = 0
+        return proc
+
+    clock_val = [0.0]
+
+    def fake_clock():
+        return clock_val[0]
+
+    def fake_sleep(_s):
+        clock_val[0] += 0.1
+
+    supervisor = Supervisor(
+        cfg,
+        with_tui=True,
+        spawn=fake_spawn,
+        sleep=fake_sleep,
+        clock=fake_clock,
+        poll_s=0.001,
+    )
+    supervisor.run()
+
+    assert ("get", 7) in log
+    assert ("set", 7, FakeTermios.TCSADRAIN, [0, 0, 0, 1, 0, 0, []]) in log
+
+
+def test_supervisor_leaves_terminal_alone_without_tui(monkeypatch):
+    """Without --with-tui the supervisor never touches terminal attributes."""
+    cfg = _cfg()
+    log = []
+    monkeypatch.setattr("stt_daemon.supervisor.termios", FakeTermios(log))
+    monkeypatch.setattr(
+        "stt_daemon.supervisor.sys",
+        types.SimpleNamespace(stdin=FakeStdin(), executable=sys.executable),
+    )
+
+    spawned = []
+
+    def fake_spawn(argv, *, redirect_output=False):
+        proc = FakePopen(argv, redirect_output=redirect_output)
+        spawned.append(proc)
+        if len(spawned) == 1:
+            proc.returncode = 0
+        return proc
+
+    supervisor = Supervisor(cfg, with_tui=False, spawn=fake_spawn, poll_s=0.001)
+    supervisor.run()
+
+    assert log == []