# Daemon Architecture

## Personas

Four participants interact with daemon. Every feature must consider all four:

1. **Developer** — configures `.agnt.kdl`, runs `agnt run` or opens Claude Code session, expects dev servers and proxies to "just work." Needs "doctor" command for manual verification and cleanup.

2. **AI Agent** — calls MCP tools (`proc`, `proxy`, `proxylog`, `get_errors`, `get_incidents`), decides based on state. Needs verified-accurate state — stale or contradictory data makes agent take wrong actions, worse than no data.

3. **Daemon** — long-running background process outliving any session. Orchestrates lifecycles, manages event system, serves as state cache.

4. **Managed processes/proxies** — active participants, not passive resources. Emit errors, need restarts, can go rogue (zombie PIDs, orphaned ports). State must be verified against OS truth, not assumed from daemon memory.

## Data Ownership — Source of Truth

Daemon in-memory state is **cache**, never authority. Canonical source of truth per state:

| State | Source of truth | Verification method |
|-------|----------------|-------------------|
| Process alive/dead | OS (PID check) | Platform-specific PID probe |
| Port ownership | OS (socket table) | Platform-specific port scan |
| Proxy alive/responsive | Proxy instance (health probe) | TCP connect or HTTP GET |
| Script config / expected state | `.agnt.kdl` on disk | Parse and compare |
| URL associations | URLTracker cache | Verified against actual port binding |
| Script registry entries | Session lifecycle | Rebuilt from config on each session connect |
| Incident inbox contents | Originating subsystem | Inbox is a cache — re-fetch from source on reconciliation |
| Blob store payloads | In-memory LRU only | Best-effort; evicted on session end or cap overflow |
| Bus in-flight events | Transient channel only | Drop-newest on overflow; no replay |

**Rule**: Any mismatch between daemon cache and source of truth = daemon updates cache to match reality and emits event. Daemon never asserts cache correct over OS truth.

**Script registry ephemeral**: Rebuilt from `.agnt.kdl` on each session connect. When last session for project disconnects, `CleanupSessionResources` removes all registry entries. Next session starts fresh from current config. Never persist or carry registry state across sessions.

## Reconciliation Model

Three reconciliation triggers:

1. **On session connect** — daemon runs full health check before responding to first query. AI agent must get verified-accurate state.
2. **Periodic** — every 30s (configurable), daemon runs health check, emits events for state changes. Never kills processes — only updates state and surfaces issues.
3. **Doctor command** — developer-initiated full reconciliation via MCP tool and overlay panel. Returns structured report with offered actions.

## Cross-Platform Mandate

**Every OS-level operation must go through `internal/platform/` and handle all four targets:**

| Operation | Linux/macOS | Windows | WSL |
|-----------|------------|---------|-----|
| PID alive check | `kill(pid, 0)` / `/proc/<pid>` | `OpenProcess` / Job Objects | `kill(pid, 0)` but may need `cmd.exe` for Windows-spawned processes |
| Port ownership | `ss -tlnp` / `lsof -i` | `netstat -ano` / `Get-NetTCPConnection` | `ss` for Linux ports, `netstat.exe` for Windows-bound ports |
| Process group kill | `SIGTERM` → `SIGKILL` to pgid | `CTRL_BREAK_EVENT` → `TerminateJobObject` | Depends on `platform.ShouldUseWindowsShell(path)` |
| Proxy health probe | TCP connect / HTTP GET | Same | Same |
| Rogue process identification | `ss -tlnp` gives PID | `netstat -ano` + `tasklist` | Both paths depending on which OS owns the port |

Existing `platform.IsWSL()` helper in `internal/platform/process_unix.go` (memoized `/proc/version` check for `microsoft`/`wsl`) is canonical WSL detection. New OS-level operations must consult it before using `runtime.GOOS == "linux"` to gate Linux-only behavior — WSL is GOOS=linux but routinely needs Windows-side processes via `tasklist.exe` / `netstat.exe` / `taskkill.exe` interop.

The `ShouldUseWindowsShell(path)` helper now exists (`internal/platform/process_unix.go:53`; Windows stub in `process_windows.go:85`). `ScriptConfig.ResolveShell()` (`internal/config/agnt.go:315`) consults it first: a WSL session with a Windows-path `run` or `cwd` resolves to `cmd.exe /c` so `.cmd`/`.bat` scripts run instead of silently picking `sh -c` and failing. Two `wsl-followup` sub-tasks (parent `5YgALr79bfhf`) remain deferred — `detectPortsForPID` has no `netstat.exe` branch and `internal/overlay/status.go:platformShell` still gates on raw `runtime.GOOS`. See `.claude/rules/wsl-audit.md` for the full audit.

