# pixtuoid (binary) — agent guide

The **TUI binary**: `ratatui` + `crossterm` + `tokio` + `clap`. Wires sources →
reducer → renderer, owns the CLI subcommands, hook installation, config
persistence, and multi-floor orchestration. The pure render pipeline lives under
`src/tui/` — see its own nested guide:
[`src/tui/CLAUDE.md`](src/tui/CLAUDE.md). Cross-cutting rules:
workspace [`CLAUDE.md`](../../CLAUDE.md); headless-lib detail:
[`../pixtuoid-core/CLAUDE.md`](../pixtuoid-core/CLAUDE.md).

## Layout

```
src/
├── main.rs             entry point; install_crash_hook (panic hook → ~/.cache/pixtuoid/crash.log);
│                       log routing (#157): TUI mode ALWAYS file-logs at a warn floor
│                       ($PIXTUOID_LOG > $XDG_STATE_HOME/pixtuoid/log > ~/.cache/pixtuoid/log,
│                       one-deep rotation at 5MB by APPENDING .old; RUST_LOG, --log-level
│                       debug|trace, or $PIXTUOID_LOG raise verbosity — plain --log-level info
│                       is indistinguishable from the default and floors to warn); non-TUI
│                       modes log to stderr; install failure eprintlns pre-altscreen
├── cli.rs              clap subcommands (run / validate-pack / init-pack / doctor) — NO install-hooks/uninstall-hooks
│                       (deleted; binding a source is the in-TUI Sources panel's job, `s`)
├── doctor.rs           `pixtuoid doctor` — read-only source self-diagnosis (connected? hooks
│                       installed? installed `<cli> --version` vs the registry's verified_version
│                       anchor → skew flag; + decode-drift counts scanned from the warn-floor log's
│                       `pixtuoid::drift` breadcrumbs). Pure scan_log_for_source/format_doctor_row/
│                       parse_version/version_status (tested; scan vs REAL fmt output); sanitizes
│                       untrusted sampled names (R0615-06). verified_version lives on SourceDescriptor.
│                       drifted_sources/footer_warning (also pure, tested) feed the LIVE footer nudge —
│                       run_tui throttle-scans the same log (≤15s) → ⚠ decode drift footer (see tui guide).
│                       **THE unified source-HEALTH module** (#309 health-consolidation): `SourceDiagnostics`
│                       { install: Option<SchemaVerifyResult>, drift } + `diagnose(src, log)` (install
│                       soundness via install::verify_target + drift scan) + `summary()` (⚠ install-broken
│                       > decode-drift) is the ONE rollup the Sources panel detail, the boot preflight
│                       (main.rs), AND `run` (the CLI report) all read — surfaces can't drift apart. Version
│                       skew stays report-ONLY (the <cli> --version probe is too costly for the interactive
│                       panel-open; advisory). doctor=health PROVIDER, ConnState=connection lifecycle it
│                       ANNOTATES (sub-state, not overlap)
├── config.rs           AppConfig persistence (~/.config/pixtuoid/config.toml), XDG-aware
├── runtime/            mod.rs (RunConfig, boot-capacity math, headless summarize — all unit-tested;
│                       ConnectedSources = the live `Arc<Mutex<HashSet<String>>>` connected-set,
│                       seeded from config::resolve_connected, mutated by the Sources panel toggle,
│                       read by the reducer task — recovers via into_inner on lock poison),
│                       driver.rs (tokio task wiring: source ── (Transport, AgentEvent) ──► reducer ──►
│                       renderer, compute_boot_capacities terminal-size query, Ctrl-C loop —
│                       untestable async glue, codecov-ignored, #103; exception: headless_loop
│                       takes its ctrl_c future as an injected seam, so its signal arms — incl.
│                       the registration-failure disarm — are unit-tested. The CONNECTION GATE lives in
│                       reducer_task: every incoming event is dropped if its source (resolved by the pure
│                       `event_source` — unit-tested — off SessionStart/Identity, else the slot) is not in
│                       the connected-set; every sweep tick RECONCILES the scene toward the set via
│                       (idempotent) `Reducer::reconcile_connected(&cur)` — which evicts every slot whose
│                       source is the COMPLEMENT of the connected snapshot (NOT a registered-source list), so a
│                       panel disconnect walks characters out gracefully + live (no restart), the JSONL watcher
│                       still running can't keep a disconnected source visible, AND a blank-source slot that
│                       slipped the per-event gate is swept too. Stateless on purpose (no prev-set bookkeeping).
│                       LIVENESS-LADDER INTERACTIONS (all benign — a disconnect is an explicit user toggle, the
│                       same authority class as a SessionEnd, NOT content-driven lifecycle): a disconnected source
│                       is evicted by THIS 1-Hz reconcile, NOT the minutes-scale stale-sweep; reconcile's
│                       write-once `mark_exiting` is honored by the probe ladder (ProofOfLife/vouch SKIP exiting
│                       slots + never create/resurrect them — core sharp edge), so a vouched-but-disconnected
│                       source still exits; `cascade_exit` is source-agnostic (parent_id BFS) so a disconnect of
│                       a delegating parent takes its whole subtree while a DIFFERENT connected source's subtree
│                       is untouched. Reconnect = a fresh `SessionStart` resurrects-in-place once the old slot GCs.
│                       `build_source_set` is the ONE source-construction site: it mints the HookRouter (the
│                       Source that owns the shared hook socket — every CLI's hooks ride it), the transcript
│                       watchers (CC/Antigravity/Codex/Copilot), and the ONE shared ChildEndUnclaims handle (#246)
│                       — handed to the HookRouter (hook-tee PRODUCER) + ClaudeCodeSource & CodexSource (watcher
│                       CONSUMERS). Daemon presence (OpenClaw) rides a source-tagged sibling channel into
│                       SceneState::daemons; reducer_task's presence/sweep arms are registry-driven
│                       (daemon_sources()) so N daemons need no driver edit)
├── init_pack.rs        extracts the embedded skeleton pack to a target dir for `init-pack`
├── install/            multi-target (Claude + Codex + Reasonix + CodeWhale + opencode) hook install via the `Target` registry:
│                       mod.rs (install_target/uninstall_target = structured core → InstallReport/UninstallReport,
│                         driven SOLELY by the in-TUI Sources panel's connect/disconnect (no CLI orchestration —
│                         plan_targets/interactive_pick/run_install/run_uninstall + inquire were deleted with the
│                         install-hooks CLI); has_hooks(t) is `pub` (the migrate-default signal for config::resolve_connected)),
│                       target.rs (Target trait + TARGETS = [CLAUDE, CODEX, REASONIX, CODEWHALE, OPENCODE, CURSOR];
│                         each Target carries a `verify_schema` fn-ptr — the #309 install-soundness check, per-source
│                         format-local like merge_install/uninstall),
│                       verify.rs (the #309 install-schema verifier: SchemaParse/SchemaVerifyResult/ShimRef +
│                         shared helpers shell_shim_ref (4 shell targets) / flat_json_verify (reasonix+cursor) /
│                         assemble; install::verify_target(t, config) = the I/O wrapper that reads the config +
│                         calls verify_schema + stats the shim. ONLY call when has_hooks(t) — the load-bearing gate
│                         (an uninstalled config verifies "broken"; a disconnect removes hooks → has_hooks=false →
│                         never called → never a false broken)),
│                       claude.rs / codex.rs / reasonix.rs / codewhale.rs / opencode.rs (per-target hook_command + config path;
│                         claude.rs: Unix = bare shell-form, Windows = exec-form absolute .exe;
│                         reasonix = GLOBAL ~/.reasonix/settings.json, FLAT {match,command,timeout-ms}
│                         entries — project-scope is trust-gated; match omitted = every tool;
│                         codewhale = ~/.codewhale/config.toml [hooks] (enabled=true) + a `hooks` array of
│                         {event, command} entries. Env-mode events (session/tool/end) bake ` --event <name>`
│                         (CodeWhale sets no event env var; shim builds from DEEPSEEK_*); the subagent observer
│                         events (subagent_spawn/complete) use the PLAIN stdin-forward command (no --event) —
│                         CodeWhale pipes a full JSON payload with the child agent_id. `_pixtuoid` sentinel idempotency.
│                         opencode = a TS PLUGIN (the FIRST install target that writes CODE, not a config block):
│                         opencode auto-discovers `<config>/plugins/*.ts` (plural, canonical), so we DROP `<opencode-config>/plugins/pixtuoid.ts`
│                         (no opencode.jsonc edit). The plugin (bundled `opencode_plugin.ts`, shim abs-path baked in
│                         JSON-escaped) pipes lifecycle/tool/permission EventV2 to the shim on stdin; merge_install
│                         renders the whole file (it's wholly ours), uninstall writes a sentinel-free no-op stub
│                         (write-only orchestrator can't delete), detect on the `@pixtuoid-opencode-plugin` sentinel),
│                       io.rs (resolve_symlink + the ONE config-write authority: ConfigLock —
│                         an RAII advisory-lock guard taken BEFORE the read and held across
│                         read+merge+backup+write (lost-update TOCTOU); its pinned symlink
│                         resolution is the ONE identity for the whole round — read/backup/
│                         remove_backup go through ConfigLock::read/::backup_once/::remove_backup,
│                         never a re-resolve of the link — and ConfigLock::write_atomic
│                         (fsync + atomic rename, PRESERVES the target's Unix mode / creates new
│                         files 0600 — settings.json can carry API keys; Windows: rename wrapped
│                         in 3×50ms retry for sharing-violation tolerance). write_config_atomic
│                         = lock_config + write_atomic for single-shot writers; NEVER re-call it
│                         while holding a ConfigLock — same-process flock self-deadlocks. The
│                         .lock file is deliberately never unlinked, and even a no-op
│                         re-install creates it: the lock must be taken BEFORE the read
│                         that detects "nothing changed".)
└── tui/                ratatui App + TuiRenderer (Renderer trait impl) — see src/tui/CLAUDE.md

sprites/                character/environment packs (NOT under pixtuoid-hook):
├── default/            coworking-lounge pack (embedded into the binary via include_str!)
├── robot/              proof-of-concept TV-head robot pack (loadable via --pack-dir)
└── skeleton/           template pack for custom sprite creation (extracted via init-pack)
```

## Known sharp edges (don't be surprised by these)

- **Terminal cell aspect drives sprite design.** The half-block ▀ technique assumes ~1:2 cell aspect. Sprites larger than ~16×16 px break on terminals with taller cells (Ghostty default, large Fira Code). The bundled **character** sprites max at **8×12 px** (e.g. `standing`/`walking_*`), safely under the ~16×16 threshold; static environment art (door 16×14, pantry 32×10) is wider but isn't an animated half-block agent. A PNG-loader experiment hit this wall and was deleted in favor of hand-drawn `.sprite` art.
- **`--max-desks` has no hard default.** It's `Option<usize>` (hidden flag / `max-desks` config key) — when absent, per-floor capacity is auto-computed from terminal size at boot. `FALLBACK_DESKS = 16` (`runtime/mod.rs`) is used only in headless mode or when the terminal-size query errors. The auto path clamps each floor to its real layout capacity; if you add an explicit-cap boot path, clamp it the same way (don't seed the floor-capacity atomics above the layout's real capacity — `fetch_max` only grows, so an over-seed leaves agents assigned to non-existent desks until the terminal grows).
- **Re-install is a SEMANTIC no-op, and backups APPEND their suffix.** `MergeOutcome.changed = merged != doc` (`install/claude.rs`) compares the *parsed/merged* config, NOT bytes — so a second connect (or a disconnect of an absent hook) detects "nothing changed", skips the write, and preserves the user's hand-formatting + skips backup churn. And `backup_once` names backups via `sibling()` = `format!("{}.{}", path, "pixtuoid.bak")` which **appends** — deliberately NOT `with_extension`, which would truncate `config.local.toml` → `config.local.pixtuoid.bak` (losing `.toml`). So a multi-dot config keeps its full name (`config.local.toml.pixtuoid.bak`). Both pinned by tests in `install/io.rs`.

## Where to look

- "How do hooks get installed?" → `install::claude::merge_install` for the JSON merge logic (CC registers SEVEN events: SessionStart / PreToolUse / PostToolUse / Notification / **SubagentStart / SubagentStop** (#241 — instant subagent register + the only end signal Workflow-fleet subagents get) / SessionEnd; a re-run over an older install ADDS newly registered events idempotently, and uninstall strips them all via the `_pixtuoid` sentinel), `install::io::write_config_atomic` for the safe filesystem write. Multi-target install via the `install::target::Target` registry (`TARGETS = [CLAUDE, CODEX, REASONIX, CODEWHALE, OPENCODE, CURSOR]`); the user picks a CLI to bind/unbind one row at a time in the in-TUI Sources panel (`s`) — there is no CLI-side target-selection orchestration (the old `plan_targets`/`interactive_pick`/confirm policy was deleted with the `install-hooks` CLI). **Unix:** Claude's `hook_command` emits bare `pixtuoid-hook` (shell-form entry, PATH-resolved at runtime); resolution is soft (warn-only) because the shell resolves it — EXCEPT when the user passed an explicit `--hook-path` (or the `PIXTUOID_HOOK` env override — the flag outranks it, empty/whitespace = unset via `io::nonempty_env`, drive-relative `C:foo.exe` bails, and it EMBEDS exactly like the flag), which always wins: the absolute path is embedded (single-quoted) for Claude too, matching Codex/Reasonix, and the PATH warning is skipped. Default config paths are fallible (`fn() -> Result<PathBuf>`): the home-anchored targets (Claude fallback arm, Reasonix) hard-error via `io::home_relative_checked` when no home dir resolves ("pass --config") instead of writing a CWD-relative file nothing reads; Codex routes through `codex_home()` (always absolute). **Windows:** `hook_command` returns the ABSOLUTE resolved `.exe` path; the hook entry gains `"args": []` (exec form — Git Bash/PowerShell make shell-form unportable); resolution is a HARD error because exec form can't fall back to PATH. Codex embeds the absolute path on all platforms; its hook `command` runs under a shell (`/bin/sh -lc` on Unix, `cmd.exe /C` on Windows — verified in codex-rs `command_runner.rs`, which runs the plain `command` field on every OS, so no `commandWindows` override is written for a locally-generated config). **Unix:** env-prefix form `PIXTUOID_SOURCE=codex '<path>'`. **Windows:** BARE exec form `<path> --source codex` (codex's own documented `command_windows` style). cmd.exe `/C` can't express the env-prefix (it would exec a program literally named `PIXTUOID_SOURCE=codex`), so the source rides as the shim's generic `--source` flag (the flag wins over `PIXTUOID_SOURCE`; either way the shim stamps `_pixtuoid_source`). We must NOT quote the path: codex spawns the hook via `Command::new(cmd.exe).arg("/C").arg(command)`, and `Command::arg`'s Windows escaping turns an embedded `"` into `\"`, which cmd.exe mangles → the hook silently never fires. codex injects no per-hook env we could use instead. A pixtuoid-hook.exe under a path with a SPACE/cmd-metacharacter can't be invoked unquoted, so `hook_cmd::windows::windows_bare_hook_command` substitutes the path's DOS 8.3 SHORT name (`GetShortPathNameW` — space/metachar-free) and only rejects if 8.3 generation is disabled on the volume (#195). The faithful cmd.exe round-trip is pinned by `pixtuoid-hook/tests/shim_pipe.rs::codex_cmd_c_invocation_of_hook_command_stamps_source` (windows-test). **Reasonix** mirrors Codex exactly (it also shells hooks via `cmd.exe /c` on Windows — `hook.go:414`): Unix env-prefix `PIXTUOID_SOURCE=reasonix`, Windows bare `<path> --source reasonix`. Both Codex and Reasonix route their Windows arm through **`hook_cmd::windows::windows_bare_hook_command(path, source)`** — the ONE place the bare-form + the cmd-unsafe-path handling (8.3 short-name substitution via `GetShortPathNameW`, else reject; chars `space tab ; , = " & | < > ( ) ^ %` — the first five are cmd.exe first-token DELIMITERS, and `; , =` are legal NTFS filename chars, #195) lives, so a security guard can't drift between targets. (Claude is the odd one out — it uses Windows *exec form* with an `args` array, not a shell string.) Reasonix's settings shape is its own FLAT per-event array (`{match?, command, timeout(ms), description}` — NOT Claude's nested `{matcher, hooks:[…]}`), the file is the GLOBAL `~/.reasonix/settings.json` (project scope only loads after `/hooks trust` — a project-scope install would silently never fire), and `match` is OMITTED = every tool (upstream special-cases `"*"` to every-tool too; any other value is an ANCHORED regex where a malformed pattern never fires — omission is the simplest always-fires form); detection uses `reasonix::detect_installed` (a `presence_probe` on the Target) because Reasonix never creates the settings.json we write — it probes the v2 config dir (`os.UserConfigDir()/reasonix`) and `~/.reasonix` instead. The hook entry detection/uninstall keys on the `_pixtuoid` sentinel, not the command shape, so both forms are idempotent. **CodeWhale** writes `~/.codewhale/config.toml` (or legacy `~/.deepseek/config.toml` when that is the file it reads — mirrors CodeWhale's own `default_config_path` so we don't shadow the user's provider/key config): a `[hooks]` table with `enabled = true` plus a `hooks` ARRAY of `{event, command, _pixtuoid}` entries. Unlike Codex/Reasonix (one command for all events), CodeWhale sets NO event env var, so `hook_command` returns the BASE form (`PIXTUOID_SOURCE=codewhale '<path>'` / Windows bare `<path> --source codewhale`) and `merge_install` appends ` --event <name>` PER ENTRY; the shim's env-mode reads that flag + `DEEPSEEK_*` env (it must NOT read stdin — CodeWhale leaves env-only hooks' stdin = the TUI terminal). `enabled = true` is set on install (CodeWhale gates all hooks on it; re-enabling is the user's intent in connecting CodeWhale). Detection probes `~/.codewhale`/`~/.deepseek` (presence_probe), and the `_pixtuoid` sentinel drives idempotency (the per-event command's last token is the event, so a command-basename fallback wouldn't apply). **opencode** is the odd one out — NOT a config block but a CODE artifact: opencode has no config-level shell hook (and SQLite-only sessions), so pixtuoid ships a TS plugin. opencode auto-discovers `<config>/plugins/*.{ts,js}` (plural — the canonical docs' dir; the anomalyco fork globs `{plugin,plugins}` so both work there, but plural is what canonical scans), so the target DROPS `<opencode-config>/plugins/pixtuoid.ts` (`OPENCODE_CONFIG_DIR` > `$XDG_CONFIG_HOME/opencode` > `~/.config/opencode`) — NO edit to the user's `opencode.jsonc`. The bundled `opencode_plugin.ts` (shim absolute path baked in JSON-escaped) pipes the lifecycle/tool/permission EventV2 stream to the shim on stdin (`--source opencode`). The plugin file is wholly pixtuoid's, so `merge_install` renders the whole file (changed = content diff, so a same-path re-install is a no-op) and reuses the ConfigLock/backup/atomic-write machinery; `merge_uninstall` writes a sentinel-free no-op stub (`export {}`) rather than deleting (the write-only orchestrator can't delete — an ACCEPTED residual; the stub is a harmless empty module), keying on the `@pixtuoid-opencode-plugin` sentinel for its changed-detection. Auto-detect (`presence_probe`/`detect_installed`) probes the opencode CLI's OWN dirs (`<config>` + `~/.local/share/opencode`), NOT our plugin file — keying on our own artifact would chicken-and-egg (opencode could never be auto-detected until after we'd installed into it), the same reason Reasonix/CodeWhale probe their CLI dirs. `hook_command` returns the absolute path (Err on non-UTF-8); `binary_strategy = EmbedAbsolute` (opencode runs the plugin under Bun, no PATH reliance — the per-target binary-resolution strategy is the `BinaryStrategy { BareNameOnPath | EmbedAbsolute }` enum on `Target`, replacing the old `needs_path_warning`/`needs_resolved_binary` bool pair). **Structured core, one presenter:** `install::install_target`/`uninstall_target` are the pure ConfigLock round (read→merge→backup→write, invariant #4 intact) returning an `InstallReport`/`UninstallReport`; the in-TUI **Sources panel** (`s` — see `src/tui/CLAUDE.md`) is the SOLE presenter (renders the report into a one-line result, hook path resolved via `hook_path=None`/`config=None`). There is no CLI presenter anymore — `run_install`/`run_uninstall` were deleted with the `install-hooks`/`uninstall-hooks` subcommands. `has_hooks` is `pub` (main.rs — a separate crate from the lib — reads it for `config::resolve_connected`'s migrate-default).
- "How does the default character pack get into the binary?" → `tui::embedded_pack` does the `include_str!` at compile time (from `crates/pixtuoid/sprites/default/`); `sprite::format::load_pack_from_strings` parses it.
- "How do custom sprite packs work?" → `pixtuoid init-pack ./dir` extracts the skeleton template from `sprites/skeleton/` (embedded via `include_str!`, see `init_pack.rs`). `pixtuoid validate-pack ./dir` loads the pack and checks against `REQUIRED_CHARACTER_ANIMATIONS` / `OPTIONAL_*` registries in `sprite::format`. `--pack-dir` CLI flag or `pack-dir` config key loads a custom pack at runtime. Custom packs only need character sprites — furniture/environment animations are merged from the embedded default via `Pack::merge_from()` (only `OPTIONAL_FURNITURE_ANIMATIONS`, never character poses). The robot pack at `sprites/robot/` is a TV-head character set (10×12 sprites).
- "How does the crash log work?" → `main.rs::install_crash_hook` sets a panic hook that restores the terminal, writes a timestamped backtrace to `~/.cache/pixtuoid/crash.log`.
- "Where do runtime errors / config warnings surface?" (#157, #87) → THREE sinks, by failure class. (1) **Always-on file log**: TUI mode installs a warn-floor file subscriber unconditionally (`main.rs`; the alternate screen owns the terminal, so the file is the only runtime channel) — every `tracing::warn!/error!` (source deaths, decode failures, config warns) lands in `~/.cache/pixtuoid/log` (or `$XDG_STATE_HOME`/`$PIXTUOID_LOG`), one-deep rotated at 5MB. (2) **Pre-altscreen stderr**: `config.rs` resolvers (`load`/`resolve_theme`/`resolve_pets`) stay layer-clean and COLLECT human-readable warnings into a `&mut Vec<String>`; `main.rs` eprintlns them before `setup_terminal` (visible in scrollback after exit — the crash hook's channel; skipped in headless where the stderr tracing subscriber already shows them). (3) **In-TUI source-health footer**: a fatal `Source::run` exit is published as `SourceDeath` on a `watch` side channel (`SourceManager::spawn_with_health` — deliberately NOT an `AgentEvent`; the one event channel carries agent activity, invariant #2), threaded `driver.rs → run_tui → TuiRenderer::set_source_warning`, and `hud.rs::source_warning_message` renders a persistent footer warning that replaces the stats tier (the stats go stale when a transport dies) and survives every width + floor transitions.
- "How does config persistence work?" → `config.rs` defines `AppConfig` (theme + optional max-desks cap + pack-dir + `[sources]` connected flags + `[[pets]]`), `config_path()` (XDG-aware: `$XDG_CONFIG_HOME/pixtuoid/config.toml` or `~/.config/pixtuoid/config.toml`; an empty/whitespace `XDG_CONFIG_HOME` counts as unset (the basedir spec defines EMPTY as unset; whitespace-only additionally fails its paths-must-be-absolute rule) — the shared `io::nonempty_env` filter, also used for `XDG_STATE_HOME` in main.rs's crash-log/log paths and for `PIXTUOID_HOOK`), `load()` (never crashes — logs warning on malformed TOML), `save()`/`save_version()` (route through `install/io.rs`'s ConfigLock — one lock across the whole read→mutate→write, fsync + atomic rename + perms preservation; they edit the raw document via `toml_edit`, so unknown keys and the user's comments/formatting survive; a config that EXISTS but doesn't parse is NEVER rewritten — the save errs and both callers warn-and-continue, and the first overwrite of an existing file takes a one-time `config.toml.pixtuoid.bak`), `resolve_theme()` (CLI > config > default; returns `Result<&'static Theme>` — the ONE place themes are validated: a `--theme` CLI typo is a HARD error listing valid names, a config typo is a soft warn+fallback so a stale config never bricks startup; `runtime::RunConfig` carries the already-resolved `&'static Theme`, so an unknown theme can't reach the runtime by construction). Theme saved on `[t]` picker Enter confirm in `tui/mod.rs`. `max-desks` is an optional cap — when set, auto-compute clamps each floor's capacity to `min(layout_capacity, cap)`. When absent, fully auto-computed from terminal size. `pack-dir` supports `~` expansion via `resolve_pack_dir`. **Per-source connection is `[sources]`** — a `BTreeMap<String, bool>` (`source_id → connected`), persisted by `save_source_connected` (via the same `toml_edit` ConfigLock path; `skip_serializing_if = is_empty` so a fresh config omits the table). **`resolve_connected(&AppConfig, has_hooks) -> HashSet<String>`** (pure/injectable, FS-free for tests) is the boot seed for `runtime::ConnectedSources`: an explicit `[sources]` flag wins; an ABSENT flag MIGRATES from the current install state — a target-bearing source is connected iff its hooks are installed (`install::has_hooks`), a no-target source (Antigravity) defaults connected. The Sources panel (`s`) is the only writer; the reducer task is the reader (gate + reconcile). **Pets are `[[pets]]` array-of-tables** — each `PetEntry { kind: String, name: Option<String> }`; `kind` is a raw String (NOT a serde `PetKind`) ON PURPOSE so a typo is warn-skipped in `resolve_pets`, not fatal (a typed enum would fail the whole `toml::from_str` → `load`'s malformed arm wipes EVERY setting). **`resolve_pets(&AppConfig) -> Vec<Pet>`** maps the stanzas → `Vec<Pet>` (`Pet { kind, name }`): absent `pets` → all kinds with default names; `pets = []` → none; unknown `kind` → warn+skip (non-fatal); `name` trimmed, empty/absent → `PetKind::default_name()`. No runtime kind→name map — the name rides on each `Pet`, so the renderer reads `pet.name` directly. No `enabled-pets`/`[pet-names]` keys (removed; backward compat is a non-goal). **`pets` MUST stay the LAST field in `AppConfig`** by convention — an array-of-tables serializes cleanest after all scalar keys (where `pet_names` used to sit); don't rely on toml's key/table interleaving.
- "How do multi-floor offices work?" → `tui/floor.rs` defines `FloorCtx` (per-floor render state: router/cache/overlay/history/**light** [LightingState]/motion) + `FloorTransition` (slide animation) + `build_floor_scene()` (agent projection). `tui_renderer/mod.rs` owns `Vec<FloorCtx>` + `Vec<RgbBuffer>` and switches between them. Floor membership is stored on `AgentSlot.floor_idx` (set once by the reducer at desk assignment, immutable thereafter). Each floor's capacity is **boot-seeded from the actual terminal size** via `compute_boot_capacities()` in `runtime/driver.rs` (queries `crossterm::terminal::size()` at startup, falls back to `FALLBACK_DESKS=16` in headless mode or on error). Per-frame, `tui/mod.rs` calls `SceneLayout::compute_with_seed` for each floor's seed and writes the result via per-floor `AtomicUsize::fetch_max` (monotone growth — capacity never shrinks, preventing cumulative-offset shifts that would remap floor 1+ agents to wrong desks). The reducer syncs all `MAX_FLOORS` capacities into `scene.floor_capacities: [usize; MAX_FLOORS]` each tick. `next_free_desk` in `state/mod.rs` scans `0..total_capacity()`. On terminal shrink, agents beyond the layout's capacity become invisible but stay alive; they reappear when the terminal grows back. PageUp/PageDown/↑↓/jk in `tui/mod.rs`. Agents past a floor's capacity overflow to additional floors (up to `MAX_FLOORS=10`; only 5 layout variants exist, so floors 6-10 cycle through the same 5 looks).
