# pixtuoid-core — agent guide

The **headless library**: no terminal dependencies (`ratatui`/`crossterm`/`stdout`
are forbidden here — see workspace invariant #1). Owns the source/decoder seam,
the reducer/state machine, sprite parsing, terminal-agnostic layout geometry,
and the pure physics/pose math. The binary (`pixtuoid`) and renderer
(`pixtuoid/src/tui`) sit on top of this. See the workspace
[`CLAUDE.md`](../../CLAUDE.md) for cross-cutting rules.

## Layout

```
src/
├── source/             Source trait, hook+jsonl decoders, listeners, SourceManager
│                       mod.rs (Source trait [native async via Send-bounded RPITIT] + its object-safety
│                       twin DynSource [boxed-future, blanket-impl'd — SourceManager's Box<dyn> currency;
│                       source authors never name it], AgentEvent + agent_id(), REGISTERED_SOURCES, AgentId::from_parts),
│                       registry.rs (THE per-source fact table: SourceDescriptor{label_prefix, line_decoder,
│                       hook{id_key,custom}, caps} — one row per CLI; doc(hidden), not public API),
│                       decoder.rs (shared utils + decode_hook_payload, a registry-driven dispatcher),
│                       hook/ (HookSocketListener facade — mod.rs shared generic handle_conn over AsyncRead [ONE framing/decode path for both transports], unix.rs UnixListener [liveness arbitration is an EXCLUSIVE advisory lock on a sibling `<sock>.lock`, held for the daemon's lifetime and NEVER unlinked — bind try-locks it: a live owner ⇒ the typed SocketBusy, which ClaudeCodeSource::run catches and degrades to transcript-only (warn + watcher.run, NO SourceDeath — hooks stay with the owning instance); lock acquirable ⇒ the previous owner is dead ⇒ the socket file is residue and is reclaimed. connect() errnos NEVER decide liveness for a lock-holding owner (a backlog-saturated LIVE daemon yields ECONNREFUSED on macOS / EAGAIN on Linux — the kernel note in pixtuoid-hook/tests/shim.rs); the belt-and-braces post-lock probe only protects a lock-LESS live listener (older pixtuoid mid-upgrade / squatter), and INSIDE that probe non-WouldBlock errnos still decide reclaim — so a saturated lock-less owner can be stolen from during the mixed-version window, an accepted residual that ages out once every daemon holds the lock. Every OTHER bind error stays fatal → SourceDeath. The socket binds at a temp name, chmods 0600, then atomically renames onto the final path — NO process-global umask mutation (it raced other tokio workers' file creation)], windows.rs named-pipe accept loop — a second instance's CreateNamedPipeW fails ACCESS_DENIED against the owner's `first_pipe_instance` and maps to the SAME typed SocketBusy → same transcript-only degradation; pipe uses owner-only SDDL `D:P(A;;GA;;;OW)` [umask-0700 equivalent; the kernel copies the descriptor at each CreateNamedPipe]; a failed connect is NOT a reusable instance, so windows.rs recreates the server after each connect error), jsonl/ (the JsonlWatcher as a directory module — mod.rs: JsonlWatcher + builders/test seams + the run() select loop + WatchCtx/SourceDecoders + the public re-exports [crate::source::jsonl::{JsonlWatcher, ProbeSnapshot, ChildEndUnclaims, …} paths are unchanged]; walk.rs: walk_jsonl + the unified first-sight gate should_seed_at_eof + scan_root/emit_first_sight/scan_pending_tasks + detect_parent_id/extract_cwd; liveness.rs: the whole probe ladder — ProbeSnapshot/LivenessProbe, refresh_probe_snapshot + pid_bindings/rebind migration, NegativeVouch, emit_session_exit/unbind_session, revouch_gated_files, emit_proof_of_life; unclaim.rs: ChildEndUnclaims + drain_child_end_unclaims [#246]; health.rs: FailureLatch; tests.rs: the sibling unit-test module),
│                       claude_code.rs / codex.rs / antigravity.rs (per-source decode + label fns + Source impls),
│                       cc_probe.rs (the CC `~/.claude/sessions/<pid>.json` registry probe: live_cc_session_ids
│                       [re-exported at `claude_code::live_cc_session_ids`] + RegistryParse/parse_registry_entry +
│                       the pid-start identity check + cc_sessions_dir),
│                       reasonix.rs (HOOK-ONLY: hook-payload decoder, no Source impl — no watchable JSONL),
│                       manager.rs (SourceManager::spawn / with_source / spawn_with_health —
│                       publishes SourceDeath on a watch channel so the binary can surface a
│                       fatal source exit in the TUI footer, #157; plain data, invariant #1 holds)
├── state/              SceneState + Reducer (event coordinator: Transport-tagged dedup, the
│                       cross-slot active_tasks/gated_before_waiting correlation, the sweeps) +
│                       fsm.rs (Layer-A per-agent transitions) + scope.rs (Layer-B parent↔subagent tree) +
│                       correlation.rs (the SEVEN reducer-private correlation maps as one Correlation
│                       struct owned by the reducer — entry types, TTL consts [re-exported at the
│                       state::reducer:: paths], freshness predicates, one gc(now); intra-layer
│                       bookkeeping extraction, DECISIONS stay in reducer.rs);
│                       GlobalDeskIndex / FloorLocalDeskIndex newtypes encode the two desk-index
│                       spaces (AgentSlot.desk_index is GLOBAL; the typed bridge to a floor's
│                       home_desks is floor_local_desk(), or the documented single_floor_local()
│                       identity for a single-floor scene)
├── sprite/             .sprite parser, pack.toml loader, half-block blitter, animator, Pack::merge_from
├── render/             Renderer trait + TestRenderer (feature = "test-renderer")
├── layout/             zone-based office geometry (terminal-agnostic):
│                       mod.rs (SceneLayout struct, Bounds, Point, Size, WallSegment, constants, accessors) + tests.rs sibling,
│                       compute.rs (compute_with_seed + 4 private helpers),
│                       decor.rs (role enums WaypointKind/PodDecor/PlantKind/WallDecor + Facing — carry NO
│                         dimensions, each .furniture()-maps to the unified Furniture geometry enum; the ONE
│                         table furniture_def(Furniture) + desk_furniture_def() — single source for EVERY
│                         point-footprint piece's FOOTPRINT [blocked ground] AND visual [sprite size],
│                         occupies_pos, dwell, ApproachSides. Includes the singleton/per-room bodies
│                         [MeetingSofaBody/MeetingTable/PantryTable/PantryChair/FloorLamp/LoungeSideTable].
│                         Only PLANT_FOOTPRINT [shared by the 4 plant rows] + DESK_APPROACH/desk_walk_anchor
│                         live alongside; walls use the linear WALL_THICK_H/V),
│                       placement.rs (Anchor {Center,TopLeft} + anchored_top_left + z_sort_row — the ONE
│                         convention for WHERE a box sits relative to its `pos` [footprint origin = sprite
│                         origin] and its y-sort row; shared by mask.rs AND the tui renderer so the blocked
│                         ground, the blitted sprite, and the z-key can't drift. Anchor is passed per
│                         PLACEMENT SITE, not stored on FurnitureDef — Whiteboard is Center as pod decor but
│                         TopLeft as wall decor),
│                       mask.rs (build_walkable_mask — stamps each obstacle via stamp_anchored/anchored_top_left
│                         [no inline origin math]; WALL_THICK_H/V wall footprints; pantry south-strip +
│                         meeting-furniture-too-narrow gate are the documented exceptions; a TopLeft
│                         south-strip CENTERS the narrower ground footprint under the sprite's visual
│                         width — the wall-decor whiteboard's 10px wheel span sits at sprite cols 2-11,
│                         not hugged to the west edge),
│                       approach.rs (stand_point [obstacle render anchor] + approach_point [A*'s goal] +
│                         seated_foot_cell [the seat's render-anchor inverse] — the walkable cell an agent
│                         stands/approaches at, on the reachable allowed side nearest its desk, filtered by
│                         FurnitureDef.approach (ApproachSides) AND ReachSet),
│                       reach.rs (ReachSet — coarse-cell BFS over the WalkableMask mirroring the tui A* grid
│                         coarsening; reaches(p) ⇒ A* routable, so approach_point never targets a walled-off cell)
├── physics.rs          pure walk-pace physics (no terminal/router deps): WalkIntent, WalkProfile,
│                       walk_profile (trapezoidal/triangular kinematics), walk_progress (t_x1000),
│                       walk_arrived, speed_mult, pause_ms_for; constants: V_CRUISE_COMMUTE=0.36,
│                       V_CRUISE_SNAPBACK=0.65, V_CRUISE_WANDER=0.25, WALK_ACCEL=6.5e-4,
│                       WALK_ACCEL_SNAPBACK=2.0e-3, SPEED_MULT_MIN/MAX, PAUSE_MS_MIN/MAX
├── platform.rs         cross-platform home-dir resolution (user_home(), USERPROFILE-first on
│                       Windows — HOME is unset there and Git Bash's HOME is POSIX-form) +
│                       codex_home() (honors CODEX_HOME when it points at an existing dir, else
│                       <home>/.codex — codex's own precedence; routes BOTH CodexSource::
│                       default_paths and the installer's config.toml path, so they can't disagree)
├── pose/               pure state→pose derivation + wander state machine (no terminal deps);
│                       mod.rs (production) + tests.rs sibling.
│                       ENTRY_ANIMATION_MS is demoted: not a duration knob, only the spawn-window
│                       upper bound for tui entry-routing and door-cosmetic gating. Exports
│                       derive_state_only, aimless_wander_seed, pick_aimless_dest, and the
│                       ABSOLUTE per-spot dwell knobs (replacing the old PHASE_*_FRAC fractions):
│                       dwell_ms(kind,id) [sofa/meeting 20-40s, pantry 10-18s, vending 4-8s],
│                       seated_dwell_ms(id) [desk 15-30s], est_wander_cycle_ms(id) — for tui::motion
│                       (render authority) + idle_pose (stateless overlay, fixed WANDER_*_EST_MS).
├── walkable.rs         WalkableMask (static bool grid) + OccupancyOverlay (dynamic per-frame)
└── tests/              one integration test per concern
```

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

- **CC hook payloads DO include `tool_use_id`** in `PreToolUse` and `PostToolUse` (verified by sniffing live payloads). The decoder reads it; the reducer's hook-wins dedup actually fires. **The dedup is asymmetric by event kind (#150)**: a recorded hook End suppresses both JSONL kinds (covers the tool's whole lagged JSONL pair), but a recorded hook Start never suppresses a JSONL End — when the PostToolUse hook drops (best-effort shim), that JSONL End is the only completion signal left, and eating a Task self-End would leak `active_tasks` for the rest of the session. The kind lives in the map *value* (an End overwrites its tool's Start entry) — don't "simplify" to a kind-in-the-key or exact-kind match; both regress a pinned case (`late_batched_jsonl_pair_after_delivered_hook_end_is_fully_dropped`).
- **CC now keys on the session UUID, not the transcript path.** The hook uses `IdKey::SessionId` (the `session_id` field); the watcher overrides `IdDeriver` with `cc_id_from_path` (the transcript filename stem, which equals that UUID); `decode_cc_line` re-derives the same UUID via `cc_id_from_path`; and `detect_parent_id` returns the `<parent-uuid>` directory component — the path segment immediately before `subagents/`. That component is the parent's own `cc_id_from_path` value, so the subagent→parent link is **cwd-split-safe**: even when the subagent transcript lands under a different project dir (a git-worktree cwd-split), only the cwd-derived prefix differs — the `<parent-uuid>` segment is identical. **Antigravity** (its keying) and CC's `SubagentStop` arm (the `agent_transcript_path` stem) fold a path through `decoder::normalize_path_key` (identity on Unix; on Windows it strips a `\\?\`/`\\?\UNC\` verbatim prefix then folds `\`→`/` + casefolds — Antigravity emits backslash paths on Windows, NTFS is case-insensitive, and the `\\?\` strip (#197) is defensive so a `canonicalize`-style verbatim path still coalesces with its plain form). `AgentId::from_transcript_path` (the test/example shim) normalizes via that fn too, so test expectations stay platform-consistent by construction. Pinned by `tests/sources/decode/mod.rs::hook_and_watcher_keys_coalesce_for_one_file` (+ cfg(windows) twins). Don't add a CC keying site without routing it through `cc_id_from_path`; don't add an Antigravity site without `normalize_path_key`.
- **CC hook `transcript_path` always points to the PARENT'S transcript**, even when a subagent is the actor — so subagent TOOL hook events hash to the parent's `AgentId`. The reducer's `active_tasks: HashMap<AgentId, HashSet<String>>` suppresses hook `ActivityStart`/`End` for any agent currently inside a `Task` tool; JSONL has correct subagent attribution via the per-subagent transcript file at `<parent_uuid>/subagents/agent-<id>.jsonl`. The subagent→parent link is derived by `detect_parent_id` as the **`<parent-uuid>` directory component** — the path segment immediately before `subagents/` — which equals `cc_id_from_path` of the parent transcript; this is cwd-split-safe (a git-worktree subagent transcript lands under a different project-dir prefix, but the `<parent-uuid>` segment is identical). **Since #241 CC also decodes its OWN `SubagentStart`/`SubagentStop` hooks** (`claude_code::decode_cc_hook_custom`, dispatched via the registry's `hook.custom` exactly like Codex's — these change the event's SUBJECT to the child, inexpressible in the shared session-keyed arms): `SubagentStart` registers the child instantly with the parent link (the JSONL watcher's later `SessionStart` for the same transcript coalesces); `SubagentStop` emits the child's `SessionEnd` — the ONLY end signal a **Workflow-fleet** subagent gets (no per-agent `Agent` tool_use in the parent transcript → b1 structurally can't fire; no transcript end marker → finished fleet agents used to hold desks until the stale sweeps). **Keying nuance**: the wire `agent_id` is BARE hex (CC v2.1.170 capture) while the transcript filename stem — the watcher's id space — is `agent-<id>`; Start prefixes the wire id (tolerating an already-prefixed form without double-prefixing), Stop keys on `cc_id_from_path(agent_transcript_path)` (the authoritative key — exact watcher parity, covers the deeper `subagents/workflows/wf_*/` nesting) with the prefixed wire id as fallback. Captured wire fixture + cross-transport lifecycle regression: `tests/sources/claude/`. The Task signal travels as `ToolDetail::Task` (a typed enum variant on `AgentEvent::ActivityStart.detail`); the reducer pattern-matches on `d.is_task()` rather than scanning a free-form string. **`make_tool_detail` detects the subagent-dispatch tool SEMANTICALLY** — by the presence of `subagent_type` in the tool input — falling back to the known names (`Agent` in current CC, legacy `Task`). This is deliberate resilience: the dispatch tool was renamed `Task`→`Agent` (CC v2.1.63, **undocumented** — issue #29677), silently disabling suppression + b1 completion (the parent then shows the subagent's tools — observed live) until we keyed on the stable `subagent_type` field. Don't revert to name-only matching. A dispatch under an unrecognized name still works (semantic) and logs a `debug!` drift breadcrumb so the known-name set / docs can be updated. **Liveness flows UP the tree** (`scope::refresh_lineage`, in the Layer-B `state/scope.rs` module that owns the parent↔subagent tree ops): any `ActivityStart`/`ActivityEnd`/`Waiting` refreshes `last_event_at` for the actor AND every ancestor, so a delegating parent isn't stale-swept while a subagent is still working — whether the work arrives as a suppressed hook (misattributed to the parent) OR as the subagent's own JSONL (hooks are best-effort; this covers dropped ones). It's the mirror of `cascade_exit` (which pushes EXIT *down*). The suppress block relies on this up-refresh — don't drop it, or a single delegation > `STALE_ACTIVE_TIMEOUT` (10 min) would make the live parent look stale and the cascade would evict its still-working subagent.
- **JSONL watcher skips historical transcripts — on EVERY first-sight path, not just startup.** `walk_jsonl` in `source/jsonl/walk.rs` runs ONE first-sight gate (`should_seed_at_eof`) the first time *any* pass sees a file (the initial seed, the 250ms rescan, the 60s poll, a notify event): a `.jsonl` with mtime outside `DEFAULT_INITIAL_WINDOW` (currently 1 hour; configurable via `JsonlWatcher::with_initial_window`) **OR** carrying a `session_end`/`SessionEnd` marker (tail-scanned last 8 KB) has its cursor seeded at end-of-file and emits NO `SessionStart`; only a recent, not-ended file is read from the top. Without this, ~hundreds of stale sessions saturate the desk allocator (capacity is auto-computed from terminal size; the headless fallback is `FALLBACK_DESKS=16` in `pixtuoid/runtime/mod.rs`). mtime is only a liveness PROXY, so the gate composes with an optional ADDITIVE-ONLY **liveness probe** (`JsonlWatcher::with_liveness_probe`, refreshed once per scan pass; notify walks reuse the latest snapshot): a transcript whose `IdDeriver`-derived session id the probe vouches for bypasses the gate — CC wires `claude_code::live_cc_session_ids` (the first-party `~/.claude/sessions/<pid>.json` registry, entries verified live via `kill(pid, 0)` PLUS a PID-reuse identity check (#220): when the entry's `startedAt` (ms epoch) and the kernel start time (`pid_start_time_secs` — macOS `proc_pidinfo(PROC_PIDTBSDINFO)` → `pbi_start_tvsec`) are BOTH available they must agree within 10s (measured live: CC stamps `startedAt` ~1.2s after process start) or the entry is skipped as recycled; either side missing → pid-alive-only, so the check is additive (Linux start time is ticks-since-boot — conversion deferred, identity check macOS-only); registry-shape drift now warns once (#247): an entry that parses as JSON but lacks a consumed key (`pid`/`sessionId` missing or mistyped — `RegistryParse::ShapeDrift`) yields no vouch and emits ONE `tracing::warn!` per process run naming the file + key (the registry is undocumented with no fetchable upstream text to diff, so the consumer is the drift detector; not-JSON files AND value-level corruption (pid <= 0, empty sessionId) stay silent — half-written mid-write transients / junk, not format drift, and the inline `live_shape_entry` fixture pins the current shape); Unix only — Windows returns an empty set = pure mtime), attached only when the projects root sits in the standard `<claude_home>/projects` layout (a `--projects-root /tmp/fixture` replay keeps pure-mtime behavior). Codex wires `codex::live_codex_rollout_ids` (open-FD binding — Codex has NO registry, but a live `codex` process holds its rollout `.jsonl` open in append mode for the whole session, upstream `RolloutRecorder`; pid → open fd → rollout path → UUID via `source/fd_probe.rs`: macOS libproc FFI / Linux `/proc/<pid>/{comm,fd}`; other platforms return the empty set = pure mtime), attached only under codex's first-party layout (`codex_probe_root`: file_name `sessions` AND parent `.codex` OR the resolved `codex_home()/sessions`, so a `CODEX_HOME` user keeps the ladder — a `--codex-sessions-root /tmp/fixture` replay keeps pure-mtime behavior). Subagent transcripts never match the CC probe (their stems are agent ids, not session UUIDs). Other long-idle live sessions (Antigravity — no probe) only re-appear after they next write. **An OVERSIZED span (> `MAX_PENDING_BYTES` = 1 MiB pending) is skipped to EOF, never replayed — but it still registers (#204, bounded head read for cwd/label) and, since #222, tail-scans the last 256 KiB (`TASK_SCAN_BYTES`, `scan_pending_tasks`) for UNMATCHED Task dispatches (an `ActivityStart{detail: Task}` with no later `ActivityEnd` for the same tool_use_id) and re-emits exactly those, Jsonl-tagged** — restoring subagent-leak suppression + b1 completion for a mid-attach to a delegating parent whose in-flight `Agent` dispatch sat in the skipped backlog (its PreToolUse hook predates attach, so `active_tasks` had no other seed). Everything else the scan decodes is DISCARDED (Task-seeding, not a replay), the scan is skipped when the span ENDED (terminator wins) or the file stays unregistered (no slot → JSONL no-ops), and tail-window geometry means a synthesized start can't be a false leak (a completion is always later in the file than its start). Residuals: a dispatch buried deeper than 256 KiB before EOF keeps the old skip behavior (bounded, pinned by `oversized_attach_dispatch_outside_window_is_missed`), and a mid-RUN oversized skip's synthesized start may be dedup-eaten by a recent hook record — correct, the hook copy already seeded `active_tasks` (#150). **The probe is ALSO ongoing liveness, not just admission (#220):** after each refresh (initial seed / 250ms rescan / 60s poll — the three sites that re-snapshot `live`) the watcher emits `AgentEvent::ProofOfLife` for every vouched id (`emit_proof_of_life`, after `scan_root` so fresh admissions already have slots); the reducer keeps a PRIVATE `recent_proof_of_life` map (deliberately not an `AgentSlot` field — no semver surface) with a 150s TTL (`PROOF_OF_LIFE_TTL`, 2.5× the 60s poll: two missed polls + slack) that `sweep_stale`'s candidate pass consults — a vouched slot is never staleness-swept while the process lives (motivating case: a permission-parked CC session renders Active after attach-replay — its hook-only Waiting is unreconstructable from JSONL — and `STALE_ACTIVE_TIMEOUT` would reap it mid-decision). **The vouch also shields the vouched slot's actively-DELEGATING subtree**: the probe never vouches subagent ids (their stems are `agent-<id>`, not session UUIDs) and a permission-parked parent renders Active, not Waiting (so `has_waiting_ancestor` can't exempt its blocked-but-live child) — `sweep_stale` therefore also skips a slot when any ANCESTOR (`scope::has_ancestor_where`, the cycle-guarded walk `has_waiting_ancestor` shares) holds a fresh vouch (`vouch_fresh`, the ONE TTL predicate) AND a non-empty `active_tasks` entry; the active-delegation gate preserves the 30-min idle backstop for completed lingering children (the b1 chained-dispatch residual). When the live signal disappears (registry entry removed / rollout fd closed) emissions stop and normal sweeps resume after the TTL lapse — subtree shield included. **And since #223 the probe drives the NEGATIVE vouch — vouched-then-gone is a high-confidence exit.** Probe failure is now EXPLICIT: `LivenessProbe` returns `Option<ProbeSnapshot>` (`ids` + `pid_of`, the id→owning-pid binding for the exit watch). `None` = the enumeration itself FAILED and the watcher changes NOTHING (admission keeps the previous snapshot, miss windows neither open nor age, no `ProofOfLife` — the reducer's TTL absorbs the gap); `Some` with empty `ids` = a healthy "nothing alive" observation (meaningful!), so absence of an id only counts inside a `Some` snapshot. A previously-vouched id MISSING from two healthy snapshots ≥ `NEGATIVE_VOUCH_MIN_SPAN` (60s — immune to Codex's brief drop-and-reopen fd gap on a write failure and to initial-seed/250ms-rescan adjacency) apart gets a Jsonl-tagged `SessionEnd` — the exit signal the CLI never writes. For Codex an fd close IS session end (including a thread switch within one process: the old rollout closes, the new one opens and gets its own vouch); CC's clean exits already `SessionEnd` via the hook, so this catches the abrupt ones (Ctrl-C, terminal close, kill) in ~60–120s instead of the 10–30 min stale-sweep. The confirmation also un-claims `seen` for the session's tracked paths (mirroring the decoded-SessionEnd revive) so a later resume/append re-registers through `emit_first_sight` — a wrongly-ended live session self-heals on its next write or re-vouch. Per-probe failure semantics: CC — unreadable-or-MISSING registry dir = `None` (ambiguous on a machine where CC runs), empty-but-readable dir = `Some(empty)`; Codex — an ABSENT sessions root = `Some(empty)` (codex never run is not a failure), `None` only when the proc-table enumeration fails (`fd_probe::pids_by_name` is now `Option<Vec<i32>>`; per-pid fd failures stay non-failures); Windows: CC = `None` (no validated pid liveness); Codex = `Some(empty)` (the `fd_probe` fallback enumerates nothing — CI-pinned by the un-gated `pids_by_name_for_nonexistent_process_is_some_empty` — so nothing is ever vouched and the negative vouch can never arm). The reducer needed NO change: a `SessionEnd` for a live slot exits it (+cascade), an unknown id is a no-op, and the `ProofOfLife` exemption is evicted with the slot in `sweep_exited`. Pinned by `negative_vouch_emits_session_end_after_sustained_disappearance` / `one_missed_snapshot_does_not_end_the_session` / `probe_failure_changes_nothing` in `tests/watcher/liveness.rs` (the `with_negative_vouch_min_span` `#[doc(hidden)]` seam shrinks the span like `with_poll_interval` does the poll). **On top of the negative vouch sits the INSTANT exit (#223 rung 2):** a watcher with a probe spawns ONE `source/exit_watch.rs` watcher (a detached `std::thread` — deliberately NOT `spawn_blocking`, a forever-loop would pin a blocking-pool slot; kqueue `EVFILT_PROC NOTE_EXIT` on macOS, `pidfd_open`+`poll` on Linux, `spawn() = None` elsewhere) and registers every pid a healthy snapshot binds (`ProbeSnapshot::pid_of`, folded into the watcher-local `pid_bindings: pid → {ids}` by `refresh_probe_snapshot` — bindings are ADDITIVE per snapshot except an observed REBIND, which migrates the id to its new pid (a `codex resume` in another process; the stale binding's death must not end the live session); otherwise the vouch ladder owns "gone"). The bound process dying emits each bound id's `SessionEnd` within milliseconds through the SAME `emit_session_exit` path the negative vouch uses (one exit path, can't fork — `seen` un-claim AND a `live` admission-set purge included (the purge stops a probe-FAILURE pass's stale snapshot from re-vouching the just-ended id into a ghost replay; for the negative-vouch caller it's a no-op), so the resume self-heal is identical) and `NegativeVouch::forget`s the id so the slower rung can't re-confirm; conversely a negative-vouch confirm `unbind_session`s its id from `pid_bindings` so a codex-style long-lived process exiting later is quiet (one process owns many rollouts — a closed rollout's session ends while the process lives). Registration races resolve at the primitive: ESRCH at registration (the macOS `EV_RECEIPT` receipt / Linux `pidfd_open`) = already dead = the exit synthesizes immediately; EPERM = dropped silently; `NOTE_EXITSTATUS` is deliberately NOT requested (status is child-only and triggers xnu's cred check — plain `NOTE_EXIT` watches same-user non-children unprivileged); ENOSYS (pre-5.3 kernel) = backend off. EVERY failure (kqueue/pipe init, kernel registration, thread death, send fail) degrades to the rungs below — the exit ladder is **instant (ms) > negative vouch (~60–120s) > ProofOfLife TTL lapse + stale sweeps** — and `run()` parks a keepalive sender clone when no ExitWatch holds one, so the select! exit arm stays forever-pending instead of resolving `None` every pass. Pinned by `instant_exit_emits_session_end_when_bound_pid_dies` / `negative_vouch_confirm_unbinds_pid_so_a_later_exit_is_quiet` in `tests/watcher/liveness.rs` + the `exit_watch` unit suite; CI asymmetry (the `fd_probe` precedent): the Linux pidfd arm is CI-pinned (ubuntu), the macOS kqueue arm local-preflight-pinned (the windows job compiles only the `None` arm). ProofOfLife refreshes ONLY the exemption: it never creates a slot (unknown id = no-op — only hook tool/permission events synthesize), never touches activity state or `last_event_at` (debounce/back-fill stay event-driven), never delays `SessionEnd`/cascade (exiting slots are skipped on insert and aren't sweep candidates anyway). Pinned by the `proof_of_life_*` + `vouch*` suite in `tests/reducer/liveness.rs` + two watcher tests in `tests/watcher/liveness.rs`: `watcher_emits_proof_of_life_for_probe_live_ids` (the initial-seed emission) and `poll_arm_refreshes_probe_snapshot_and_reemits_proof_of_life` (the 60s-poll snapshot refresh + re-emission, made testable by the `#[doc(hidden)]` `with_poll_interval` builder seam). A 250ms post-startup rescan catches files missed by `read_dir` during the initial seed (APFS metadata propagation race). **Unifying the gate inside `walk_jsonl` is the #85 fix** — it used to be split (only the old `initial_seed_walk` gated; the rescan/poll/notify read first-sight files from offset 0), so when the initial `read_dir` lost the APFS race the 250ms rescan resurrected a missed ended/stale session as a phantom `SessionStart` (a real resurrect-on-restart bug, not only a test flake).
- **Watch backend: native in prod, polling in tests.** `JsonlWatcher::run` watches via `notify`'s `RecommendedWatcher` (FSEvents/inotify/…) plus the 60s `scan_root` poll backstop. Integration tests call `force_polling_backend_for_tests(interval)` — a `#[doc(hidden)]`, set-once process hook — to swap in `notify::PollWatcher` at ~25ms instead: a real FSEvents stream costs **tens of seconds per test to set up + tear down** on macOS (it was the bulk of the watcher suite's runtime, and the real-FSEvents coupling was the seam behind the #85 flake — see the line above). A second `#[doc(hidden)]` seam, the `with_poll_interval` builder, shrinks the 60s `scan_root` poll backstop per-watcher (default unchanged in prod) — the poll arm's probe refresh + ProofOfLife re-emission are untestable at the production cadence. Both backends impl `notify::Watcher` and feed the SAME channel, so `run`'s `select!` loop is backend-agnostic. The poll uses **`with_compare_contents(true)`** (hash-based) on purpose: `PollWatcher`'s default change-date diff misses rapid appends/truncate-rewrites (the `watcher_does_not_consume_partial_trailing_line` + `watcher_resets_cursor_on_truncation_below_cursor` cases). This seam swaps only the *delivery* mechanism, never the logic — the first-sight gate stays covered watcher-free by the deterministic `walk_jsonl` unit tests. Don't wire a real FSEvents watcher into tests.
- **A hook event from an UNKNOWN session id REGISTERS it — hooks are proof of life.** A hook can only come from a live process, so a Hook-transport `ActivityStart`/`ActivityEnd`/`Waiting` whose `AgentId` has no slot means a LIVE session is invisible: its transcript was gated at first sight (mid-attach, idle >1h — the watcher edge above), and a session parked on a permission prompt appends NOTHING to its transcript, so it has no JSONL revival path at all. **Since #221 the registration normally lands with REAL identity:** `decode_hook_payload` (one payload → a `Vec<AgentEvent>`) attaches an `AgentEvent::Identity { source, session_id, cwd: Option<PathBuf> }` AHEAD of every tool/permission activity event (PreToolUse / PostToolUse / Notification / PermissionRequest, plus the reasonix custom decoder's activity arms — its cwd is the session key, so `Identity` mirrors its SessionStart exactly and coalescing holds), and the reducer's `Identity` arm registers-or-back-fills from it: no slot → `register_slot` with the real source/session_id/cwd (normal `cc·repo`-style label; a cwd-less payload — Codex PermissionRequest, CC PostToolUse — registers ordinal-labeled but with the same reap exemption as below); slot exists → the shared `backfill_identity` helper heals EMPTY source/session_id/cwd first-wins (the same heal the duplicate-SessionStart arm runs — but label upgrades stay on the SessionStart path: `Identity` carries no label authority, and it never touches activity state or `last_event_at` — the paired activity event right behind it does). The blank path is now the FALLBACK: `reducer::apply`'s pre-pass 0 (`synthesize_hook_registration`) still performs the registration for identity-LESS hook activity (`Stop` decodes without `Identity` — an end proves nothing worth registering — and directly-constructed events), starting the slot BLANK: empty source/session_id/cwd, bare ordinal label (`#N`) — deliberately NOT an unknown-cwd ghost (exempt from the 3-min `STALE_UNKNOWN_CWD_TIMEOUT` reap: the motivating permission-parked session emits no follow-up event within 3 min, so the ghost reap would kill it before any back-fill). The next real `SessionStart` (a JSONL revive, Codex's per-prompt re-emit) **back-fills** the blanks in the duplicate-SessionStart arm: empty source/session_id/cwd are healed first-wins (via `backfill_identity`), and the label is upgraded ONLY while it's still a derivation fallback (`is_fallback_label`: bare prefix / `cc#N` ordinal / the source-less `#N` hook-synthesis shape — a basename- or Rename-derived label is real information and is never clobbered). Three boundaries are load-bearing and PRESERVED by #221: (1) **JSONL events must NOT synthesize** — a transcript line can be a historical replay (the first-sight gate exists precisely for those), so the unknown-id no-op stays on that transport (`jsonl_event_for_unknown_id_stays_a_no_op`) and a JSONL-transport `Identity` is a structural no-op (`jsonl_identity_is_a_no_op` — nothing in-tree emits one; the guard IS the boundary); (2) `SessionEnd`/`Rename` don't synthesize either — an end/rename for an unknown agent proves nothing worth showing, end-ish events (`Stop`/`SessionEnd`) carry no `Identity`, and the `Identity` arm honors the SessionEnd tombstone (`hook_identity_respects_session_end_tombstone`) — **as does the `SessionStart` arm for CHILD registrations (#242)**: a `parent_id`-carrying Start on a tombstoned id with no slot is a SubagentStart decoded after its own SubagentStop (per-connection hook tasks reorder) and degrades to warn+no-op on BOTH transports (the tombstone is transport-agnostic evidence the child already ended — a JSONL first-sight of the child's transcript racing the hook Stop has the same phantom shape, and a historical replay never SessionStarts; the tombstone is not consumed, so a duplicate late Start no-ops too); parentless starts are exempt by construction, preserving Reasonix's SessionEnd→SessionStart resurrect on its cwd-keyed id (`tombstoned_parentless_session_start_still_registers`). **On top of the 5s tombstone sits the reducer-private CHILD LEDGER (#244/#246)**: `AgentEvent::SessionEnd` carries an `as_child` stamp (`true` ONLY from the CC/Codex SubagentStop decoders — every other constructor, incl. JSONL terminators and the watcher's negative-vouch/instant-exit synthesis, stamps `false`), and the ledger remembers each child's APPLIED parent link (registration/enrichment — never a refused one) plus an `ended_at` from `as_child` ends (slot or no slot) and from `sweep_exited` removing a child slot (bounding the map; gc prunes ended entries after `CHILD_END_LEDGER_TTL` = 90s — past the watcher's 60s poll like `DRAINED_TASK_TOMBSTONE_TTL`, safe because child ids are per-spawn unique). It closes the three windows the unknown-id-only tombstone can't: a KNOWN child's Stop mints no tombstone, so its late transcript first-sight (notify outage → 60s poll, long past the 4.5s GC) is gated by the ledger's fresh `ended_at` (PARENTED starts only, judged on the event's own parent); a PARENTLESS same-id start instead ADOPTS the remembered parent — through the #240 cycle filter like any wire link — which re-links the re-registrations that DO occur: a post-un-claim revival start (#246's adoption seam — a negative vouch / instant exit / decoded terminator un-claims the rollout from `seen`, so its next line re-emits a parentless SessionStart on the same rollout UUID) and a tombstoned Codex child's parentless flat first-sight, now a parent-LINKED registration instead of the former orphan phantom (#244-w1). The IN-FLIGHT multi-turn Codex child (parent `send_input`) has NO SessionStart carrier of its own at turn N+1 — upstream provides none (hook_runtime.rs verified 2026-06-11: UserPromptSubmit fires only for direct user input, non-Subagent events in a child's context carry the ROOT session_id, SubagentStart fires only at thread STARTUP) — so **the child-end un-claim side-channel (#246) manufactures the JSONL first-sight carrier**: `source/jsonl/unclaim.rs::ChildEndUnclaims` is a tiny shared handle (id + push-time, TTL-pruned at 5 min — several 60s-poll cycles, so the owning watcher always drains first; bounds ids no watcher ever claims). PRODUCER: a tee inside `ClaudeCodeSource::run` between the hook listener and the real channel (ALL sources' hooks ride its one socket) pushes the id of every Hook-transport `SessionEnd{as_child:true}` it forwards — forwarding everything UNCHANGED, transport tag included (push-before-forward is for determinism only; the watcher acts on its own cadence). CONSUMER: each watcher (`with_child_end_unclaims`, wired by `runtime/driver.rs` as ONE handle shared across CC+Codex) drains on every notify/rescan/poll arm the ids matching its OWN `seen` paths: pending bytes are walked to EOF FIRST (the #228 drain-before-unclaim discipline — a pre-stop straggler must not re-register the just-ended child) and NO SessionEnd is emitted (the hook already ended the slot), then the claim is RELEASED — `seen` flips to `false` rather than being removed, because the FD probe still vouches the live child's open rollout and a removed claim would let `revouch_gated_files` replay the whole rollout (stale-activity burst + instant re-registration negating the Stop); a released path revives only on NEW bytes, which re-register through `emit_first_sight` (head-cwd fallback included) and re-link via the ledger adoption above. Residuals: bytes written between the hook Stop and the drain are consumed silently (the next append revives — an active turn appends continuously), and a revival start inside the 4.5s exit grace is swallowed by the root-gated resurrect pin (the turn-N+2 Stop re-arms the path). Acceptance e2e: `tests/watcher/unclaim.rs::in_flight_multi_turn_codex_child_revives_and_relinks_via_unclaim`; the tee parity pin lives in `claude_code.rs` tests + `tests/transport/socket.rs`. Parentless revivals are deliberately NOT blocked — Codex resurrect-on-prompt is a legitimate same-id new life, and a genuinely dead child registered parent-linked rides the parent cascade / 5-min short-idle reap, strictly better than an orphan. Accepted residual: in the Stop-before-Start reorder the blocked Start's parent is never APPLIED, so that child's later flat first-sight still registers parentless (bounded by the sweeps). Two more deliberate edges: a dead child's hook straggler landing in the (5s, 90s] window re-registers it PARENTLESS via the Identity arm / blank hook synthesis (they consult only the 5s tombstone — a hook is proof of life and the reorder skew it guards is ms-scale), self-healing to parent-linked when a later parentless SessionStart hits the enrichment adoption; and a revival start landing while the child slot is still EXITING (< `EXIT_GRACE_WINDOW`, 4.5s after its Stop) is swallowed by the root-gated resurrect pin (`session_start_on_exiting_subagent_does_not_resurrect`, deliberate) — the next start after GC re-links via the ledger. Pinned by the child-ledger suite in `tests/reducer/child_ledger.rs` (`late_parented_restart_…`, `non_child_session_end_tombstone_alone_gates_…`, `parentless_revival_start_of_an_ended_codex_child_…`, `parentless_session_start_enriching_…`, `tombstoned_codex_child_flat_first_sight_…`, `adopted_ledger_parent_still_runs_the_cycle_filter`, `reasonix_resurrect_is_unaffected_…`); (3) the hook-wins dedup record is gated on the slot EXISTING post-synthesis — a registration REFUSED on desk exhaustion must not record its `tool_use_id`, or the stale record would dedup-eat the JSONL `ActivityStart` that finally registers the session once a desk frees inside `HOOK_WINS_WINDOW` (`refused_hook_registration_does_not_poison_dedup_for_the_later_jsonl_copy`; `Identity` carries no `tool_use_id`, so its refusal can't poison anything — `hook_identity_desk_refusal_is_quiet`). Pinned by the proof-of-life + G4 back-fill + `hook_identity_*` suites in `tests/reducer/lifecycle.rs` (`hook_activity_start_for_unknown_id_synthesizes_slot_and_goes_active`, `duplicate_session_start_backfills_hook_synthesized_slot`, `hook_identity_registers_unknown_id_with_real_identity`, …) and end-to-end by the mid-attach scenario suite in `tests/watcher/attach.rs`.
- **Agent removal needs a `SessionEnd`; abrupt exits have none and fall back to the slow stale-sweep.** A slot is GC'd `EXIT_GRACE_WINDOW` (4.5 s) after `exiting_at` is set, which only an `AgentEvent::SessionEnd` or `sweep_stale` sets. CC emits `SessionEnd` ONE way: the **hook** (best-effort — 200 ms write timeout, no retry, races CC's own teardown, so it drops silently); a dropped hook falls back to the idle sweep. There is NO durable JSONL fallback: modern CC persists no `/exit` record at all (slash commands are `type:"system",subtype:"local_command"` lines, and no transcript ends with an /exit user line — 135-transcript corpus), and the old content-based `<command-name>/exit</command-name>` matcher false-positived on messages QUOTING the wrapper, so it was removed — user-controllable content must never drive lifecycle (`cc_session_ended` now matches only structural `session_end`/`SessionEnd` markers, which CC doesn't write today; the upstream-drift watch flags it if that changes). **Ctrl-C / terminal-close / kill produce no signal either** (the process dies before the hook runs), and **Codex has no `SessionEnd` concept at all** (its hooks are `UserPromptSubmit`/`Stop`/`PermissionRequest`; `Stop` = turn end → Idle, not session end). Those exits are reaped only by `sweep_stale` (Active 10 min / Idle 30 min / Waiting 60 min) — except where the liveness probe runs (macOS/Linux standard layouts): since #223 its NEGATIVE vouch (see the watcher edge above) ends a vouched-then-gone session in ~60–120s, leaving `sweep_stale` as the fallback for the probe's blind spots. For **CC** this is largely inherent — its clean exits usually signal via the hook, so the CC sessions that reach the long timeout are genuinely live-but-idle, abruptly killed, or the rare dropped-hook clean exit; a short CC silence-reaper would evict legitimately-idle live sessions (lunch-break idle), so don't add one. **Codex gets a carve-out** because it has NO exit signal of ANY kind: no `SessionEnd` hook (its upstream `HookEventName` enum has none — only `Stop` = turn end), no PID in any payload, and its internal `EventMsg::ShutdownComplete` is *not persisted* to the rollout (`should_persist_event_msg` → false). All three verified against upstream `openai/codex`. So `sweep_stale` is Codex's ONLY reaper for EVERY exit, and at the 30-min idle timeout an exited `cx·` sprite ghosts for half an hour. Codex Idle therefore uses the shorter `STALE_SHORT_IDLE_TIMEOUT` (5 min, in `reducer.rs`, selected by the registry-derived `SourceCaps::short_idle_reap()` = `!has_exit_signal && resurrects_on_prompt` — Codex is the only row where both hold) — safe *specifically* for Codex because the only false-positive (a live-but-idle session past 5 min) is **self-healing**: its next `UserPromptSubmit` re-emits `SessionStart` and the sprite walks back in. **Since #220 the FD probe's `ProofOfLife` exempts a vouched Codex slot from this reap** — a slot whose rollout a live `codex` process still holds open is never short-idle-reaped; the reap effectively fires only once the probe stops vouching (process exited → fd closed → emissions stop), with the 5-min timer measured from the LATER of the last event and the vouch lapse (`PROOF_OF_LIFE_TTL` = 150s after the last emission). The self-healing property is unchanged — it now covers only the probe's blind spots (non-macOS/Linux platforms, a non-standard `--codex-sessions-root`). Pinned by `codex_vouched_idle_slot_outlives_short_idle_reap`. CC keeps the 30-min `STALE_IDLE_TIMEOUT` (its real `SessionEnd` hook makes a short reaper there all cost, no benefit). Pinned by `codex_idle_agent_reaps_faster_than_claude_idle` in `tests/reducer/liveness.rs`. **A stale-swept parent now cascades its whole subagent subtree** via the shared `scope::cascade_exit` (the parent↔subagent tree ops live in `state/scope.rs`; same `parent_id` BFS the `SessionEnd` arm uses) — so an abrupt-exit parent takes its subagents with it rather than orphaning them on their own longer stale timers. **Two guards stop the cascade from evicting a *live* subagent** (modeled on k8s liveness-vs-readiness): (1) **liveness up-propagation** — a working subagent keeps its ancestors fresh (`refresh_lineage`, see the `active_tasks` edge above); (2) **readiness exemption** — a subagent blocked under a `Waiting` ancestor (its permission `Notification` was attributed to the parent, so the parent is `Waiting` while the subagent stays `Active`) is skipped by `sweep_stale` via `has_waiting_ancestor` — "not ready", not dead. So the stale-sweep cascade only fires once the whole subtree is genuinely silent. **A subagent that COMPLETES is removed promptly too** (no 30-min linger), via a two-rung ladder: (1) **the `SubagentStop` hook (#241)** — CC fires it per finished subagent with `agent_transcript_path`, decoding to the child's own `SessionEnd` (instant, and the ONLY end signal for Workflow-fleet subagents, which have no per-agent `Agent` dispatch for b1 to drain); (2) **b1 Task-drain inference** for Agent-tool dispatches when that best-effort hook drops — CC writes no completion marker, so it's inferred: when the parent's last `Task` drains (`active_tasks[parent]` empties) the delegated subtree has returned, and `scope::cascade_exit(parent)` marks it exiting (the parent keeps running); the stale sweeps remain the backstop below both. **The b1 cascade is grace-DEFERRED, not immediate** (`B1_CASCADE_GRACE`, 2.5s, #151): a parallel second Task dispatch is suppressed as a leak and tracked only via its JSONL copy, so an immediate cascade at the first Task's drain would evict the second's LIVE subtree while that copy is still in watcher latency — unrecoverably (`exiting_at` has no clearer; after the GC the child's JSONL events no-op forever). Any Task insert inside the grace cancels the pending cascade (`pending_b1_cascades`); the drain's own tool_result write triggers the notify that replays the backlogged dispatch line, so one FSEvents hop is all the grace must cover. Don't "fix" the ~2.5s completed-subagent linger back to immediacy — and note the linger is unbounded for CHAINED dispatches: a sequential re-dispatch inside the grace defuses the pending entry, so the previous wave's completed children wait for the NEXT drain-to-empty (worst case the 30-min idle sweep) — the unavoidable price of never evicting a live subtree, since the cascade is whole-subtree. Residuals (consciously accepted, see #151): the chained linger above, and a sustained notify outage spanning the grace (both writes missed → 60s poll) can still evict early. A source with a clean completion signal would drive the same cascade through its own decoder; the scope ops stay source-agnostic.
- **Resurrect-in-place starts from clean correlation state.** A `SessionStart` on an EXITING root slot cancels the walkout (`fsm::resurrect_in_place`) AND evicts the dead life's `active_tasks`/`gated_before_waiting`/`pending_b1_cascades` entries (mirroring `sweep_exited`, which never ran because the corpse never GC'd — a leftover tuid can never drain, so it would suppress every hook event of the new life); the `recent_proof_of_life` vouch deliberately survives (the resurrecting process is alive by definition).
- **Codex subagents (`spawn_agent`) are wired via the `SubagentStart`/`SubagentStop` HOOKS, not JSONL paths.** Unlike CC (whose subagent has a `<parent>/subagents/agent-*.jsonl` transcript → `detect_parent_id` derives the parent from the path; CC additionally decodes its own Subagent hooks since #241 — see the transcript_path edge above), a Codex subagent's rollout is FLAT (`~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`), so the JSONL watcher renders it but ORPHANED. The hooks are the only carrier of the parent link: `SubagentStart`/`SubagentStop` payloads carry a distinct `agent_id` (the subagent, == its rollout-filename UUID) plus the parent `session_id`. `decode_hook_payload` keys the CHILD on `agent_id` (coalescing with that rollout) and emits `SessionStart{parent_id: session_id}` / `SessionEnd{agent_id}` — so the Codex subagent joins the SAME `scope` tree (cascade/liveness/readiness) as a CC one. Because the two transports race, `reducer::apply`'s `SessionStart` arm **enriches** an existing orphan's `parent_id` (rather than no-op-on-exists) when the JSONL slot was created first; it never re-parents an agent that already has a parent, and it **refuses any link that would close a `parent_id` cycle** (`scope::would_create_cycle`, checked at this ONE seam for both registration and enrichment — warn + degrade to parentless, #238): with cycles unable to exist, the scope walks need cycle guards only for termination, never correctness (a mutual-Waiting 2-cycle would otherwise exempt both members from `sweep_stale` forever). A MULTI-TURN Codex child (parent `send_input`) gets SubagentStop at EVERY turn end but no new SubagentStart — and NO other SessionStart carrier at turn N+1 from upstream (hook_runtime.rs verified 2026-06-11: UserPromptSubmit fires only for direct user input, non-Subagent events in a child's context carry the ROOT session_id). The carrier is manufactured by the **child-end un-claim side-channel (#246)** — the `SubagentStop`'s decoded `SessionEnd{as_child:true}` is observed by the CC hook tee and the Codex watcher then RELEASES the rollout's `seen` claim (drain-pending-bytes-first, no SessionEnd emitted, `seen`→`false` so the FD probe's re-vouch can't replay the still-open rollout) — so the turn-N+1 append first-sights a parentless SessionStart that the child-ledger re-link (see the hooks-proof-of-life edge above) re-parents; the same adoption covers the other parentless starts: a flat-rollout first-sight (#244-w1) and a post-exit revival. The two `CODEX_EVENTS` registrations (`install/codex.rs`) are therefore live, not dead — captured wire format is regression-pinned in `tests/sources/codex/mod.rs`.
- **Subagent display names come from `attributionAgent` in JSONL.** The decoder strips the plugin prefix (`feature-dev:code-explorer` → `code-explorer`) and emits `AgentEvent::Rename` so labels read meaningfully. Parents get their `cwd` basename instead. `cc_derive_label` falls back to the CC **project dir** (its name encodes the cwd path, `'/'→'-'`) when a Rename's seed line has no `cwd`, so an empty-cwd Rename never degrades `cc·dotfiles` back to a bare `cc`.
- **`AgentSlot.state_started_at` is `std::time::SystemTime`** — process-local in practice (no wall-clock anchoring), but the type is already serializable, so the v2 daemon split won't need a type swap. The pose system computes elapsed time relative to it for animation timing.
- **`ActivityState::Active` ≠ "tool is currently executing".** CC fires PreToolUse → PostToolUse around every individual tool call, so without debouncing the slot flickers Active/Idle on every tool. The reducer treats `ActivityEnd` as "arm pending idle" (`AgentSlot.pending_idle_at`) instead of an immediate flip; the actual transition to `Idle` is realized by `reducer::tick` after `ACTIVE_GRACE_WINDOW = 1500 ms`. Any `ActivityStart` inside the window cancels the pending idle. Net: the slot reads as continuously Active for chained tool work, and visible Idle lags real Idle by up to 1.5 s + the 1 s sweep interval (≈ 2.5 s worst case). Don't add code that depends on `Active → Idle` being instant.
- **The reducer's permission `Waiting` resolves on the gated tool's PostToolUse.** A `PermissionRequest`/decision sets `Waiting`; the matching tool's `ActivityEnd` (PostToolUse) clears it (`gated_before_waiting` tracking). It is also cleared by `SessionEnd` + retained-eviction; `gated_before_waiting` is evicted at BOTH sites — `tick`'s `retain` and `sweep_exited`'s explicit `remove` (the apply-path eviction, pinned by `gated_before_waiting_evicted_on_apply_path_sweep`). A THIRD clear site lives on the Task-drain path (#152), CONDITIONAL on the drained tuid matching the gate: the drain deliberately skips the main arm (a Waiting parent keeps Waiting), so its gate entry would go stale and a late out-of-window JSONL replay of the same END would false-resolve a still-pending permission. The conditionality is load-bearing — an unconditional clear would wipe a parallel ordinary tool's gate (pinned by `task_drain_keeps_parallel_ordinary_tool_gate`). Keep the sites in sync if you touch eviction.
- **A meeting room narrower than `MEETING_FURNITURE_MIN_W` (compute.rs) has NO sofa/table/seats — bare floor, BY DESIGN.** Below it the 16px sofa body leaves too little margin for the coarse 4×4 router to reach the seats, so an idle agent sent to sit would TELEPORT (find_path None → straight-line fallback). The room Bounds/walls/door still exist; only the unroutable furniture is dropped (same degradation the dense floor uses when too short). So "meeting room exists" no longer implies "meeting slots exist" — don't "fix" the missing sofa at small sizes. Guarded by `pathfind::tests::every_wander_waypoint_is_routable_on_the_coarse_grid` (coarse-grid reachability across seeds × sizes — stronger than the pixel-BFS connectivity sweep).
- **Occlusion is EMERGENT — there is no `occludes_behind` field / synthetic cap any more (deleted).** An overhanging piece (`visual.h > footprint.h`: plant canopy, booth column, TV monitor, whiteboard panel, pantry counter) has its footprint **south-anchored to the sprite base** by `mask::stamp_overhang_aware`, so a walker parks DEEP behind it and the piece's own y-sorted sprite paints over their lower body. The old `paint_furniture_back_floor` floor-snapshot cap is gone. **Two intentionally decoupled footprints:** the MASK uses the shallow `footprint` (occlusion + collision); `stand_point`/`approach_point` derive the USER's park distance from the full `visual` (`half_extents`/`approach_footprint`) so someone *using* the furniture parks clear of the whole sprite, not inside it. This is the industry Y-sort pattern (base-collision + sprite-occludes); **don't re-add a cap** — make the sprite overhang its shallow footprint instead. Pinned by `pixtuoid::tui::pixel_painter::tests::every_pod_occludes_via_overhang` (in the binary renderer, not core).
- **Pantry counter blocks only a shallow `PANTRY_FOOTPRINT_DEPTH` south strip, not its full sprite height.** The counter is a ¾-view sprite centered on `pos`; only its south base contacts the floor (the receding cabinet tops overhang, invariant #6). `mask.rs` south-anchors a 3px strip; a character behind it is occluded by the counter's own y-sorted sprite, couch-style — the same emergent occlusion as every overhanging piece (above). `stand_point` parks the USER clear of the full `visual`, not this shallow strip (the runtime-sized pantry was the original mask-vs-approach decouple, now generalised to all overhanging obstacles).

## Where to look

- "Where does a furniture's footprint / visual size / approach side / dwell come from?" → **`core::layout::decor`** is the single source of truth, and there is now exactly ONE geometry table. `FurnitureDef { footprint, visual, occupies_pos, dwell, approach: ApproachSides }` + `furniture_def(Furniture)` — keyed by the unified `Furniture` geometry enum — plus `desk_furniture_def()` (the home desk: same `FurnitureDef` type, NOT a `Furniture`/waypoint row, since it's owned per-agent). **The role enums carry NO dimensions**: `WaypointKind` (wander destination), `PodDecor` (aisle filler), `PlantKind`, `WallDecor` each expose `.furniture()` mapping onto one `Furniture` row, so overlapping items (a phone booth that is both a waypoint and pod decor, a plant that is both free-standing and pod decor, a whiteboard that is both wall and pod decor) have their shape defined exactly once. The singleton / per-room furniture (meeting sofa-body + table, pantry table/stool, lamp, side table) are ALSO rows now — `Furniture::{MeetingSofaBody, MeetingTable, PantryTable, PantryChair, FloorLamp, LoungeSideTable}` — distinct from the `MeetingSofa`/`MeetingStand` SEAT rows (3 seats sit on 1 body, so the seats carry `None` footprint and the body carries the obstacle). The ONLY geometry living outside the table is `PLANT_FOOTPRINT` (the one value shared by the four plant rows, named once to avoid duplication) and the linear wall thickness `WALL_THICK_H/V` (walls are a thickness, not a point-centred rect). **`footprint` is the BLOCKED ground area** (z=0 projection) — `mask::build_walkable_mask` stamps it (no inline literals); **`visual` is the sprite size** — render centering + the z-sort south row (`center_pin_south_offset`) derive from it. **Three load-bearing distinctions:** (1) `footprint` (blocked ground) ≠ `visual` (sprite, may overhang — `Furniture::PlantTall` footprint 6×**3** (a shallow south-anchored pot strip) vs visual 6×10; overhanging pieces have a footprint *shorter* than the sprite so the canopy/column occludes a walker behind it via z-sort; vertical wall footprint 1px vs 3px drawn); (2) `occupies_pos` = the agent sits/stands ON the cell (couch/meeting) vs approaches it from a side (appliances); (3) **placement overlap** (furniture buried in a wall = bug, see `freestanding_decor_does_not_overlap_room_walls`) ≠ **occlusion overlap** (glass-over-sofa, sofa-over-sitter, canopy-over-walker = physically correct, resolved by the tui z-sort, NOT a placement error). `ApproachSides` (canonical facing-South, rotated by `allows`) is the editable entry-side knob — flip one bool to drop a side. The unifier is "one geometry table (footprint + visual) + one shared z-sort," not a second `size()` per role enum.
- "How is the office laid out?" → `core::layout::SceneLayout::compute_with_seed` is the coordinator; it calls 4 private helpers: `compute_pod_desks` (pod grid), `compute_pod_decor` (aisle decor), `compute_room_walls` (wall segments + door gaps), `compute_waypoints` (all waypoint assembly). Re-exported as `tui::layout::Layout`. `core::pose::derive` for pure state→pose mapping including the Idle wander state machine (`WANDER_CYCLE_BASE_MS=7000` + per-agent jitter).
- "How does the per-agent state machine work?" → **two stacked layers, both extracted into named modules so the reducer reads as a coordinator.** **Layer A** = `state/fsm.rs`: the single-slot transitions (`enter_active`/`enter_delegating`/`enter_waiting`/`arm_pending_idle`/`settle_to_idle`/`mark_exiting`/`rename`), each independently unit-tested; `accumulate_active_ms` lives here once (was copy-pasted 4×). **Layer B** = `state/scope.rs`: the parent↔subagent tree ops. **Invariant:** `fsm.rs` mutates ONE slot; the **cross-slot correlation** (`active_tasks` suppression, `gated_before_waiting` permission, the hook-wins dedup) stays in the REDUCER's layer — it spans slots and is NOT the FSM. The seven correlation maps' bookkeeping (entry types, TTL consts, freshness predicates, the one `gc(now)`) lives in `state/correlation.rs` as a `Correlation` struct owned by the reducer (`self.corr.*` — intra-layer extraction, not a new layer); the DECISIONS (which arm fires, what gets suppressed) stay in `reducer.rs`. The reducer decides *whether/which* transition fires; `fsm` decides *how* the slot changes. Don't move the correlation maps onto `AgentSlot`.
- "Why is the subagent's sprite the right one and not the parent?" → `reducer::Reducer::apply` does subagent-leak suppression via `active_tasks` before applying. `claude_code::decode_cc_line` emits `AgentEvent::Rename` from `attributionAgent`.
- "How does multi-source decoding work?" → `JsonlWatcher` takes `LineDecoder` and `LabelDeriver` fn pointers (Strategy pattern via fn pointers, not traits). CC wires `claude_code::decode_cc_line` + `cc_derive_label` (plus the optional `with_liveness_probe` seam → `live_cc_session_ids` — see the first-sight-gate sharp edge); Codex wires `codex::decode_codex_line` + its label fn (plus the same probe seam → `live_codex_rollout_ids`, open-FD binding via `fd_probe`); Antigravity wires `antigravity::decode_ag_line` + `derive_ag_label`. **Reasonix is HOOK-ONLY** (`line_decoder: None` in its registry row) — no `Source` impl, no watcher: its v2 session files are full-rewritten per turn (untailable) and its hook payloads carry no session id/path to coalesce on, so `reasonix::decode_rx_hook_custom` (cwd-keyed AgentId) runs as the row's `hook.custom` decoder, BEFORE the shared CC/Codex field requirements (its envelope is camelCase with an `event` discriminator and no `session_id` — an alien-envelope custom decoder claims EVERY event). `decoder.rs` holds shared utilities (`describe_tool_target`, `make_tool_detail`, `decode_hook_payload`). Each source owns its own format knowledge. `AgentId::from_parts(source, key)` namespaces IDs per source. Labels show source prefix: `cc·project` / `cx·project` / `ag·project` / `rx·project`. **CLI attribution comes ONLY from the shim-owned `_pixtuoid_source` key, NEVER the public `source` field** (CC overloads `source` for the SessionStart *reason* — startup/resume/clear — and reading it splits the agent into an un-reapable ghost; see the WHY at `source/decoder.rs` `decode_hook_payload`). Per-source CROSS-CUTTING facts (label prefix, hook id-key/custom decoder, reducer capability flags, the harness's line decoder) live in ONE `SourceDescriptor` row in `source/registry.rs` — the reducer's `source_label_prefix`/`stale_threshold` and the fixture harness read the table instead of name-matching, and `SourceCaps::short_idle_reap()` derives the Codex short-idle policy from wire facts (`!has_exit_signal && resurrects_on_prompt` — Antigravity lacks the signal too but can't resurrect, hence its long window). Adding a CLI = (1) the source module (SOURCE_NAME + decoders), (2) ONE descriptor row in `registry.rs`, (3) the name in `source::REGISTERED_SOURCES` (a bridge test pins table↔list equality; the fixture conformance test forces a fixture under `tests/sources/fixtures/<name>/`), (4) wire it into `pixtuoid/runtime/driver.rs::run_async` (the registry gates conformance tests, NOT runtime spawning — **a source registered in `registry.rs` + `REGISTERED_SOURCES` but NOT wired into `driver.rs` passes every conformance/manifest test yet never spawns at runtime; `driver.rs` is the ONE step the registry can't force, because each source struct has different typed override fields**) — a hook-only CLI skips (4) and ships an `install/` target instead so its hooks reach the shared socket. (5) a row in `site/src/sources.json` — single-sources the README "Supported Tools" glimpse + the site's tool × OS matrix (run `just gen-readme`); its `supported` set is pinned to `REGISTERED_SOURCES` by `tests/supported_sources_manifest.rs`, so a newly registered source FAILS that test until its manifest row exists. (6) if the CLI has a custom home/config root, add a `pub fn <cli>_home()` in `source::<cli>` honoring the CLI's own `*_HOME` env precedence (like `codex_home` → `CODEX_HOME`-if-exists-else-`~/.<cli>`), and call it from BOTH the watcher's `default_paths()` AND the installer's `default_config_path()` — one function, two consumers, so the watched root and the installed-hook config can't disagree. You do NOT separately edit `source_label_prefix`, `stale_threshold`, or the harness's `decoder_for` — those read the table. Ship a fixture exercising the **SessionStart hook** so `tests/sources/conformance.rs`'s one-AgentId assertion guards against the ghost. (Test-layout detail + add-a-CLI test steps live in the nested [`tests/CLAUDE.md`](tests/CLAUDE.md).)
- "Why don't old idle sessions show on startup?" → `source::jsonl::walk::should_seed_at_eof` (the first-sight gate inside `walk_jsonl`). Checks `check_session_ended` (tail-scans last 8 KB for `session_end`/`SessionEnd` markers) and seeds the cursor at EOF for any file with mtime > `DEFAULT_INITIAL_WINDOW` (1 hour) — no `SessionStart`. An mtime outside the 1-hour window **OR** a `session_end` marker is the freshness gate, and it runs on every first-sight path (seed / 250ms rescan / 60s poll / notify), not only startup. Exception: a session the **liveness probe** vouches for (CC's `sessions/<pid>.json` registry; Codex's open-rollout FD binding — see the sharp edge above) is never gated, however old its mtime. (The reverse also exists since #242: a CHILD transcript's first-sight SessionStart can be dropped by the reducer's SessionEnd-tombstone gate — or, since #244, by the child ledger's 90s ended-recently gate — when the child's hook Stop already passed; see boundary (2) of the hooks-proof-of-life edge.)
- "How does walk-pace physics work?" → `pixtuoid_core::physics` (pure, no terminal deps) — `WalkIntent` tags the walk kind (Entry/Exit/WanderOut/WanderBack/SnapBack); `walk_profile(path_len_octile, intent, agent_id)` returns a frozen `WalkProfile` with trapezoidal/triangular kinematics (triangular when path < `v²/a`, trapezoidal otherwise); `walk_progress(p, elapsed_ms)` emits `t_x1000 ∈ [0,1000]`; `walk_arrived` gates the Walking→Seated/AtWaypoint flip including the per-agent arrival pause. Constants: `V_CRUISE_COMMUTE = 0.36` octile/ms (Entry/Exit), `V_CRUISE_SNAPBACK = 0.65` octile/ms (SnapBack — the urgent Idle→Active return rushes back), `V_CRUISE_WANDER = 0.25` octile/ms (amble, wander legs), `WALK_ACCEL = 6.5e-4` octile/ms² (~0.55 s ramp; `L_crit ≈ 199` octile commute, `≈ 96` octile wander), `WALK_ACCEL_SNAPBACK = 2.0e-3` octile/ms² (~3× — short snap-backs are accel-limited, so the rush comes from a harder accelerator, not cruise). Per-agent personality: `speed_mult` (bits 24..34 of splitmix64 hash, range 0.85–1.20) and `pause_ms_for` (bits 40..52, range 200–400 ms) — disjoint from each other and from `cycle_ms_for`/`personality_for`. Near desks arrive and sit before far desks (natural staggered arrivals). The *stateful* timeline that consumes these profiles lives in `pixtuoid/src/tui/motion` (the render authority) — see that nested guide.

## Keeping the decode mapping current (upstream drift)

Tool names, hook-event names and transcript types are upstream wire contracts that change **without notice** (the `Task`→`Agent` rename shipped undocumented). Four defenses, in order of reliability:

1. **Resilient detection (in code).** Prefer a *semantic* signal over a hardcoded name where one exists — e.g. `make_tool_detail` keys subagent dispatch on the stable `subagent_type` input field, not the tool name. This survives a rename by construction.
2. **Self-monitoring from the real stream (in code).** The decoders already see ground truth. An unrecognized `hook_event_name` `bail!`s + `warn!`s; a dispatch under an unknown name logs a `debug!` breadcrumb; the `sessions/<pid>.json` registry consumer warns once on shape drift (#247 — the canonical instance of this defense for a NON-FETCHABLE surface defense #4 can't reach). These surface drift the moment it appears in a user's own usage — no network, no upstream-layout dependency.
3. **Internal consistency test.** `every_registered_codex_event_decodes` / `every_registered_cc_event_decodes` (in `install/{codex,claude}.rs`) assert that every hook event we *register* has a decoder arm — catching the *registered-but-not-decoded* class (the original SubagentStop bug) at `cargo test` time.
4. **Upstream watch (CI).** `scripts/check_upstream_drift.py` + `.github/workflows/upstream-drift.yml` (weekly) read the names we depend on directly from our source and compare to live upstream; on a vanished name or a new unhandled Codex/Reasonix hook it opens a deduped `upstream-drift` issue. Sources:
   - **Codex** (open Rust): `codex-rs/protocol/src/protocol.rs` `HookEventName` enum (raw on `main`). `PreCompact`/`PostCompact` are intentionally omitted (`CODEX_KNOWN_OMITTED` in the script) — not agent activity.
   - **CC** (closed binary — no source to grep): the docs markdown `code.claude.com/docs/en/tools-reference.md` (authoritative tool-name list) **and `hooks.md`'s hook-event summary table** — diffed against `install/claude.rs` `EVENTS` (now incl. SubagentStart/SubagentStop, #241) + the script's `CC_KNOWN_OMITTED` set, so a registered event vanishing is breaking drift and a NEW upstream event is a review ping. Tool names like `Agent` are NOT schematized upstream, so defense #2 is the real backstop for CC.
   - **Reasonix** (open Go): `internal/hook/hook.go` `Event` consts + the payload json tags (`event`/`cwd`/`toolName`/`toolArgs`/`message`) on `main-v2`. `PostLLMCall`/`PreCompact`/`SubagentStop` are intentionally omitted (`REASONIX_KNOWN_OMITTED`).

## When refactoring

The channel type, `Source` trait, `AgentEvent` enum, and reducer signature are workspace-wide contracts — see the root [`CLAUDE.md`](../../CLAUDE.md) "When refactoring" for the full list of test files to update and the add-a-CLI checklist.