### WSL Awareness — what's wired vs deferred

| Site | Status | File |
|------|--------|------|
| `platform.IsWSL()` detection | Wired | `internal/platform/process_unix.go:24` |
| `platform.ScanWindows()` (`tasklist.exe`) | Wired | `internal/platform/process_unix.go:172` |
| Duplicate scanner appends Windows procs | Wired | `internal/daemon/duplicate_scanner.go:175` |
| `FindPIDsByPort` falls back to `netstat.exe` | Wired (audit landed 2026-05-02) | `internal/config/portdetect_unix.go` |
| `ShouldUseWindowsShell(path)` helper | Wired | `internal/platform/process_unix.go:53` |
| `ResolveShell` picks `cmd.exe` for Windows-path scripts on WSL | Wired | `internal/config/agnt.go:315` |
| `ProcessNameByPID` / `ProcessNamesByPIDs` fall back to `tasklist.exe` | Wired | `internal/config/portdetect_unix.go:336,387` |
| Doctor command attributes Windows-side port owners by name | Wired (batched `tasklist.exe`) | `internal/daemon/doctor.go:209` |
| `taskkill.exe` to kill Windows-side rogue processes | Wired (`KillWindowsPID`; called by port preflight + shutdown) | `internal/platform/killwindowspid_unix.go:36` |
| `detectPortsForPID` falls back to `netstat.exe` | **Not yet** — `wsl-followup` sub-task | `internal/config/portdetect_unix.go` |
| `platformShell` uses `ShouldUseWindowsShell` | **Not yet** — `wsl-followup` sub-task | `internal/overlay/status.go:624` |

### Accepted WSL escape hatches

Intentional behaviors. Look like WSL bugs but aren't:

| Behavior | Why we accept it |
|----------|-----------------|
| `pidAlive` cannot probe Windows PIDs from WSL | We never register Windows PIDs in our process manager — show up only via `ScanWindows()`, read-only for us |
| `directChildren` returns nil for Windows-side parent PIDs | We don't track Windows process trees; descendant cleanup is for our managed processes |
| `normalizePath` is case-sensitive for `/mnt/c/...` paths in WSL | `/mnt/c/Users/Foo` and `/mnt/c/Users/foo` resolve to same NTFS file but Linux treats as distinct paths. Forcing lowercase would collapse two distinct sessions registered under different casings |
| `cleanupStaleFiles` PID file uses Linux layout under WSL | Daemon socket path owned by caller; running daemon under WSL means Linux paths end-to-end |
| Browser launcher doesn't have WSL branch | `BROWSER` env var is WSL-friendly contract; we don't bridge `open` ↔ `cmd.exe /c start` |
| `chromedp` session URL picker is darwin-only | Chrome process discovery only meaningful when we launch chrome ourselves; WSL users typically point at chrome on host |

## Silent Failure Prohibition

No subsystem may silently skip expected action. If config declares proxy, process, or dependency, system must either:
1. Successfully create/start it, OR
2. Emit visible error/warning event reaching AI agent and session log

`debug.Log` not sufficient for failures — only goes to debug file. Failures must propagate through event system or session log.

## Config Authority

If `.agnt.kdl` declares expected state (proxy with `fallback-port`, script with `depends-on`), system must honor it. Config fields parsed but not acted on are bugs.

## Tool session-scoping

**Canonical classification** of every hub query/list verb (and MCP tools driving it) against session-scope chokepoint. Project scoping is *structural* property, not per-handler convention: exactly one resolution point, `resolveProjectScope` (`internal/daemon/hub_helpers.go`), and every non-debug list/query routes through it. Adding new query/list verb without classifying it here — and wiring to gate if non-debug — is a bug.

### The gate contract (`resolveProjectScope`)

Given per-call `DirectoryFilter{Global, SessionCode, Directory}` and connection's bound session code, resolution order:

1. `Global == true` → `("", true, nil)`: no project filter (cross-project).
2. explicit `SessionCode` → that session's project path (error if unknown).
3. explicit `Directory` → normalized directory.
4. otherwise connection's bound session's project path.
5. none of above → `("", false, errNoSessionScope)`: **fail loud**.

