# 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 / install-hooks / uninstall-hooks / validate-pack / init-pack)
├── config.rs           AppConfig persistence (~/.config/pixtuoid/config.toml), XDG-aware
├── runtime/            mod.rs (RunConfig, boot-capacity math, headless summarize — all unit-tested),
│                       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. Also creates the ONE
│                       shared ChildEndUnclaims handle (#246) and hands it to BOTH
│                       ClaudeCodeSource (hook-tee producer + CC watcher consumer) and
│                       CodexSource (watcher consumer) — manual wiring like the rest of
│                       source construction, the registry can't force it)
├── init_pack.rs        extracts the embedded skeleton pack to a target dir for `init-pack`
├── install/            multi-target (Claude + Codex + Reasonix) hook install via the `Target` registry:
│                       mod.rs (run_install/run_uninstall, plan_targets, interactive_pick),
│                       target.rs (Target trait + TARGETS = [CLAUDE, CODEX, REASONIX]),
│                       claude.rs / codex.rs / reasonix.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),
│                       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 `install-hooks` (or an uninstall 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]`); `install::plan_targets` decides which CLIs to act on (auto-detect + confirm + non-TTY policy). **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.
- "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 + `[[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`. **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).
