# Pre-daemon parity audit

**PRD**: [#92 — Pre-daemon parity audit](../prds/done/92-process-boundary-invariant-audit.md)
**Branch**: `prd-92-process-boundary-invariant-audit`
**Audited**: 2026-05-22
**Baseline**: `2fc39c3` — *"chore: update docs chart to v0.24.7 [skip ci]"*, the last commit before PRD #76 (`4b81c06`) merged. Pre-daemon state.
**Current**: tip of branch (= `main` at `4087df2`, plus the v2 PRD rewrite).
**Methodology**: feature/behavior list built FROM baseline (`git worktree add /tmp/dot-agent-deck-baseline 2fc39c3`); each item verified against current code via code-read and tests. `Explore` agents fanned out across TUI keybindings/dialogs, dashboard/session/modes, CLI commands/env vars, and the quit/lifecycle surface. Triage buckets per PRD #92 v2: **Preserved** / **Regressed** / **Intentional change** / **Unclear**.

## Coverage statement

Baseline sources read:

- `docs/getting-started.mdx`, `docs/installation.md`, `docs/keyboard-shortcuts.md`, `docs/configuration.md`, `docs/session-management.md`, `docs/troubleshooting.md`, `docs/workspace-modes.md` at `2fc39c3`.
- `src/main.rs` at `2fc39c3` for the CLI surface.
- `src/lib.rs` at `2fc39c3` for the module map.
- The 22 shipped PRDs in `prds/done/` at `2fc39c3` (read as titles; spot-checked when a behavior was unclear).
- `tests/integration_test.rs`, `tests/mode_integration_test.rs`, `tests/session_restore_test.rs` at `2fc39c3` (the entire baseline integration suite — 1265 LOC total).

Feature categories covered:

- CLI commands and global flags.
- TUI keybindings (global, tab nav, mode tab, dashboard, dialogs, directory picker, new-pane form).
- Dashboard card display (statuses, density, fields).
- Session lifecycle (save on exit, `--continue` restore, mode-tab restoration).
- Workspace modes (config schema, persistent panes, reactive panes, circular pool).
- Daemon socket / hook ingestion (the in-process daemon's external surface — env vars, file paths).
- Environment variables (`DOT_AGENT_DECK_SOCKET`, `_CONFIG`, `_SESSION`, `_LOG`).
- Auto-install of hooks on TUI startup.
- Idle ASCII art (config keys, CLI standalone).
- Quit / shutdown lifecycle (the F1 worked-example surface).

Post-baseline additions (out of scope per PRD #92; listed in the *Intentional changes appendix* below):

- `daemon serve`, `daemon hello` subcommands.
- `remote add|list|remove|upgrade` and `connect` subcommands.
- New env var `DOT_AGENT_DECK_ATTACH_SOCKET`.
- Auto-spawn of the daemon, idle-shutdown logic, attach protocol versioning.
- `DOT_AGENT_DECK_PANE_ID` const moved into `agent_pty.rs` (refactor only).

A future re-audit can extend this coverage by reading any PRD-shipped feature in `prds/done/` that landed before `2fc39c3` and re-checking parity against current main.

## Worked example: force-shutdown gap (F1)

At baseline, quitting the deck killed every agent — period. `src/main.rs:413–418` in the baseline:

```rust
// TUI exited — clean up
daemon_handle.abort();

if path.exists() {
    let _ = std::fs::remove_file(&path);
}
```

The daemon was an in-process tokio task (`tokio::spawn` at baseline `src/main.rs:378`); the agent PTYs were `tokio` children of the deck process (`pane::detect_multiplexer` returning an in-process `PaneController`). Exiting the TUI aborted the daemon task, removed the hook socket file, and let the deck process exit — taking every agent PTY with it. The baseline quit dialog (`docs/keyboard-shortcuts.md:93`) was a literal **Yes / No** confirmation that quitting would happen.

In current code (post PRD #76 + PRD #93):

- The daemon is fork-execed detached via `setsid(2)` into its own session (`src/daemon_attach.rs:343`); the comment at `src/main.rs:626–630` makes this explicit — *"the daemon was fork-execed detached by `ensure_external_daemon_or_die` (setsid'd into its own session) so it is intentionally outside this process tree: we do not abort the daemon and do not unlink its sockets. Agents must survive TUI exit (PRD #76 line 199)."*
- Agent PTYs are owned by the daemon's `AgentPtyRegistry` (`src/agent_pty.rs:805+`), spawned via `daemon.rs:709–710`, well outside the TUI's process tree.
- The quit dialog was deliberately collapsed to **Detach / Cancel** per PRD #93 M4.2 — *"every pane is daemon-backed so quitting the TUI is always a detach, never a kill"* (`src/ui.rs:1666–1700, 535–536, 599, 5328–5339`).
- The daemon idle-shuts-down only when *both* `clients == 0 AND agents == 0` (`src/daemon.rs:480–551`), so as long as any agent is alive, the daemon stays up — by design (PRD #93 line 32).
- **Neither `DaemonCmd::Stop` nor `RemoteCmd::Stop` exists.** `DaemonCmd` has only `Serve` and `Hello` (`src/main.rs:135–150`); `RemoteCmd` has only `Add`, `List`, `Remove`, `Upgrade` (`src/main.rs:160–209`); `Remove` deregisters a local registry entry, it does not touch the remote daemon.

PRD #93 line 39 anticipated this exact gap: *"User can `dot-agent-deck remote stop` (or equivalent local command) to force shutdown."* The promise was never met. Today's only way to stop the daemon while agents are managed is `pkill dot-agent-deck`. This is the parity regression at the heart of the audit — the same user gesture (quit the deck) used to kill the agents, and now no in-product gesture does.

This is the only Regressed row in the audit. Everything else is Preserved or Intentional change with citation.

## Findings

| # | Baseline feature | Triage | Baseline evidence (`2fc39c3`) | Current evidence | Rationale | Follow-up |
|---|---|---|---|---|---|---|
| 1 | **`dot-agent-deck` runs the TUI dashboard** (two-column: 1/3 dashboard, 2/3 panes) | Preserved | `src/main.rs:155–158`, `docs/getting-started.mdx:60–73` | `src/main.rs:257`, `src/ui.rs:700` (`dashboard_pane_dims` → `[33,67]`); layout tested by `src/ui.rs::dashboard_pane_dims_tiled_divides_main_height`, `::dashboard_pane_dims_stacked_focused_takes_remainder`, `::test_render_empty_state`, `::test_render_wide_grid_layout` | Same entrypoint, same layout proportions. | — |
| 2 | **`--continue` restores last session** (panes from `~/.config/dot-agent-deck/session.toml`; mode tabs restored with their full structure; warn-to-stderr if directory missing or mode renamed) | Preserved-but-untested | `src/main.rs:19–20`, `docs/session-management.md:43–57` | `src/main.rs:25–26, 574–638`; `src/ui.rs:2247, 2600–2700`; `src/config.rs:319` (env override); `tests/session_restore_test.rs` exercises the baseline saved-session round-trip (snapshot + load + restore on a clean state) | Same flag, same on-disk format, same warn-and-skip semantics; the hydration path additionally consults the daemon registry first (PRD #76 M2.11/12), but for `--continue` parity the saved-session file remains authoritative for dir/name/command, deduped against live daemon panes. **Preserved-but-untested**: `tests/session_restore_test.rs` proves the baseline saved-session path; `tests/rehydration.rs` proves the daemon-hydration path independently. No current test exercises the *combined* `--continue` + daemon-hydration dedupe (daemon has agents AND a saved session exists AND the dedupe by `(dir, name, mode)` correctly favors the live daemon entry). This is an evidence gap, not a regression — code-read of `src/ui.rs:2600–2700` confirms the dedupe is in place — but it is unverified by execution. | — |
| 3 | **`--theme` flag** (auto/light/dark) | Preserved | `src/main.rs:25–27` | `src/main.rs:31–33`; `src/config.rs::theme_defaults_to_auto`, `::theme_deserialize_light`, `::theme_get_set_field`; theme dispatch covered by `src/theme.rs::resolve_palette_dispatches_to_palettes` (cited in row 50) | Identical clap definition. | — |
| 4 | **`hook --agent <claude-code\|opencode>`** subcommand reads stdin, sends to daemon socket | Preserved | `src/main.rs:40–44`; `src/hook.rs::send_to_socket` | `src/main.rs:46–49`; `src/hook.rs:243–250`; `tests/integration_test.rs::hook_handler_end_to_end` (line 141, exercises the full stdin→socket→daemon path); `::single_session_lifecycle`, `::opencode_session_lifecycle`, `::mixed_agent_sessions` exercise per-agent dispatch | Same default (claude-code), same wire path. | — |
| 5 | **`hooks install` / `hooks uninstall` (with `--agent`)** | Preserved | `src/main.rs:46–48, 110–124` | `src/main.rs:52–55, 211–225`; `src/hooks_manage.rs::install_adds_hooks_to_empty_settings`, `::install_preserves_existing_hooks`, `::install_is_idempotent`, `::uninstall_removes_only_dot_agent_deck_entries`, `::install_upgrades_old_format_to_new`, `::install_fixes_malformed_matcher`, `::uninstall_noop_on_empty_settings`, `::uninstall_noop_when_no_file` | Same shape, same defaults. | — |
| 6 | **Auto-install hooks on TUI startup** (silent / idempotent / best-effort; missing agent dir silently skipped; Claude Code: SessionStart/SessionEnd/UserPromptSubmit/PreToolUse/PostToolUse/Notification/Stop/PreCompact/SubagentStart/SubagentStop; OpenCode: JS plugin at `~/.opencode/plugin/dot-agent-deck/index.js`) | Preserved | `src/main.rs:393–395`; `docs/troubleshooting.md:51–58` | `src/main.rs:604–605`; Claude Code: `src/hooks_manage.rs::auto_install_skips_when_no_claude_dir`, `::auto_install_installs_when_claude_dir_exists`, `::auto_install_is_idempotent` plus the install/uninstall mechanics cited in row 5; OpenCode: `src/opencode_manage.rs::auto_install_skips_when_no_opencode_dir`, `::auto_install_installs_when_opencode_dir_exists`, `::auto_install_is_idempotent`, `::plugin_template_uses_exec_file_sync`, `::install_creates_plugin_file`, `::detect_opencode_root_prefers_xdg_when_present`, `::detect_opencode_root_falls_back_to_legacy` | Same call site, same idempotent semantics. | — |
| 7 | **`config get` / `config set`** (with `config_keys_help`) | Preserved | `src/main.rs:50–53, 212–238` | `src/main.rs:57–60, 310–336`; `src/config.rs::theme_get_set_field` exercises `get_field` / `set_field` round-trip | Same shape. | — |
| 8 | **`ascii --input --output [--provider --model]`** standalone CLI ASCII art | Preserved | `src/main.rs:55–69, 428–442` | `src/main.rs:62–75, 816–831`; `src/ascii_art.rs::parse_single_frame`, `::parse_multi_frames`, `::parse_trailing_delimiter`, `::parse_empty_input`, `::validate_valid_frame`, `::validate_too_many_lines`, `::validate_line_too_long`, `::validate_non_ascii_rejected`, `::validate_empty_frame` cover the frame parser/validator; `::system_prompt_embedded` covers the prompt wiring | Same flags, same default behavior. The CLI thin-wrapper at `run_ascii` is not directly tested but its body just forwards to `generate_ascii_art` which is covered. | — |
| 9 | **`init [--path]`** generate `.dot-agent-deck.toml` template (does not overwrite existing) | Preserved | `src/main.rs:70–75` | `src/main.rs:77–80, 337`; `src/init.rs::creates_config_file`, `::does_not_overwrite_existing`, `::fails_on_invalid_path` | Same default `.`, same non-destructive semantics, tested directly. | — |
| 10 | **`validate [--path]`** validate config; prints `Config is valid.` on success; lists issues with appropriate exit code | Preserved | `src/main.rs:76–81, 307–337` | `src/main.rs:83–86, 491–521`; `src/config_validation.rs::valid_config_has_no_issues`, `::invalid_regex_produces_error`, `::valid_orchestration_has_no_issues` | Identical output text and exit-code mapping. | — |
| 11 | **`watch --interval <secs> <command>`** periodic re-exec | Preserved-but-untested | `src/main.rs:82–89` | `src/main.rs:89–94, 338–340`; `src/watch.rs` has only `run_watch` with no unit tests | Same shape. Preserved-but-untested: `src/watch.rs` is a thin loop with no test coverage (the body spawns a child and re-execs on a timer, which is awkward to unit-test); the subcommand is exercised in practice via the persistent-pane watch flow covered by `tests/mode_integration_test.rs::reactive_pool_cycling_with_real_config`. | — |
| 12 | **`delegate --task --to <role>...`** orchestrator delegate; requires `DOT_AGENT_DECK_PANE_ID`; fails with specific error if unset; fails if `--to` empty | Preserved | `src/main.rs:90–98, 243–276` | `src/main.rs:97–103, 341–373`; `tests/orchestration_delegate.rs::delegate_writes_task_file_to_each_workers_own_cwd`, `::delegate_wraps_task_with_role_prompt_template`, `::delegate_applies_prompt_template_for_unnamed_orchestration`, `::delegate_does_not_cross_route_between_same_basename_orchestrations`, `::delegate_from_non_orchestrator_is_rejected_daemon_side` | Same shape and error messages; the env var is now imported as a const from `agent_pty.rs` rather than a literal — functionally equivalent. | — |
| 13 | **`work-done --task [--done]`** worker work-done signal; same env-var requirement | Preserved | `src/main.rs:99–107, 277–306` | `src/main.rs:106–112, 375–403`; `tests/orchestration_delegate.rs::work_done_survives_subscriber_detach_and_reattach` | Same shape. | — |
| 14 | **Quit kills agents** (closing the deck process aborts the in-process daemon and removes the hook socket; agent PTYs die with the process) | **Regressed** | `src/main.rs:413–418` (the `daemon_handle.abort()` + `remove_file` block) | Daemon is now setsid'd into its own session (`src/daemon_attach.rs:343`); a plain TUI exit still does NOT kill agents. PRD #92 F1 ships the equivalent user gesture via a new **Stop** option in the Ctrl+C dialog (`src/ui.rs::handle_quit_confirm_key`, secondary `UiMode::StopConfirm` via `src/ui.rs::handle_stop_confirm_key`); selecting Stop sends `KIND_SHUTDOWN` (`src/daemon_protocol.rs:113` — `0x15`) to the daemon, which terminates every managed agent with SIGTERM-then-SIGKILL via `AgentPtyRegistry::shutdown_all_graceful` (`src/agent_pty.rs::shutdown_all_graceful`) and exits. Tests: `src/ui.rs::quit_confirm_stop_with_no_agents_returns_stop_and_quit`, `::quit_confirm_stop_with_agents_prompts_secondary_dialog`, `::stop_confirm_yes_returns_stop_and_quit`, `::stop_confirm_y_shortcut_returns_stop_and_quit`, `::stop_confirm_no_returns_to_primary_dialog`, `::stop_confirm_down_clamps_to_two_options`; `tests/stop_dialog.rs::send_shutdown_returns_cleanly_with_no_agents`, `::send_shutdown_drains_registry_and_signals_notify`, `::double_shutdown_is_idempotent` | The same one-keystroke-to-shutdown gesture from baseline is now reachable again, with the safety improvement of an explicit secondary confirmation when agents are alive. PRD #93 line 39 anticipated this gap; it is now closed. The Regressed triage stays for traceability — the original baseline behavior (quit kills agents *without* a confirmation) is intentionally NOT restored; the F1 design adds friction the baseline did not have. | **F1 (shipped — see PRD #92 Phase 6)** |
| 15 | **Quit confirmation dialog** (Ctrl+C in command mode → Yes/No to quit immediately) | Intentional change | `docs/keyboard-shortcuts.md:93`; `src/ui.rs:1332–1358` | `src/ui.rs::handle_quit_confirm_key` (now 3 options — Detach / Stop / Cancel per PRD #92 F1), `src/ui.rs::render_quit_confirm` (3-option layout), `src/ui.rs::handle_stop_confirm_key` + `::render_stop_confirm` (secondary y/n) | PRD #93 M4.2 collapsed the dialog from Quit/Cancel to Detach/Cancel (Detach as the default — the visible symptom of "daemon now persists across deck exits"). PRD #92 F1 then added Stop as a third option in the middle (Detach default / Stop / Cancel) to restore the pre-daemon "user gesture that takes everything down" without shifting the default. See **Intentional changes appendix § A**. | — |
| 16 | **Ctrl+d (enter command/navigation mode)** | Preserved | `src/ui.rs:2853` (handler) | `src/ui.rs:3645` (sets `UiMode::Normal`); `src/ui.rs::test_mode_transitions` covers Normal-mode entry from the Filter/Help/Rename modes which `Ctrl+d` returns through | Identical. | — |
| 17 | **Ctrl+n (new pane → directory picker → name+command form)** | Preserved-but-untested | `src/ui.rs:2862–2868` | `src/ui.rs:3694–3699` | Identical entry into `DirPicker`. No unit test for the Ctrl+n key transition itself; the downstream `DirPicker` handler and the new-pane form are covered separately (rows 25, 26). | — |
| 18 | **Ctrl+w (close selected pane / tear down mode tab; dashboard tab cannot be closed)** | Preserved-but-untested | `src/ui.rs:2871+` | `src/ui.rs:3702–3759` | Tab-vs-pane branching present in current code. No unit test for the Ctrl+w handler; the mode-tab teardown is exercised indirectly via `tests/mode_integration_test.rs::mode_deactivation_closes_all_panes`. **Note**: the *parity* claim (handler exists, branches correctly) is preserved; the audit-of-the-audit surfaced a separate error-handling gap — the handler discarded `close_pane`'s `Result` and silently destroyed the dashboard card when `StopAgent` failed, leaving the agent alive in the daemon registry. That gap is closed by **F4** (PRD #92 Phase 8 — shipped). The Ctrl+W dispatcher now inspects per-pane close results via `CloseTabOutcome`; failed panes retain their sessions and surface an error in `ui.status_message`. Tests in `tests/close_pane_errors.rs`. | **F4 (shipped — see PRD #92 Phase 8)** |
| 19 | **Ctrl+t (toggle stacked / tiled layout)** | Preserved-but-untested | `src/ui.rs:2807–2815` | `src/ui.rs:3665–3691` | Same toggle. No unit test for the Ctrl+t key handler; the underlying layout calculation is tested via `src/ui.rs::dashboard_pane_dims_tiled_*` / `::dashboard_pane_dims_stacked_*` (cited in row 1). | — |
| 20 | **Ctrl+C in PaneInput delivers SIGINT (0x03)** | Preserved | `src/ui.rs::keyevent_to_bytes` | `src/ui.rs:1470–1480` (`Ctrl+c` → `vec![0x03]`); `src/ui.rs::keyevent_ctrl_c_and_ctrl_a` | Identical encoding, tested. | — |
| 21 | **Tab navigation: Ctrl+PageDown/Up (any mode); Tab/Right/l, Shift+Tab/Left/h (command mode only)** | Preserved-but-untested | `src/ui.rs` (multiple handlers) | `src/ui.rs:3762–3781, 3796–3823` | Same key sets, same gating on Normal-mode-only for the letter/arrow variants. No dedicated unit tests for the tab-navigation key arms. | — |
| 22 | **Mode-tab pane focus: j/k or Down/Up cycle agent↔side panes; Enter enters PaneInput; Esc returns focus to agent** | Preserved-but-untested | `src/ui.rs:3086–3089` (Esc) | `src/ui.rs:3844–3922` | Identical cycling logic. No dedicated unit test for the j/k/Enter/Esc focus cycling in a mode tab. | — |
| 23 | **Dashboard interactive keys: 1–9 jump-to-card, `/` filter, `r` rename, `g` generate-config, `?` help, Esc clears filter** | Preserved | `src/ui.rs:1875–1895, 1730+` | `src/ui.rs:3600–3636, 1875–1895, 1730`; `src/ui.rs::test_mode_transitions` covers `/` → Filter, `?` → Help, `r` → Rename; `::test_normal_esc_clears_filter`, `::test_rename_not_available_when_empty`, `::test_filter_typing`, `::test_filter_esc_clears`, `::test_rename_handler_clears_buffer_and_exits_on_enter`, `::test_rename_handler_preserves_existing_label_on_enter` | All six keys behave the same; `g` still uses the Yes/No/Never three-option dialog and respects `auto_config_prompt`. The plain-digit `1`–`9` arm is covered by row 51's PRD #12 row; the `g` config-gen dialog handler is at `src/ui.rs:1730+` with no dedicated unit test for the Yes/No/Never dispatch. These keys gate UI-state transitions (open filter, open rename, open dialog, toggle help, clear filter, focus card) — not daemon-path operations — so unit-test coverage of the key dispatch arms is the right evidence level. | — |
| 24 | **y / n approve / deny permission request** (documented in baseline help overlay) | Preserved | `src/ui.rs:4531` (help text) but **no handler** for plain `y` / `n` at command mode | `src/ui.rs:5536` (help text) and now a handler: `handle_normal_key` matches `KeyCode::Char('y')` / `KeyCode::Char('n')` when the selected card's status is `WaitingForInput`, returns `KeyResult::SendPermissionResponse(bool)`, and the dispatcher writes the literal `y` / `n` to the pane PTY via `write_to_pane`. Tests: `src/ui.rs::permission_y_on_waiting_for_input_returns_approve`, `::permission_n_on_waiting_for_input_returns_deny`, `::permission_y_n_on_non_waiting_status_is_no_op`, `::permission_y_n_with_no_card_selected_is_no_op` | Baseline help text documented `y` / `n` approve/deny but no handler existed at baseline either (only `KeyCode::Char('n')` triggers Ctrl+n new-pane); current code now matches the help text via PRD #92 Phase 5 (shipped). The pre-existing PRD #18 defect that **F2** captured is now closed. | **F2 (shipped — see PRD #92 Phase 5)** |
| 25 | **Directory picker keys** (j/k navigate, l/Enter enter, h/Backspace up, Space confirm, `/` filter, Esc clear, `q` cancel; loops; `..` always visible) | Preserved | `src/ui.rs` (DirPicker handler) | `src/ui.rs:2090–2125`; `src/ui.rs::dir_picker_refilter_matches_case_insensitive`, `::dir_picker_parent_entry_always_present`, `::dir_picker_selection_wraps`, `::dir_picker_filter_typing_narrows_entries`, `::dir_picker_backspace_clears_filter_when_empty`, `::dir_picker_filter_esc_clears_text`, `::dir_picker_esc_clears_then_closes_picker`, `::dir_picker_refresh_resets_filter_on_navigation`, `::dir_picker_enter_confirms_when_no_subdirs`, `::dir_picker_filter_mode_q_cancels_picker` | Same eight keys present and tested with 10 unit tests. | — |
| 26 | **New-pane form keys** (Tab/Shift+Tab cycle fields; Left/Right/h/l cycle mode selector; Enter confirm; Esc cancel) | Preserved-but-untested | `src/ui.rs` (form handler) | `src/ui.rs:2159–2228` | Identical. No dedicated unit test for the new-pane form key dispatch. | — |
| 27 | **Filter dialog** (type to narrow, Backspace delete, Enter accept-and-stay-filtered, Esc clear+close) | Preserved | baseline tests cover this | `src/ui.rs:1901–1924`; `ui.rs::test_filter_typing, ::test_filter_esc_clears` | Same semantics, tested. | — |
| 28 | **Rename dialog** (type, Enter confirm, Esc cancel) | Preserved | baseline tests | `src/ui.rs:1994–2019`; `ui.rs::test_rename_handler_*` | Same, tested. | — |
| 29 | **Help overlay dismiss with `?` / Esc / `q`** | Preserved | baseline | `src/ui.rs:1926–1934`; `src/ui.rs::test_mode_transitions` covers `?` → Help → Esc → Normal | All three dismiss keys present, covered by the mode-transitions test for Esc; `?` and `q` dispatch through the same handler. | — |
| 30 | **Six session statuses** (Thinking / Working / Compacting / WaitingForInput / Idle / Error) | Preserved | `src/state.rs:15–22` | `src/state.rs:17–24`; `src/state.rs::full_session_lifecycle`, `::waiting_for_input_status`, `::ask_user_question_shows_waiting_for_input`, `::tool_end_clears_waiting_for_input`, `::toolstart_does_not_override_waiting_for_input`, `::toolstart_sets_working_when_not_waiting`, `::error_status`, `::permission_request_sets_waiting_for_input` exercise each status transition | Identical enum, exercised by 8+ state-machine tests. | — |
| 31 | **Card display fields** (Title row with card number + display name + animated dot + status label; `Dir:` truncated basename; `Last:` elapsed time + `Tools:` count; `Prmt:`; recent tool calls) | Preserved | `src/ui.rs:4776–5046` | `src/ui.rs:5781–6051`; `src/ui.rs::test_render_empty_state`, `::test_render_with_sessions`, `::test_recent_tool_lines`, `::test_prompt_display_in_card`, `::test_render_wide_grid_layout`, `::test_format_elapsed`, `::test_status_style`, `::test_flash_dot` | All fields present in the same shape, exercised by 8 rendering tests. | — |
| 32 | **Card density auto-selection** (Spacious: 3+3; Normal: 1+3; Compact: 1+1) | Preserved | `src/ui.rs:50–100` | `src/ui.rs:50–100`; `src/ui.rs::test_choose_density_wide`, `::test_choose_density_narrow` | Same thresholds, tested. | — |
| 33 | **Session save on exit** (panes' dir/name/command saved automatically) | Preserved | `SavedSession::snapshot`; `tests/session_restore_test.rs` | `tests/session_restore_test.rs::snapshot_preserves_mode_field_when_called_pre_teardown`, `::snapshot_prunes_externally_closed_panes`, `::snapshot_save_load_round_trips_mode_field`, `::load_legacy_session_without_mode_field_parses_with_none` exercise `SavedSession::snapshot` shape and round-trip; `src/config.rs::saved_session_round_trip`, `::saved_session_load_save_clear`, `::saved_session_empty_default`, `::saved_session_deserialize_empty` cover the on-disk format | The on-disk format is unchanged; the deduplication against the live daemon registry on `--continue` is an additive layer that does not alter the saved-side schema. | — |
| 34 | **Mode tab restoration on `--continue`** (each agent pane records its mode; reopens full mode tab — agent + side panes — by looking up `.dot-agent-deck.toml`; agent conversation NOT restored; warn-to-stderr if config missing or mode renamed) | Preserved-but-untested | baseline `src/ui.rs:1909–1927` (fallback warn) | `src/ui.rs:2295–2340` (daemon-hydration path seeds `ui.pane_metadata` with `mode_hint` from `TabMembership::Mode`); `src/ui.rs:2324` (`TabMembership::Mode { name }` lookup); `tests/session_restore_test.rs::save_then_restore_recreates_side_panes` exercises the saved-session restore independently of the daemon (uses a `MockPaneController`); `tests/daemon_protocol.rs::start_agent_with_mode_membership_round_trip` exercises the daemon-side `TabMembership::Mode` round-trip independently of any saved session | Same fallback, same restored shape. Daemon-side tab_membership (M2.12) is additive — it lets the daemon's own registry rebuild tabs on attach; the saved-session file remains the authority for restart cases (and wins by `(dir, name, mode)` dedupe when both sources have the same tuple, per `src/ui.rs:2317–2335`). **Preserved-but-untested**: the saved-session-only path and the daemon-hydration-only path each have dedicated tests, but no current test exercises the *combined* scenario (daemon has agents with `TabMembership::Mode` AND a saved session file with `mode = Some(...)` exists AND `--continue` runs AND the dedupe correctly favors / merges them). Same structural gap as row 2. Evidence shortfall, not a regression — code-read confirms the combined-path logic is in place. | — |
| 35 | **Session/config file paths** (`~/.config/dot-agent-deck/session.toml`, overridable via `DOT_AGENT_DECK_SESSION`; `~/.config/dot-agent-deck/config.toml` overridable via `DOT_AGENT_DECK_CONFIG`) | Preserved | `src/config.rs:255–265` | `src/config.rs:312–322`; `src/config.rs::saved_session_round_trip`, `::saved_session_load_save_clear`, `::saved_session_empty_default`, `::saved_session_deserialize_empty` cover the session file shape and env-var-aware load/save | Same env-var overrides, same default paths. | — |
| 36 | **`DOT_AGENT_DECK_SOCKET` env var** (hook ingestion socket path; baseline default `$XDG_RUNTIME_DIR/dot-agent-deck.sock` or `/tmp/dot-agent-deck.sock`) | Intentional change | `src/config.rs:52–62` at baseline | `src/config.rs:52–68` in current | The env var is still honored as an override and the XDG_RUNTIME_DIR default is unchanged. The `/tmp` fallback path was changed to include a uid suffix (`/tmp/dot-agent-deck-{uid}.sock`) for multi-user host safety. Introduced in commit `48b9180` (the PRD #93 squash-merge); rationale lives in the inline code comment at `src/config.rs:61–67` (and the mirror at `:85–91` for `attach_socket_path`). The doc-default in `docs/configuration.md:22` is now slightly out-of-date; not a behavior regression. See **Intentional changes appendix § B**. | — |
| 37 | **`DOT_AGENT_DECK_LOG` env var** (empty/`1` → `/tmp/dot-agent-deck.log`; any other value used verbatim) | Preserved-but-untested | `src/main.rs:346–351` | `src/main.rs:535–540` (`init_logging_from_env` helper) | Identical default + override behavior. No unit test for `init_logging_from_env`; it side-effects a global `tracing_subscriber` so testing it cleanly would require subprocess isolation. | — |
| 38 | **Workspace modes** (`[[modes]]` with `name`, optional `init_command`, `panes`, `rules`, `reactive_panes` default 2) | Preserved | `src/project_config.rs:38–42` | `src/project_config.rs:38–42`; `src/project_config.rs::parse_valid_full_config`, `::parse_minimal_config`, `::load_valid_file`, `::reactive_panes_defaults_to_two`, `::reactive_panes_configurable`; `tests/mode_integration_test.rs::load_real_config_and_verify_structure`, `::load_real_config_and_activate_mode` | Identical schema. | — |
| 39 | **Persistent panes** (`[[modes.panes]]` with `command`, optional `name`, `watch` default true; watch re-execs every 10s via built-in `watch`) | Preserved | `src/project_config.rs:51–55` | `src/project_config.rs:51–55`; `src/project_config.rs::pane_watch_defaults_to_true`, `::pane_watch_can_be_set_to_false`, `::pane_name_defaults_to_none`, `::watch_defaults_to_false`; `tests/mode_integration_test.rs::activate_creates_persistent_and_reactive_panes`, `::all_persistent_mode_works` | Same, tested. | — |
| 40 | **Reactive panes / rules** (`[[modes.rules]]` with `pattern` regex, `watch`, `interval`; start empty; populate on regex match) | Preserved | `src/project_config.rs:62` | `src/project_config.rs:62`; `src/mode_manager.rs::handle_command_matches_first_rule`, `::no_match_returns_none`, `::invalid_regex_returns_error`, `::handle_command_no_active_mode_errors`; `tests/mode_integration_test.rs::end_to_end_command_routing`, `::all_reactive_mode_works`, `::invalid_regex_in_config_fails_activation` | Same, tested end-to-end. | — |
| 41 | **Circular pane pool** (persistent first; reactive cycle; oldest reused when full) | Preserved | `src/mode_manager.rs:163, 335–345` | `src/mode_manager.rs:194–196, 396–412` | Same algorithm; covered by `tests/mode_integration_test.rs::reactive_pool_cycling_with_real_config`. | — |
| 42 | **PaneInput in side pane** (Enter → type into the pane's shell; Ctrl+C SIGINT; Ctrl+d exits) | Preserved | `src/ui.rs:1324–1330` | `src/ui.rs:3890–3915`; submit-key parity via `src/pane_input.rs` | The submit-key dance was lifted out of `embedded_pane.rs` into `pane_input.rs` (PRD #93 round 5) but is functionally identical at the user surface; tested by `tests/pane_input.rs`. | — |
| 43 | **Idle ASCII art** (opt-in `idle_art.enabled`; provider/model/timeout_secs config; only in Spacious density; CLI standalone via `ascii`) | Preserved | `src/config.rs:112, 181, 183`; `src/ui.rs::update_idle_art` | `src/config.rs:169, 181, 238–240`; `src/ui.rs:6246–6248`; `src/ui.rs::test_update_idle_art_gated_on_config_disabled`, `::test_update_idle_art_gated_on_density`, `::test_update_idle_art_waiting_before_timeout`, `::test_update_idle_art_reset_on_active`, `::test_update_idle_art_reset_on_new_idle_stretch`, `::test_update_idle_art_removes_stale_sessions`, `::test_build_art_input_combines_prompts`, `::test_build_art_input_deduplicates_last_prompt`, `::test_build_art_input_empty`, `::test_build_art_output_with_tools`, `::test_build_art_output_no_tools`, `::test_frame_cycling`, `::test_idle_art_has_art_cached`, `::test_idle_art_failed_stays_failed_within_cooldown` | Same config keys, same density gate, same standalone CLI, strong test coverage on the idle-art update logic. | — |
| 44 | **Version update notification** (PRD #22 — background `version::check_for_update()` polls the latest release and surfaces `update_available` on the dashboard) | Preserved-but-untested | `prds/done/22-version-update-notification.md`; baseline `src/main.rs:384–389` spawns the check; baseline `src/ui.rs:482, 551, 2169–2170` carries `update_available` on `UiState` and mirrors it from the `AppState` snapshot | `src/main.rs:594–599` (same spawn-and-store pattern); `src/ui.rs:510, 586, 3019–3020` (mirror), `src/ui.rs:5297` (rendering in the dashboard footer) | Same background-poll-and-render shape. No dedicated unit test in `tests/`; the network probe and the dashboard-footer render path are both exercised only as a side effect of the running TUI. | — |
| 45 | **Aggregate stats bar** (PRD #17 — single status bar across the bottom of the dashboard summarizing session counts by status) | Preserved-but-untested | `prds/done/17-aggregate-stats-bar.md`; baseline `src/ui.rs:3764, 3852` (call sites) and `render_stats_bar` defined earlier | `src/ui.rs:4765, 4853` (call sites); `src/ui.rs:5169–5214` (`fn render_stats_bar`); the aggregate-count source `AppState::aggregate_stats` is tested by `src/state.rs::aggregate_stats_empty`, `::aggregate_stats_mixed_sessions`, `::aggregate_stats_tool_count_summation` | Same single-bar layout, called from the same two render contexts. The underlying stat *computation* is tested, but `render_stats_bar` itself has no dedicated rendering unit test. | — |
| 46 | **Star repo reminder** (PRD #37 — periodic dismissible dialog asking the user to star the GitHub repo; supports snooze and permanent dismiss) | Preserved | `prds/done/37-star-repo-reminder.md`; baseline `src/ui.rs:116` (`UiMode::StarPrompt`), `:498, :559` (`star_prompt_state`), `:1360–1379` (`handle_star_prompt_key` with `dismiss_permanently` + `snooze`), `:1882–1886` (`StarPromptState::load` + activation), `:3107` (mode dispatch) | `src/ui.rs:116` (mode), `:526, :594` (`star_prompt_state`), `:1703` (`handle_star_prompt_key`), `:2272–2276` (load + activation), `:3981` (mode dispatch), `:4901` (rendering); `StarPromptState` itself is tested by `src/config.rs::star_prompt_default_values`, `::star_prompt_serde_round_trip`, `::star_prompt_serde_missing_fields`, `::star_prompt_increment_and_check_triggers_at_10`, `::star_prompt_snooze_resets_window`, `::star_prompt_dismiss_permanently`, `::star_prompt_load_save_cycle` | Same mode, same handler, same snooze / dismiss-permanently semantics; the `StarPromptState` model is tested with 7 unit tests covering the trigger, snooze, dismiss, and persistence paths. The `handle_star_prompt_key` dispatcher itself has no dedicated unit test (would just be a thin wrapper over the state methods that are already covered). | — |
| 47 | **Terminal bell notifications** (PRD #8 — emit a bell when an agent transitions to `WaitingForInput`; per-status toggles via `BellConfig`; no repeat for unchanged status; cleanup on session removal) | Preserved | `prds/done/8-terminal-bell-notifications.md`; baseline `src/ui.rs:1084` (`compute_bell_needed`), `:2327` (call site) | `src/ui.rs:1410` (`compute_bell_needed`), `:3177` (call site); 8 unit tests at `src/ui.rs:8252–8351` (`bell_on_transition_to_waiting`, `bell_no_repeat_same_status`, `bell_respects_config_toggle_off`, `bell_respects_config_toggle_on`, `bell_disabled_globally`, `bell_multiple_transitions_single_bool`, `bell_cleanup_removed_sessions`, `bell_new_session_triggers`) | Same `compute_bell_needed` signature and behavior, called from the same render-loop site, with strong unit-test coverage. | — |
| 48 | **OpenCode sessions on the dashboard** (PRD #30 — OpenCode sessions render with the `OpenCode` label and OpenCode hook events flow through the same `AgentEvent` pipeline as Claude Code) | Preserved | `prds/done/30-opencode-agent-support.md`; baseline `AgentType::OpenCode` variant in `src/event.rs`, label rendering in `src/ui.rs` | `src/event.rs:26` (variant), `:38–48` (`AgentType::from_command` matches `"opencode"`); `src/ui.rs:33` (`AgentType::OpenCode => write!(f, "OpenCode")`); enum/serde-level: `src/event.rs::agent_type_from_command_recognizes_opencode` (line 230), `::parse_open_code_event` (line 256, validates the `"open_code"` serde tag end-to-end); end-to-end OpenCode-in-dashboard: `tests/integration_test.rs::opencode_session_lifecycle` (exercises the full hook→daemon→AppState pipeline for an OpenCode session), `::mixed_agent_sessions` (verifies Claude Code and OpenCode coexist on the dashboard) | Same `AgentType::OpenCode` enum variant; same `"open_code"` wire tag; same dashboard label rendering. The JS plugin install path is covered by row 6 (auto-install) and the CLI dispatch by rows 4–5 (`hook --agent`, `hooks install --agent`); this row covers the user-visible OpenCode-shows-up-in-the-dashboard behavior specifically. | — |
| 49 | **Always-create-pane-card placeholder** (PRD #54 — every newly-spawned pane immediately gets a dashboard card via a placeholder `SessionState` with the `"No agent"` label, so a user who returns to the dashboard before launching an agent can still see / select / close the pane; the placeholder transitions seamlessly to a real session when the first `SessionStart` event arrives) | Preserved | `prds/done/54-always-create-pane-cards.md`; baseline `src/state.rs::insert_placeholder_session` + `register_pane`, baseline `src/ui.rs` `KeyResult::NewPane` handler around `:1783` plus `Ctrl+w` close path | `src/state.rs:175` (`register_pane`), `:179–219` (`insert_placeholder_session` with doc comment "*until a real `SessionStart` hook fires*"), `:485–528` (session-reuse / placeholder transition in `apply_event`), `:220` (`unregister_pane`); tests `src/state.rs::placeholder_session_created` (line 1079), `::placeholder_session_carries_supplied_agent_type` (line 1099, PRD #76 M2.13 extension), `::placeholder_session_defaults_to_none_when_agent_type_unknown` (line 1121) | Same placeholder-on-pane-create, same `"No agent"` label, same `SessionStart`-triggered transition. The M2.13 hydration extension is additive — placeholder now optionally carries `agent_type` from the daemon registry so a reconnected pane shows `ClaudeCode` / `OpenCode` instead of `"No agent"` (`src/ui.rs:2304` comment), but the baseline behavior is preserved when no daemon-side metadata exists. | — |
| 50 | **Light / dark theme palette switching** (PRD #27 — auto-detect terminal background via OSC 11 query, swap a small "neutral text colors" palette (White↔Black titles, Gray↔DarkGray labels); accent ANSI colors (Cyan/Green/Yellow/Red/Blue/Magenta) unchanged; fallback to dark on detection failure; `--theme auto\|light\|dark` and `theme:` config-file option override the detection) | Preserved | `prds/done/27-light-theme-option.md`; baseline `src/theme.rs` with `ColorPalette::dark`/`::light`, `resolve_palette(Theme)`, and `detect_palette()` using `terminal_colorsaurus`; baseline `src/main.rs:25–27` (CLI flag) and `src/config.rs::theme` field | `src/theme.rs:44–94` (`ColorPalette` struct, `dark`/`light` constructors, `resolve_palette`), `:99–109` (`detect_palette` calling `terminal_colorsaurus::color_palette`, falling back to dark on failure); `src/main.rs:609` (`resolve_palette(effective_theme)`); tests `src/theme.rs::dark_palette_uses_expected_colors` (line 152), `::light_palette_uses_expected_colors` (line 163), `::resolve_palette_dispatches_to_palettes` (lines 174–187) | Same palette structure, same constructor names, same `terminal_colorsaurus`-based detection, same dark-on-failure fallback. The accent-ANSI-colors-unchanged contract is preserved by simply not having those colors in `ColorPalette` (they're emitted directly from rendering code). | — |
| 51 | **Dashboard UI improvements: Dir-ellipsis truncation and plain-digit deck selection** (PRD #12 — `Dir:` field truncates with `…` when it would overlap `Last`/`Tools` on the same row; plain `1`–`9` (no Alt modifier) selects and focuses deck N in Normal mode, alongside the historical Alt+N keybinding) | Preserved-but-untested | `prds/done/12-dashboard-ui-improvements.md`; baseline `src/ui.rs` `render_session_card` with `truncate_with_ellipsis` helper, baseline `handle_normal_key` plain-digit arm using shared `focus_deck` helper | `src/ui.rs:1780` (`truncate_with_ellipsis` helper), `:6014` (call site for `Dir:`), `:1795` (`focus_deck` shared helper), `:3620–3637` (plain-digit arm: `if ui.mode == UiMode::Normal && let KeyCode::Char(c @ '1'..='9') = key.code && key.modifiers == KeyModifiers::NONE`, dispatching to `focus_deck`); Alt+N path also calls `focus_deck` so the shared-helper invariant from the PRD holds | Same two behaviors, same shared `focus_deck` helper, same ellipsis truncation. Plain digits are gated on `Normal` mode only (Filter/Rename input still consume them), matching the PRD's non-goal note. No dedicated unit test for either `truncate_with_ellipsis` or the plain-digit key arm; the truncation helper is exercised indirectly by `::test_render_wide_grid_layout` and `::test_render_with_sessions`. | — |

### Counts

- Preserved: 48 (35 with current-test citation + 13 Preserved-but-untested)
  - Preserved (with current-test citation): 35 (rows 1, 3–10, 12–13, 16, 20, 23–25, 27–33, 35, 38–43, 46–50)
  - Preserved-but-untested: 13 (rows 2, 11, 17–19, 21–22, 26, 34, 37, 44–45, 51)

(Earlier revisions of this audit carved row 24 out as a "no-handler parity" case because no `y` / `n` handler existed in either baseline or current code. PRD #92 Phase 5 shipped the handler plus 4 unit tests, so row 24 is now an ordinary Preserved-with-test-citation entry. The carve-out language is retained here for historical traceability — see the F2 follow-up below, marked **Shipped**.)
- Regressed: 1 (row 14 / **F1**)
- Intentional change: 2 (rows 15, 36)
- Unclear: 0

Total: 51 rows. Row 24's underlying pre-existing defect is captured by **F2**.

Note on sub-counts: "Preserved-but-untested" is a label-level relaxation per the PRD's evidence rule, not a separate triage bucket. It is a sub-classification *under* Preserved, used when the behavior matches baseline but the verification rests on code-read rather than test execution.

## Follow-up milestones to file

The user reviews and authorizes filing separately. Drafts (2–3 sentences each).

### F1 — Add a force-shutdown gesture (shipped as Ctrl+C dialog "Stop")

**Problem**: At baseline, quitting the deck killed every agent. After the PRD #76 + PRD #93 pivots, agents are owned by a setsid'd daemon process that intentionally outlives the TUI, idle-shutdown is gated on `agents == 0`, and the quit dialog was deliberately collapsed to Detach/Cancel — leaving no in-product gesture that stops a running deck installation. PRD #93 line 39 anticipated needing a force-shutdown command but neither `DaemonCmd::Stop` nor `RemoteCmd::Stop` shipped.

**Status**: **Shipped** via PRD #92 Phase 6 (M6.1 design lock / M6.2 implementation / M6.3 tests). Implementation:

- Primary Ctrl+C dialog now has three options — Detach (0, default) / Stop (1) / Cancel (2) — in `src/ui.rs::handle_quit_confirm_key` and rendered by `src/ui.rs::render_quit_confirm`. Detach stays the default so the existing muscle memory does not become destructive.
- Picking Stop with 0 managed agents proceeds directly to `KeyResult::StopAndQuit`. With ≥1 agents, the TUI transitions to a secondary `UiMode::StopConfirm` y/n dialog (`src/ui.rs::handle_stop_confirm_key`, `src/ui.rs::render_stop_confirm`) that defaults to **No** and names the agent count in the prompt.
- `KeyResult::StopAndQuit` calls `EmbeddedPaneController::shutdown_daemon` (`src/embedded_pane.rs::shutdown_daemon`), which sends a new `KIND_SHUTDOWN` (`0x15`, `src/daemon_protocol.rs:113`) frame via `DaemonClient::send_shutdown` (`src/daemon_client.rs::send_shutdown`).
- Daemon-side: `handle_connection` in `src/daemon_protocol.rs` short-circuits `KIND_SHUTDOWN` before the usual `KIND_REQ` decoding, calls `AgentPtyRegistry::shutdown_all_graceful(Duration::from_secs(3))` (`src/agent_pty.rs::shutdown_all_graceful` — SIGTERM each child, poll `try_wait` up to 3s, then SIGKILL survivors via the existing teardown), and signals the daemon's shutdown `Notify`. The hook loop exits, `run_daemon_with` returns, and the registry's Drop calls `shutdown_all` as the backstop.
- Idempotency: `AgentPtyRegistry::shutting_down: AtomicBool` latches on first entry so a second `KIND_SHUTDOWN` (or a SIGTERM-triggered drop racing the protocol handler) is a no-op.

**Followup hardening (2026-05-23) — reviewer + auditor pass:**

- `KIND_SHUTDOWN_ACK = 0x16` frame added (`src/daemon_protocol.rs`); daemon writes it before beginning teardown. `PROTOCOL_VERSION` bumped to 2. Closes a reviewer-blocker upgrade-mismatch silent-failure where the original "socket close == ack" wire was indistinguishable from an old daemon closing the connection on an unknown frame.
- `DaemonClient::send_shutdown` waits up to 1s for the ack; timeout / EOF / unexpected-frame all return `Err`. The TUI's `KeyResult::StopAndQuit` arm now stays in Normal mode on Err, surfaces the error via `ui.status_message` ("Stop failed: ... — daemon may be incompatible or unresponsive. Try Detach or restart the daemon manually."), and lets the user retry, Detach, or `pkill`.
- Malformed `KIND_SHUTDOWN` (non-empty payload) rejected daemon-side (auditor #1).
- `StartAgent` during shutdown refused daemon-side via new `AgentPtyRegistry::is_shutting_down()` getter (auditor #2).
- `pid_to_pgid` helper guards both `killpg` call sites against `pid == 0` (would signal the daemon's own group) and `pid > i32::MAX` (would overflow the `u32 → i32` cast); falls back to `child.kill()` on `None` (auditor #3).
- `tests/stop_dialog.rs::close_pane_does_not_signal_daemon_process_group` (in `tests/process_group_kill.rs`) probes the daemon's pgid before / after a close to verify `killpg` targets the child group, not the daemon's (auditor #5).

Tests:

- Unit (in `src/ui.rs`): `quit_confirm_stop_with_no_agents_returns_stop_and_quit`, `quit_confirm_stop_with_agents_prompts_secondary_dialog`, `quit_confirm_down_clamps_to_three_options` (updated from the 2-option version), `quit_confirm_cancel_returns_to_normal_mode` (updated for index 2), `stop_confirm_defaults_to_no`, `stop_confirm_yes_returns_stop_and_quit`, `stop_confirm_y_shortcut_returns_stop_and_quit`, `stop_confirm_no_returns_to_primary_dialog`, `stop_confirm_down_clamps_to_two_options`.
- Unit (in `src/agent_pty.rs`, followup): `pid_to_pgid_accepts_positive_normal_pid`, `pid_to_pgid_rejects_zero_pid`, `pid_to_pgid_accepts_max_i32_pid`, `pid_to_pgid_rejects_overflowing_u32_pid`.
- Integration (`tests/stop_dialog.rs`): `send_shutdown_returns_cleanly_with_no_agents`, `send_shutdown_drains_registry_and_signals_notify`, `double_shutdown_is_idempotent`; followup: `send_shutdown_times_out_without_ack`, `send_shutdown_errors_on_eof_without_ack`, `send_shutdown_errors_on_unexpected_frame`, `kind_shutdown_with_payload_is_rejected_by_daemon`, `start_agent_during_shutdown_is_refused`.
- Integration (`tests/process_group_kill.rs`, followup): `close_pane_does_not_signal_daemon_process_group`.

Scope notes intentionally deferred — filed as **F6** below (renumbered from F4 when the audit-of-the-audit surfaced F4 / F5 as in-scope for this PRD):

- No CLI command this round. `dot-agent-deck stop` (and the matching `remote stop <name>` for remote daemons) are anticipated by PRD #93 line 39 but were not part of F1's gesture-restoration scope.
- Remote-daemon shutdown is also deferred to F6. The dialog Stop only affects the daemon the TUI is attached to.

### F4 — Ctrl+W respects `close_pane` errors

**Problem**: Auditor-found post-PRD-#76 regression. `EmbeddedPaneController::close_pane` is explicitly designed to return `Err` when the daemon's `StopAgent` RPC fails or times out (the controller restores the local pane state internally so the user can retry — `tests/daemon_attach_cleanup.rs::ctrl_w_stop_agent_timeout_restores_pane_and_returns_error` pins this contract). The TUI ignored the result: `src/ui.rs:3736–3780` did `let _ = pane.close_pane(pane_id)` and then unconditionally removed the dashboard session, card, and pane metadata. Outcome: a failed StopAgent left the agent alive in the daemon registry while the card vanished from the dashboard — the user had no way to see the agent and no way to retry the close. The group-close paths (`TabManager::close_tab`, `ModeManager::deactivate_mode`) had the same shape but worse: a single failed pane silently destroyed the whole tab.

**Status**: **Shipped** (PRD #92 Phase 8 — M8.1 / M8.2 / M8.3).

- **M8.1 single-pane (`src/ui.rs`)**: The Ctrl+W arm now matches on `pane.close_pane(pane_id)`. On `Ok(())` the session, card, and pane metadata are removed as before. On `Err(e)` they stay, a `tracing::warn!` records the error, and `ui.status_message` is set to a retry-hinting message (`"Failed to close pane {id}: {e} — press Ctrl+W to retry"`).
- **M8.2 group close**: New [`CloseTabOutcome`](../src/pane.rs) type in `src/pane.rs` with `closed: Vec<String>` and `failed: Vec<(String, String)>`. `TabManager::close_tab` (`src/tab.rs`) and `ModeManager::deactivate_mode` (`src/mode_manager.rs`) now return per-pane outcomes; the Ctrl+W dispatcher builds a `HashSet<&str>` from `outcome.closed` and uses it to filter session removal, so failed panes keep their dashboard cards. `tracing::warn!` per failure plus an aggregate status-bar message ("Close partially failed for N pane(s) — first error: ...").
- **M8.3 tests (`tests/close_pane_errors.rs`)**: 4 new integration tests cover the happy path (no failures), orchestration group-close with one failing pane (split between `closed` and `failed`), mode-tab teardown where the agent pane close fails (side panes still close, agent retains its card), and `ModeManager::deactivate_mode` directly with one failing persistent pane. All 4 pass.

### F5 — process-group kill semantics

**Problem**: Auditor-found defect (possibly pre-existing — baseline's `pane.child.kill()` had the same single-PID limitation, but the user-visible symptom is now sharper because the daemon outlives the TUI). Commands launched via `$SHELL -c <cmd>` register the shell's PID, not the actual agent's. `force_kill_child_and_wait` sent `SIGKILL` to that recorded PID only — direct child only, no process-group semantics. The shell died; the shell's children (the actual agent, plus anything the agent spawned — language servers, file watchers, etc.) were orphaned and re-parented to PID 1. Daemon and TUI both thought the kill succeeded. `tests/local_attach.rs:257–299` only asserted the recorded child PID dies, not its descendants, so the test suite missed this.

**Status**: **Shipped** (PRD #92 Phase 9 — M9.1 / M9.2 / M9.3).

- **M9.1 spawn-side process-group setup**: no code change required. `portable-pty` already calls `setsid()` in its `pre_exec` on Unix (`portable-pty-0.8.1/src/unix.rs:220`), so every PTY-spawned child is a session leader by construction — its PID equals its session ID and its process-group ID. The original F5 plan anticipated adding a `setpgid(0, 0)` `pre_exec` hook to the spawn path in `src/agent_pty.rs`, but that step would have been redundant.
- **M9.2 kill-side update**: `force_kill_child_and_wait` (used for single-pane Ctrl+W via StopAgent, and indirectly by `shutdown_all` from Drop) now calls `libc::killpg(pid as i32, SIGKILL)` instead of `libc::kill(pid as i32, SIGKILL)`. The SIGTERM phase inside `shutdown_all_graceful` (used by the F1 KIND_SHUTDOWN path) likewise calls `libc::killpg(pid, SIGTERM)`. Both signal the entire process group rather than just the direct child. Used `libc::killpg` directly rather than pulling `nix` in for one wrapper.
- **M9.3 tests (`tests/process_group_kill.rs`)**: `close_pane_reaps_shell_descendants` launches `sh -c 'sleep 30 & echo $! > FILE; wait $!'` so the shell writes its background `sleep`'s PID to a relay file. The test reads that PID, asserts both the shell and the descendant are alive (so a descendant that died on its own can't make the test pass for the wrong reason), calls `close_pane`, and probes both PIDs with `libc::kill(pid, 0)` returning ESRCH within 3s. The 3s bound is well under the descendant's 30s sleep, so a pre-F5 regression would leave the sleep alive past the bound and fail the assertion. 1 test, passes.

**Limitation (surfaced during F5 manual-test pass, 2026-05-23)**: `killpg` reaches the agent's session but does NOT cross `setsid` boundaries that the agent itself creates. Claude Code internally `setsid`s its sub-shells, placing them in fresh process groups that escape the outer `killpg`. Those orphaned sub-shells survive Ctrl+W as init-parented processes. Mitigation: **F8** (graceful single-pane close — SIGTERM with 3 s grace before SIGKILL) gives the agent a catchable signal so it can reap its own descendants during the grace window. See F8 below for details. If the agent ignores SIGTERM or doesn't clean up, the descendants survive — that's an agent bug, not a deck bug.

### F8 — Graceful single-pane close (SIGTERM-then-SIGKILL)

**Problem**: F5's `killpg(pgid, SIGKILL)` kills the agent's immediate process group but cannot reach sub-sessions the agent itself creates via `setsid` (real-world example: Claude Code's internal sub-shells). The pre-F8 Ctrl+W path also sent SIGKILL directly, which is uncatchable — so even a well-behaved agent that *wanted* to clean up its own descendants had no opportunity to do so.

**Status**: **Shipped** (PRD #92 Phase 10 — M10.1 / M10.2).

- **M10.1**: new `terminate_child_with_grace_and_wait` helper in `src/agent_pty.rs` — SIGTERM the child's process group, poll `try_wait` for up to `AGENT_TERMINATE_GRACE` (3 s), then SIGKILL as the backstop. `AgentPtyRegistry::close_agent` (the single-pane Ctrl+W path) now routes through this helper. `force_kill_child_and_wait` is retained as SIGKILL-only for the contexts where a grace window is wrong or unnecessary (`PtyGuard` / `AgentPty` `Drop`, `shutdown_all` from registry `Drop`, `shutdown_all_graceful`'s phase-3 SIGKILL backstop). Both paths now share a new `signal_child_pgroup_or_fallback` low-level helper for the `killpg` syscall + `pid_to_pgid` validation + `tracing::warn!` shape, so the three call sites (single-pane SIGTERM, single-pane SIGKILL fallback, daemon-wide SIGTERM) can't drift.
- Controller-side timeout adjustment: the Ctrl+W path's `CREATE_PANE_STOP_TIMEOUT` (2 s — written for the attach-failure cleanup) was too tight after F8 because the daemon-side RPC can now legitimately take up to 3 s. New dedicated `CTRL_W_STOP_TIMEOUT = 5 s` (3 s F8 grace + 2 s buffer) for the Ctrl+W path; the 2 s constant stays in place for its original use.
- **M10.2 tests (`tests/process_group_kill.rs`)**: `close_pane_well_behaved_agent_runs_sigterm_trap` (shell traps SIGTERM, writes a sentinel, exits; test polls for the sentinel within 2 s of the close) and `close_pane_uncooperative_agent_killed_after_grace` (shell ignores SIGTERM via `trap '' TERM`; test asserts the close completes under 3.5 s — well above the 3 s grace, well below the shell's 60 s `sleep`). Existing `daemon_attach_cleanup::ctrl_w_stop_agent_timeout_restores_pane_and_returns_error` adjusted: outer deadline 4 s → 7 s to match the new `CTRL_W_STOP_TIMEOUT`.

The deck's contract: "the agent gets a catchable signal and a bounded grace window." The agent's contract: "use that signal to reap your own descendants if you spawned any." Agents that don't honour the contract still leak descendants — visible via `pgrep` after Ctrl+W — but the deck has no way to chase arbitrary descendant trees safely without crossing `setsid` boundaries that exist for good reasons.

### F6 — Scripted shutdown via `dot-agent-deck stop` (and `remote stop <name>`)

**Problem**: F1 shipped the in-product Stop gesture (Ctrl+C dialog), but PRD #93 line 39 also anticipated a CLI command for scripted shutdown — e.g. a deploy script that wants to ensure no stale daemon is holding a socket before installing a new binary. There is currently no headless way to stop the daemon; you have to attach a TUI just to hit the dialog.

**Suggested approach**: Add `DaemonCmd::Stop` to `src/main.rs` that connects to `attach_socket_path()` and sends a `KIND_SHUTDOWN` frame via the now-shipped `DaemonClient::send_shutdown`. Mirror with `RemoteCmd::Stop { name }` that ssh-execs `dot-agent-deck daemon stop` on the registered remote. Decide whether the CLI form should require `--force` when agents are alive (parallel to the secondary dialog in F1) — likely yes for symmetry, so the destructive case stays explicit.

**Likely PRD home**: a small successor PRD, sequenced after F1 has run in production for a release cycle so the wire-level behavior is proven before adding headless callers. (Renumbered from F4 when the audit-of-the-audit surfaced F4 / F5 as in-scope for this PRD.)

### F2 — Implement the `y` / `n` permission key documented in the help overlay (PRD #18 follow-up)

**Problem**: The TUI help overlay has listed `y` / `n` as "Approve / deny permission" since baseline (`src/ui.rs:5536` in current; `:4531` in baseline). No code path implements it — `KeyCode::Char('y')` has no handler at command mode, and the only `KeyCode::Char('n')` handler is for Ctrl+n new-pane. A user who reads the help and tries to approve a `WaitingForInput` session with `y` gets nothing. The defect predates both PRD #76 and PRD #93 and ties back to PRD #18 (permission prompt control) — the help text was added but the keybinding never wired up.

**Suggested approach**: In `handle_normal_key` (around `src/ui.rs:1875–1895`), add `KeyCode::Char('y')` and `KeyCode::Char('n')` arms gated on `selected card status == WaitingForInput`. Dispatch through the same approve/deny path the permission-prompt dialog should already use (or define one if it does not — confirm against PRD #18's scope). Alternative: remove the help line if the design has moved on from in-TUI permission approval.

**Likely PRD home**: a small successor PRD pointing at PRD #18, or a one-off bug fix.

**Status**: **Shipped** via PRD #92 Phase 5 (M5.1 / M5.2 / M5.3). `handle_normal_key` now takes a `selected_status: Option<SessionStatus>` parameter; `y` / `n` arms gated on `WaitingForInput` return `KeyResult::SendPermissionResponse(bool)`; the dispatcher writes the literal `y` / `n` character to the selected pane via `pane.write_to_pane`, which handles the submit-key parity dance. PRD #18's richer mechanism (`PermissionResponders` map, blocking-hook response channel, `pending_permission` on `SessionState`) was not present in current code — the F2 implementation chose the simpler PTY-forward model. 4 unit tests added in `src/ui.rs`. Help text at `src/ui.rs:5536` is now accurate without any text change.

### F3 — Update `docs/configuration.md` to reflect `/tmp` socket uid suffix

**Problem**: `docs/configuration.md:22` documents `/tmp/dot-agent-deck.sock` as the default `DOT_AGENT_DECK_SOCKET` value. Current code (introduced in commit `48b9180`, see appendix § B) uses `/tmp/dot-agent-deck-{uid}.sock` to avoid multi-user collisions. The doc has not been updated.

**Suggested approach**: Replace the `/tmp/dot-agent-deck.sock` literal in `docs/configuration.md:22` with `/tmp/dot-agent-deck-{uid}.sock` and add a one-sentence note explaining the per-user disambiguation. Cross-check other docs (`docs/installation.md`, `docs/remote-requirements.md`) for the same staleness; mirror the fix wherever the old path appears.

**Likely PRD home**: docs-only change; can land as a small standalone commit or be folded into the next docs-touching PR.

### F9 — Restore worker context cleanup per `.dot-agent-deck.toml` `clear` setting

**Status**: Shipped (respawn-based). Implementation matches baseline `2fc39c3:src/ui.rs::dispatch_delegate_events` semantics: on a `clear = true` delegate the daemon terminates the worker's existing child (SIGTERM with the F8 3 s grace then SIGKILL) and spawns a fresh agent reusing the same `pane_id_env` before writing the new prompt. `clear = false` skips the respawn. ANSI-clear-only shims were considered and rejected — they wipe the visible screen but leave the agent's in-memory context intact, which is not what the baseline contract delivered.

*Note (2026-05-24 followup)*: agents that auto-reload prior session state on startup (Claude Code with `--continue`, OpenCode with session autoload) will defeat the kill+respawn context-clearance semantics of `clear = true`. The respawn delivers a fresh PID, a fresh scrollback, and a fresh broadcast bus, but the agent's first action is to read its own persisted session from disk and resume — the LLM's conversation history is restored before the new task prompt lands. This is an agent-side configuration concern, not a daemon-side fix. Disable session autoload on roles where `clear = true` matters (e.g., `claude --new` instead of `claude --continue` in the role's `command`), or accept that the respawn only resets the deck-visible state (PID, scrollback, broadcast bus) — not the LLM's persisted memory.

**Problem**: Each `[[orchestrations.roles]]` entry in `.dot-agent-deck.toml` supports a `clear` field (default `true`; the `release` role explicitly sets `clear = false` to keep its scrollback for the release-flow walkthrough). Pre-daemon (baseline `2fc39c3`), the orchestrator honored this setting and cleared a worker's pane before each delegation when `clear == true`. Post-daemon (current main), this honoring is lost — workers retain previous pane content across delegations. Surfaced by user during F8 manual testing. Likely lost in PRD #93 round 5 (commit `d39930f`) when delegate / work-done dispatch moved into the daemon — either the field was dropped from the in-memory orchestration-role representation, or its honoring code in the delegate dispatch path was removed during the refactor.

**Suggested approach**: Verify the `clear` field is parsed into the in-memory orchestration-role config (restore the parse if it was dropped). Wire the pane-clear into the daemon-side delegate dispatch path (`src/state.rs::handle_delegate` and/or `src/agent_pty.rs::write_to_pane` — choose whichever site is cleaner). Before writing the new task prompt to the worker's pane, emit a clear-screen sequence (or equivalent) when the role's `clear == true`. Skip the clear when `clear == false` (release role keeps its scrollback). Tests: parse-level for default and explicit values; dispatch-level for honoring of the setting.

**Likely PRD home**: PRD #92 Phase 11 (this audit's remediation scope).

### F12 — TUI auto-renew per-pane subscription on respawn

**Status**: Shipped (user manual-tested 2026-05-24). Final fix at commit `96762b3`; e2e regression test at commit `a2b5370`. Folded into scope 2026-05-24 after manual testing of F9 followup-7 confirmed the daemon side was fully working but the TUI side did not auto-renew its `broadcast::Receiver` subscription when an agent was respawned.

*F12 saga (commits in order):* `6368a8f` (initial reattach loop — passed unit tests, failed manual test) → `7c6356f` (close_pane retry-once on stale agent_id + doc nits) → `96762b3` (the REAL fix: replaced the 200 ms retry budget with exponential backoff totaling ~10 s; the bug was that the production respawn gap of 0.5–3 s exceeded the 200 ms budget) → `a2b5370` (e2e regression test that exercises the real `respawn_agent_for_pane` path with a SIGTERM-trapping shell, FAILS at the broken budget, PASSES at the fixed budget) → `d14f7bc` (doc-comment + test-name polish).

*Fix lives in `src/embedded_pane.rs::run_pane_io_task`* — the per-pane io_task wraps its read/write `select!` in an outer reconnect loop. On STREAM_END from the daemon, the loop calls `resolve_and_reattach` to look up the NEW agent for `pane_id_env` via `list_agents` (with **exponential backoff: 200 ms initial doubling to 1 s cap, ~10 s total budget**) and re-`attach`. `StreamBackend::agent_id` is now an `Arc<Mutex<String>>` shared with the io_task and the resize worker so `close_pane` / `resize_pane_pty` target the currently-bound agent.

*Tests:* `tests/pane_auto_renew_on_respawn.rs` covers four unit-level scenarios (respawn happy path, no-live-agent give-up, respawn-in-flight race, no-respawn regression) but bypasses the real daemon dispatch — those tests passed at the broken budget. The e2e regression test that actually catches a budget regression is `tests/orchestration_delegate.rs::e2e_pane_renders_new_agent_after_sigterm_trap_respawn` — spawns a real daemon, uses a SIGTERM-trapping shell to force a ~2 s respawn gap, asserts the daemon's post-respawn prompt one-liner reaches the controller's vt100 parser within 30 s.

**Problem**: When the daemon respawns an agent (F9 `clear = true` path), the TUI's per-pane stream subscriber is bound to the OLD agent's `broadcast::Sender`. The OLD agent dies, the sender is dropped, the receiver returns `Closed`. The TUI does not re-resolve the pane to the NEW agent's sender. The pane view freezes showing only stale output (typically just the devbox bootstrap line); the user must manually detach and re-attach to see the new agent's output. Verified empirically by the user: detach + re-attach immediately reveals the running new agent. The F9 coder's claim that *"the TUI's existing reconnect machinery handles this"* is incorrect — that machinery handles whole-daemon reconnect, not per-pane agent-respawn.

**Suggested approach**: TUI-side fix. On `broadcast::error::RecvError::Closed`, the per-pane stream subscriber re-resolves `pane_id_env` via the attach protocol's `list_agents`, finds the NEW agent's id, and re-subscribes. Small backoff (e.g. 100 ms initial) for the case where Closed fires before the new agent has registered. Cap total retries so a permanently-closed pane doesn't spin.

**Likely PRD home**: PRD #92 Phase 13 (this audit's remediation scope).

### F10 — Dedupe double work-done notifications

**Status**: Deferred (2026-05-24) — not reproducible after the reviewer/auditor migration off `opencode --model openrouter/openai/gpt-5.5` to `claude --model opus` on 2026-05-23. The duplication that motivated F10 may have been agent-binary-specific (opencode signaling `work-done` on exit, or OpenRouter network-latency-induced retries). Orchestrator continues to count notifications per worker completion; F10 reopens if duplicates recur. See PRD #92 Design Decisions 2026-05-24 entry.

**Problem**: Pre-daemon, a worker's `work-done` signal produced exactly one orchestrator notification. Post-daemon, the orchestrator frequently receives the same "Worker X completed" notification twice for a single commit. Reproducible — manifested in this very PRD #92 implementation conversation across F2 / F5 / F8 work-done messages. Likely lost in the same PRD #93 round-5 refactor (`d39930f`) that moved work-done dispatch into the daemon. Either dedup logic was removed, or two parallel code paths now both notify (e.g., the direct dispatch in `handle_work_done` plus a broadcast subscriber both firing on the same signal).

**Suggested approach**: Map the work-done delivery graph (`src/state.rs::handle_work_done` + broadcast subscribers in `src/daemon.rs` / `src/daemon_protocol.rs` + any hook-event handler forwarding `work-done` payloads) — use `Explore` agents for breadth if needed. Identify the dup source. Fix with a single-emit guard or by collapsing the parallel paths. Tests: subscribe a fake orchestrator-side listener, fire one `work-done`, assert exactly one notification arrives. Regression test that the broadcast machinery still delivers `work-done` to all attached TUIs (so the dedup did not break broadcast).

**Likely PRD home**: PRD #92 Phase 12 (this audit's remediation scope).

### F11 — Bus-push status delivery (daemon → TUI events for status messages)

**Status**: Deferred (2026-05-24). Filed as the long-term fix for the submit=false LF risk identified during F9 followup-2 audit.

**Problem**: The current implementation writes spawn-failure notices (and any future status messages) via the orchestrator pane's PTY using a submit=false byte sequence. This depends on agent-side behavior — if the agent's TUI interprets LF as Enter, the notice is submitted to the LLM as a user prompt. Even on agents that treat LF as literal-newline, the notice bytes accumulate in the agent's stdin buffer and contaminate the next submitted user prompt.

**Suggested approach**: Add a daemon → TUI event channel for status messages, parallel to but separate from the PTY byte stream. The TUI subscribes to status events and displays them in the dashboard (e.g., toast notification or pane status indicator). The agent's PTY only carries user-input bytes — never daemon-internal status. Requires:
- New KIND_STATUS attach-protocol message (header + role + pane_id + message text).
- Daemon-side emit when respawn fails (and any future status events).
- TUI-side subscriber that renders status into the dashboard.

**Likely PRD home**: A successor PRD focused on attach-protocol observability. Out of scope for PRD #92 since it adds new wire-protocol surface and TUI UI machinery beyond the parity restoration this PRD is about.

### F7 — Protocol version mismatch detection at attach handshake

**Status**: Covered by PRD #90 (Remote daemon upgrade flow) — Planning. Filed during PRD #92 work but not implemented here.

**Problem**: During F9 manual testing, the user hit a "stale daemon" scenario — `cargo run` produced a new TUI binary but the existing daemon process was from an earlier build. The new TUI silently attached to the old daemon and used its (older) behavior. F1 followup added `PROTOCOL_VERSION = 2` checking specifically for `KIND_SHUTDOWN` (via the `KIND_SHUTDOWN_ACK` contract), but normal-path attach operations (Ctrl+W, StartAgent, write_to_pane, list_agents) don't get version-protected — a newer TUI binary attached to an older daemon silently uses degraded behavior.

**Suggested approach**: at the Hello handshake (`src/daemon_protocol.rs`), reject an attach when the client and daemon `PROTOCOL_VERSION` values diverge. The TUI surfaces a clear error and refuses to attach. Forces the upgrade-mismatch case to fail fast with a visible error instead of silently degrading.

**Likely PRD home**: PRD #90 — Remote daemon upgrade flow. PRD #90's problem statement explicitly calls out "No version compatibility check" as one of four upgrade-flow pain points. Implementation of this audit's F7 lands as part of PRD #90's solution.

### F13 — Remote-attach SSH-wedge on laptop sleep

**Status**: Deferred (2026-05-24). Filed during PRD #92 work but not derived from the audit — this is post-baseline behavior (`dot-agent-deck connect <name>` was introduced in PRD #76 and doesn't exist at the parity baseline).

**Problem**: When a user is connected to a remote daemon via `dot-agent-deck connect <name>` and the laptop sleeps, the SSH connection enters a half-open state. The local terminal blocks on I/O — cannot send input, cannot render output, cannot exit the TUI through any keyboard gesture. The only workaround is to close the terminal tab and start a fresh `dot-agent-deck connect`. Surfaced empirically by the user during PRD #92's F9 / F12 manual-test sessions.

**Suggested approach**: Likely an SSH-keepalive issue more than a deck issue (TCP keepalive is slow; SSH `ServerAliveInterval` / `ServerAliveCountMax` defaults are absent or long). Possible fixes:
- Set sensible `ServerAliveInterval` + `ServerAliveCountMax` in the spawn args used by `dot-agent-deck connect`.
- Add a client-side liveness probe in whatever wraps the SSH session on the laptop, so a dead connection drops fast and the TUI can exit gracefully.
- Document the gotcha + recommended SSH config for users who run `connect` long-term.

**Likely PRD home**: A successor PRD on remote-attach resilience, or a focused SSH-config tweak. Out of scope for PRD #92 since it's post-baseline behavior and not a parity finding.

## Intentional changes appendix

Behaviors that changed between baseline and current, where the change is a deliberate design decision with citation. Recording so a future re-audit does not re-flag.

### § A. Quit confirmation dialog: Yes/No → Detach/Cancel

**Where**: `src/ui.rs:1666–1700, 5328–5339`.

**Justification**: PRD #93 M4.2 explicitly collapses the dialog. Every pane is now daemon-backed, so the TUI cannot kill agents from inside the dialog regardless of which option the user picks; offering a "Yes (quit)" that no longer kills anything would be a misleading UX. The fact that the architecturally-correct dialog now reads "Detach / Cancel" is the visible symptom of F1 — the user can no longer reach the kill path that "Yes" used to perform.

### § B. `DOT_AGENT_DECK_SOCKET` `/tmp` fallback gains uid suffix

**Where**: `src/config.rs:52–68` (`socket_path`) and mirror at `:76–91` (`attach_socket_path`).

**Justification**: Introduced in commit `48b9180` (the PRD #93 squash-merge that brought in always-external-daemon work). The inline code comment at `src/config.rs:61–67` records the rationale: *"the `/tmp` fallback must include the uid so two users on the same host can't collide on the same socket path (the daemon is per-user; the 0o600 mode is on the socket inode, but the *path* still has to be unique, otherwise the loser's `bind(2)` sees `EADDRINUSE` against the winner's inode). Same rationale as `attach_socket_path` below."* The PRD document itself does not contain a numbered REV-2 reference; the citation lives in the squash-merge commit message and the code comment. On a shared-host where `$XDG_RUNTIME_DIR` is unset and multiple users fall back to `/tmp`, the original `/tmp/dot-agent-deck.sock` would be a collision target. The new fallback is `/tmp/dot-agent-deck-{uid}.sock`. The env-var override behavior is unchanged; only the `/tmp` default differs. The doc default at `docs/configuration.md:22` is mildly out-of-date but does not change the user's ability to override.

### § C. In-process daemon → external daemon

**Where**: `src/main.rs:574–638`, `src/daemon_attach.rs:99–294`, `src/daemon.rs:317–551`, `src/agent_pty.rs:805+`.

**Justification**: PRD #76 + PRD #93 Phases 1–3. The daemon is now a separate process; agents are owned by `AgentPtyRegistry` in the daemon; the TUI talks to the daemon over a per-user Unix socket via the attach protocol; auto-spawn is lazy at TUI startup and protected by flock. This is the architectural change that produced F1; everything else in this audit is a consequence of it.

### § D. Post-baseline additions (out of scope for parity, listed so future re-audits do not enumerate them as baseline features)

- `daemon serve` and `daemon hello` subcommands (`src/main.rs:135–150`).
- `remote add|list|remove|upgrade` and `connect` subcommands (`src/main.rs:160–209, 409–489`).
- `DOT_AGENT_DECK_ATTACH_SOCKET` env var (`src/config.rs:76–82`).
- `attach_socket_path()` and the streaming-attach socket (`src/config.rs:76+`, `src/daemon_protocol.rs`, `src/daemon_client.rs`).
- Auto-spawn / idle-shutdown / startup-race protection / stale-socket recovery (`src/daemon_attach.rs`, `src/daemon.rs`).
- Attach protocol Hello / `PROTOCOL_VERSION` handshake (`src/daemon_protocol.rs:97–121`).
- Hook event `KIND_EVENT` fanout and TUI `spawn_event_subscriber` (`src/main.rs:640–700`).
- Daemon-owned orchestration dispatch (`src/state.rs:251–479`, `src/agent_pty.rs::write_to_pane`).
- `RunningAgent` / `AgentRecord` metadata for reconnect (`src/agent_pty.rs:699–800`) — M2.11/12/13.
- `pane_input.rs` (submit-key parity lifted out of `embedded_pane.rs`).

## Historical anchors mapped to parity rows

Each IPC-boundary fix that surfaced during PRD #76 (M2.11–M2.20) and PRD #93 (round-fixes recorded in its implementation-notes section) had a user-visible consequence — a behavior that worked at baseline but broke under the daemon split, then got re-implemented. This appendix maps each anchor back to the parity row(s) where its surviving end-state is verified. A future re-audit can use this to confirm no anchor went silent.

- **M2.11** (pane id stability across reconnect — `pane_id_env`, `display_name`, `cwd` added to the daemon registry): rows **2** (`--continue` restores last session, including pane names), **34** (mode tab restoration, which depends on stable pane ids across reconnect), **49** (always-create-pane-card placeholder; the M2.13 extension carries the daemon-supplied identity through the placeholder).
- **M2.12** (tab membership reconstruction on reconnect — `TabMembership` carried in `RunningAgent` / `AgentRecord`): row **34** (mode tab restoration on `--continue`), row **38** (workspace modes config schema — the `name` field that `TabMembership::Mode { name }` references).
- **M2.13** (`agent_type` in `AgentRecord` so reconnect labels agents correctly): row **49** (placeholder session optionally carries `agent_type` from the daemon registry, so a reconnected pane shows `ClaudeCode` / `OpenCode` instead of `"No agent"`).
- **M2.17** (hook event delivery from daemon to attached TUI — `KIND_EVENT` broadcast fanout): rows **4** (`hook` subcommand ingestion), **30** (six session statuses, which are exactly what hook events drive), **31** (card display fields — every `Last:` / `Tools:` / `Prmt:` update is a hook-event consequence), **47** (terminal bell, which fires on the hook-event-driven `WaitingForInput` transition), **48** (OpenCode events flow through the same `AgentEvent` pipeline). The `KIND_EVENT` wire format itself is a post-baseline addition listed in appendix § D.
- **M2.19** (daemon-owned orchestration dispatch via `AgentPtyRegistry::write_to_pane`): rows **12** (`delegate`), **13** (`work-done`). The original env-var-staleness framing is dead — covered explicitly in F1's worked example and the v2 PRD's Design Decisions block.
- **M2.20** (submit-key parity — `SUBMIT_DELAY` + bracketed-paste wrapper, lifted into `src/pane_input.rs`): rows **12** (delegate, which uses `write_to_pane` and therefore the same submit-key encoding), **13** (work-done, same), **42** (PaneInput in side pane — explicitly cites `src/pane_input.rs` as the shared helper).
- **PRD #93 round fixes** (recorded in `prds/93-always-external-daemon.md` "Implementation notes"):
  - `BroadcastMsg::WorkDone` variant + delegate / work-done re-delivery (`f65bb94`, `adb13e9`, `184a756`): rows **12** (delegate), **13** (work-done). The buffer / salvage mechanism itself is a post-baseline addition (appendix § D).
  - Round-5 daemon-owned dispatch (`d39930f`): rows **12**, **13** — this is the same anchor as M2.19, completed via the round-5 redesign that moved dispatch entirely into the daemon.
  - Submit-key parity in `src/pane_input.rs` (`7c1a5a2`): rows **12**, **13**, **42** (same as M2.20).
  - Work-done footer restoration in `compose_delegate_prompt` (`adf131a`): row **12** (the delegate row mentions the footer in the daemon-side `compose_delegate_prompt` chain; the footer ensures the worker knows to call `work-done`, closing the orchestration loop verified by row 13).
  - Bracketed-paste-marker rejection in multi-line input (`c119e0f`): row **42** (the PaneInput / `src/pane_input.rs` row carries this defense-in-depth alongside `SUBMIT_DELAY`).

No anchor in the M2.11–M2.20 range failed to map to at least one parity row. The PRD #93 round-fix anchors all map cleanly because they are refinements of the dispatch / submit / event-delivery paths already covered by rows 12 / 13 / 42 / the M2.17 cluster.

---

*End of audit.*