Non-global call that cannot resolve a project is rejected with `invalid_args: no session attached …` rather than silently leaking every project's data. Per-call `global` flag always wins. MCP daemon connection not session-bound, so MCP tools name project explicitly via `SessionCode` (preferred) or `Directory` (fallback) — see `collectProcessAlerts` / `handleProcList` for canonical client pattern.

### Uniform `global` override on MCP tools (C6)

Every gated MCP tool exposes **same** override: `global bool` input (`json:"global,omitempty"`, default `false`, documented in jsonschema). `global:true` returns cross-project results; default scopes to caller's session/project. MCP daemon connection not session-bound, so each tool, when non-global, names project explicitly on wire (`SessionCode` preferred, `Directory` fallback) — see `handleProcList` / `handleProxyList` / `handleTunnelList` / `collectProcessAlerts` / `collectProxyErrors` / `collectStartupErrors` / `handleDaemonStartupLog`. Reflection contract test (`internal/tools/global_scope_uniform_test.go`, `TestGatedMCPTools_ExposeGlobalFlagUniformly`) pins that `get_errors`, `proc`, `proxy`, `tunnel`, `session`, and `daemon` (startup_log) all carry the field.

Two tools intentionally do **not** take cross-project `global`, excluded from contract test:

- **`get_incidents`** — incident inbox per-session *hard-isolated* ("Cross-session isolation" numbered contract below). Stronger guarantee than project scoping; cross-session global would violate it.
- **`watch`** — emits `agnt monitor` command string. Monitor stream scoping is separate STREAM-EVENTS concern, not result-returning query, so `global` flag would be no-op (silent no-ops forbidden).

### Gated (must route through `resolveProjectScope`)

Default project-scoped, `global`-overridable, session-less non-global rejected.

| Verb | MCP tool | Filter field | Notes |
|------|----------|--------------|-------|
| `ALERTS QUERY` | `get_errors` (process alerts) | `AlertStoreFilter.ProjectPath` | C4 |
| `ALERTS STARTUP-LOG` | `get_errors` (startup errors), `daemon startup_log` | `StartupLogFilter.ProjectPath` (matched via `basename-hash:` ProcessID prefix — entries not stamped at ingest) | C5 |
| `PROC LIST` | `proc {action:"list"}` | `ProjectPath` compare on each process | C5 (migrated off inline logic) |
| `PROXY LIST` | `proxy {action:"list"}` | `ProjectPath` compare on each proxy | C5 (migrated off inline logic) |
| `TUNNEL LIST` | `tunnel {action:"list"}` | `tunnelm.ListByPath` | C5 (migrated off `getSessionProjectPath` fallback-to-all) |
| `SESSION LIST` | `session {action:"list"}` | `sessionRegistry.List(path, global)` | C5 |
| `SESSION TASKS` | `session {action:"tasks"}` | `scheduler.ListTasks(path, global)` | C5 |
| `INCIDENTS QUERY` | `get_incidents` | per-session inbox partition | pre-existing model gate converges toward |
| `PORTS QUERY` | overview ports panel (`fetchPorts`) | `resolveProjectScope` → declared-port set | classifies owners as managed/unmanaged/conflict; orphans uid-scoped, not project-scoped |

### ID-scoped (single resource addressed by explicit id)

Take resource id, not project filter. Id **lookup** resolves through `getSessionScoped` (`internal/daemon/hub_helpers.go`), restricting fuzzy matching to connection's session project so you cannot address another project's resource by id; exact id always works. No `global` flag — id is the scope.

| Verb(s) | Resolver |
|---------|----------|
| `PROC STATUS` / `OUTPUT` / `STOP` / `RESTART` | id → `ProcessManager` |
| `PROXY STATUS` / `STOP` / `RESTART` / `TOAST` | `getSessionScoped(…, GetWithPathFilter)` |
| `PROXYLOG QUERY` / `SUMMARY` / `CLEAR` / `STATS` | `getSessionScoped(…, GetWithPathFilter)` |
| `CURRENTPAGE LIST` / `GET` / `SUMMARY` / `CLEAR` | `getSessionScoped(…, GetWithPathFilter)` |
| `TUNNEL STOP` / `STATUS` | `getSessionScoped(…, GetWithPathFilter)` |

### Debug-exempt (by design)

Agent supplies explicit `proxy_id` it already holds; these interactive browser-debug surfaces, not cross-project discovery. **Not** project-filtered, but `proxy_id` **lookup** still resolves through `getSessionScoped`, so debug call cannot reach another project's proxy by id.

| Tool | Verb |
|------|------|
| `proxy {action:"exec"}` | `PROXY EXEC` |
| `responsive_audit` | `PROXY EXEC` (script injection) |
| `snapshot` | `PROXY EXEC` |
| `screenshot` | `PROXY EXEC` |
| sketch / design modes | `PROXY EXEC` / panel |
| `channel_reply` | `PROXY TOAST` |

### Why STARTUP-LOG is prefix-matched, not ingest-tagged

`StartupLogEntry` has 59 ingest sites across daemon; stamping project path at each would be invasive and error-prone. Instead entry's `ProcessID` (`makeProcessID(projectPath, name)` → `basename-hash:name`) deterministically encodes project, so scoped query filters by `basename-hash:` prefix (`makeProcessID(projectPath, "")`). Consequence: daemon-wide events with bare (non-project) `ProcessID` — shutdown/scan records — visible only to `global` query, never to scoped one. Intended trade-off.

## Incident Pipeline

Incident pipeline (`internal/incident/`) is opt-in alert path (Phase A, gated by `alerts.incident-pipeline true`) replacing direct `AlertHub` sink dispatch with normalised, deduped, priority-ordered inbox.

### Source of Truth

| State | Source of truth | Notes |
|-------|----------------|-------|
| Inbox entries | Originating subsystem | Inbox is a cache; entry present does not mean event still active |
| Bus in-flight events | Transient MPSC channel | Drop-newest on overflow (`bus.go`, 4096-cap). No replay path. |
| Blob store payloads | In-memory LRU per session | Best-effort: evicted when session ends or 16MB cap reached. Never persisted to disk. |
| Dedup fingerprints | Deduplicator in-process state | Cleared on session teardown; cross-session dedup does not apply |

### Numbered Contracts

1. **Cross-session isolation.** Each session connecting with pipeline enabled gets own `sessionPipeline` instance. Events from session A never appear in session B's inbox, even for same project.

2. **Drop-newest on bus overflow.** MPSC bus drops incoming event (not oldest) when 4096-slot channel full. Keeps latency bounded at cost of losing most recent event under extreme load. Overflow count surfaced via `bus.OverflowCount()`.

3. **Dedup scope per-session, not per-project.** Fingerprint collision in session A does not suppress same event in session B. Deduplicator state owned by `sessionPipeline`, torn down with it.

4. **Coalescer batch window non-configurable at runtime.** Coalesce window (default 200ms) set at `sessionPipeline` construction time from config. Live reconfiguration not supported; daemon restart required to change.

5. **Inbox capacity hard-capped per band.** Each of four priority bands (critical / error / warning / info) holds at most 100 entries. Oldest entries evicted to make room for new arrivals. AI agent must poll with returned cursor to drain inbox before it wraps.

6. **Blob store best-effort.** `BlobRef` in envelope may resolve to `nil` if blob evicted before agent pulled incident. Callers must handle absent blobs gracefully; not errors.

7. **Pinger never blocks delivery.** Pinger sends compact pings to MCP, channel, and PTY sinks using non-blocking channel sends. Slow consumer does not delay other consumers or block Inbox drain loop.

8. **Migration flag all-or-nothing per session.** If `alerts.incident-pipeline` is `false` when session connects, that session uses legacy `AlertHub` path for entire lifetime, even if config file changed mid-session. Pipeline path and legacy path mutually exclusive for given session.

### File Ownership

| Component | File |
|-----------|------|
| `IncidentEvent`, `BlobRef`, `BlobStore` | `internal/incident/envelope.go` |
| Signal source adapters (11 sources) | `internal/incident/adapters.go` |
| `Deduplicator`, `Coalescer`, `FlowController` | `internal/incident/dedup.go` |
| `Inbox` (4 bands, cursor pull, subscribe) | `internal/incident/inbox.go` |
| `Pinger` (subscribe → fan-out pings) | `internal/incident/pinger.go` |
| `get_incidents` MCP tool | `internal/incident/get_incidents.go` |
| Remediation routing table | `internal/incident/routing.go` |
| MPSC bus + `sessionPipeline` | `internal/incident/bus.go` |
| `INCIDENTS QUERY` hub handler, session lifecycle wiring | `internal/daemon/hub_incidents.go` |

## Session Containment

Session owns more than processes explicitly registered with `proc run`. AI agent behind `agnt run` session routinely spawns background work through non-interactive bash — `npm run dev &`, `cargo watch &`, `python manage.py runserver &` — and non-interactive bash does not enable job control. So those jobs inherit PTY child's process group instead of getting own, and daemon has no explicit handle on them. Without containment, session B cannot claim ports session A's backgrounded jobs still hold.

### The Session pgid Invariant

PTY child started by `agnt run` gets own POSIX session via `setsid` (creack/pty does this). Its PID doubles as session pgid, and every descendant process — interactive shells, tool invocations, backgrounded jobs spawned via `sh -c 'cmd &'` — inherits that pgid unless descendant explicitly escapes (see below).

Daemon holds this invariant through three primitives:

1. **Wire-through at registration.** `agnt run` client captures PTY child PID, passes to daemon as `SessionPGID` during `SessionRegister`. Field survives client → protocol → hub handler → registry round trip.
2. **Kill on cleanup.** `CleanupSessionResources` → `doCleanup` calls `killSessionPGID` **before** touching managed processes. Sends SIGTERM to group, waits 2s grace window, escalates to SIGKILL on any survivors. Self-exclusion protects daemon's own PID if it ever (defensively) shares group.
3. **Startup orphan scan.** On `Start()`, daemon walks `/proc` looking for pgids whose leader is dead but members still alive — "daemon crashed mid-session" case — and reaps via same kill primitive. UID-filtered, gated on `session.orphan-pgid-scan` config (default on).

### What Is Caught

| Scenario | Caught? | By which primitive |
|----------|---------|--------------------|
| `npm run dev &` in non-interactive bash | yes | session pgid kill on cleanup |
| `nohup cmd &` (SIGHUP blocked) | yes | pgid kill uses SIGTERM/SIGKILL, not SIGHUP |
| `disown %1` after backgrounding | yes | `disown` affects shell's job table, not pgid |
| Managed `proc run` scripts | yes | ProcessManager path; redundant with pgid kill |
| Leaked pgid after daemon crash | yes | startup orphan `/proc` scan |
| Grandchildren of backgrounded jobs | yes | they inherit pgid transitively |

### Accepted Escape Hatches

These **intentionally** escape session pgid. Represent conscious "I want to survive session shutdown" decision and daemon must not try to track them:

| Escape | Why it escapes | Operator responsibility |
|--------|----------------|------------------------|
| `setsid cmd &` | Creates new session + pgid at exec time | User explicitly asked for detached process; they own cleanup |
| Double-fork daemon (fork → setsid → fork → exit) | Classic Unix daemonization | Same — explicit "become a daemon" pattern |
| `systemd-run --scope`, `systemd-run --user` | Hands process to systemd's cgroup | systemd owns lifetime |
| Container runtimes (`docker run -d`, `podman run -d`) | Container PID1 is runtime, not session | Runtime owns cleanup |
| Processes that re-exec into different uid | `/proc` scan filters by uid | Outside our blast radius |

Each leaves port or resource held after session shutdown, but that's operator's explicit choice. Repro test `TestSessionContainment_SetsidEscapes` asserts `setsid` escapes containment — regression would accidentally reap detached processes, worse than leaking them.

### File Ownership

| Primitive | File |
|-----------|------|
| `KillSessionPGID`, `MembersOfPGID`, `readPGID` | `internal/platform/sessionpgid_unix.go` |
| `ScanOrphanPGIDs` (dead-leader scan) | `internal/platform/orphanpgid_unix.go` |
| `killSessionPGID` wiring + `doCleanup` ordering | `internal/daemon/daemon_session_cleanup.go` |
| `startupOrphanPGIDScan` + config gate | `internal/daemon/daemon_orphan_pgid.go` |
| PTY child PID capture + wire-through | `cmd/agnt/pty_common.go`, `internal/daemon/client.go` (`SessionRegisterWithPGID`) |
| Session struct field | `internal/daemon/session.go` (`SessionPGID`) |
| Primitive-level regression tests | `internal/daemon/daemon_session_pgid_test.go`, `internal/daemon/daemon_orphan_pgid_test.go` |
| End-to-end port-reuse repro | `internal/daemon/daemon_session_containment_test.go` |

### Cross-Platform Note

Session pgid primitives Unix-only (`//go:build !windows`). On Windows, Job Objects already provide equivalent cascade-kill semantics for PTY child tree, and `SessionPGID` is always 0 — `killSessionPGID` is no-op guarded by `pgid <= 1`. Startup orphan scan has three implementations selected by build tag:

| Platform | File | Mechanism |
|----------|------|-----------|
| Linux | `internal/platform/orphanpgid_unix.go` (`//go:build linux`) | `/proc` walk via `Scan()` + `readPGID` from `/proc/<pid>/stat` |
| macOS | `internal/platform/orphanpgid_darwin.go` (`//go:build darwin`) | `sysctl` `KERN_PROC_ALL` via `unix.SysctlKinfoProcSlice` — atomic snapshot with pid/pgid/ppid/uid in one syscall |
| Other Unix (FreeBSD, OpenBSD, etc.) | `internal/platform/orphanpgid_other.go` (`//go:build !windows && !linux && !darwin`) | Stubs return nil — no orphan detection but no false reaping either |

Pure orphan-classification logic shared via `internal/platform/orphanpgid_classify.go` (`//go:build !windows`) and exhaustively tested in `orphanpgid_classify_test.go` so darwin code path verifiable on Linux CI without macOS host. macOS-side verification beyond cross-compile (`GOOS=darwin go build ./...`) requires real darwin runtime to exercise sysctl source; procisolation-tagged test file (`orphanpgid_unix_test.go`) is `//go:build linux && procisolation` because it exercises host-global `/proc` and `kill(2)` directly and depends on Linux PID namespaces (`unshare`) for safe execution.

## Test startup contract

Tests almost never want heavyweight production startup — walking `/proc`, issuing `kill(2)` to whatever PID currently owns a port, replaying persisted proxy state, or spinning up 24-hour update-check ticker. Running any inside unit test either slows suite (hundreds of ms per construction × thousands of daemon instances) or, worse, reaps unrelated host processes owned by same uid.

Daemon solves with two-entry-point split. Both entry points share same `bootstrap()` helper, so test path cannot drift from production:

| Step | `Start()` (production) | `NewForTest(t, cfg)` |
|------|------------------------|----------------------|
| `setupDebugLogging` — rotated log file at `GetLogPath()` | runs | **skipped** |
| `bootstrap()` — `registerCommands` + `SetSessionCleanup` + `hub.Start` + `scheduler.Start` + `urlTracker.Start` + `handleProxyEvents` goroutine + `drainHooks` goroutine | runs | runs |
| `cleanupOrphans` — walks `FilePIDTracker`, kills stale PIDs | runs | **skipped** |
| `startupPortCleanup` — `FindPIDsByPort` + `KillProcessByPort` for every persisted proxy port | runs | **skipped** |
| `startupOrphanPGIDScan("")` — `/proc` walk for orphan pgids | runs (gated by `OrphanScanEnabled`) | **skipped** (belt-and-braces; `OrphanScanEnabled` already defaults to false) |
| `restoreProxies` — replay persisted `ProxyConfig`s into fresh `proxym` | runs | **skipped** |
| `updateChecker.Start` — 24h GitHub poll goroutine | runs (gated by `EnableUpdateCheck`) | **skipped** |
| `t.Cleanup(Stop)` registration | n/a | **registered** — 5s timeout |

### What the split guarantees

- **Production `Start()` byte-for-byte unchanged** — original sequence preserved via shared `bootstrap()` helper. Reviewers who "fix" apparent omission in `NewForTest` by pulling production-only step back in trip `TestNewForTest_StartsUnder100ms` assertion (100ms budget, ~20ms observed on laptop).
- **No build tag needed.** `*testing.T` parameter is fence — production code cannot construct `*testing.T`, so `NewForTest` unreachable from any non-test caller. Compilation cost of pulling in `testing` package negligible for `agnt` binary.
- **`daemontest.New` routes through `NewForTest`**, so every test adopting factory (iter 28, commit `dfcd2a4`) automatically gets fast startup path. Tests specifically exercising `cleanupOrphans`, `startupPortCleanup`, `restoreProxies`, or `startupOrphanPGIDScan` continue calling those methods directly — none private to `Start()`.

### File ownership

| Primitive | File |
|-----------|------|
| `bootstrap()` (shared wiring) | `internal/daemon/daemon.go` |
| `Start()` (production path — bootstrap + heavy ops) | `internal/daemon/daemon.go` |
| `NewForTest(t, cfg)` (test entry point) | `internal/daemon/test_helpers.go` |
| `daemontest.New` (ephemeral socket + opts + cleanup) | `internal/daemontest/factory.go` |
| Timing + hub-accept assertions | `internal/daemon/test_helpers_test.go` |
