Skip to main content

tear_core/
inproc.rs

1//! `InProcess` — the in-memory [`tear_types::MultiplexerControl`]
2//! implementation backed by `parking_lot::RwLock<Registry>` +
3//! `BTreeMap<PaneId, PtyHandle>`.
4//!
5//! ## Architecture
6//!
7//! - **Registry** (`Arc<RwLock<Registry>>`) — pure typed state: which
8//!   sessions / windows / panes exist, what their layouts are. Read
9//!   by `list_sessions`, `get_*`; written by mutating ops.
10//! - **PTYs** (`Arc<Mutex<BTreeMap<PaneId, PtyHandle>>>`) — the
11//!   physical PTY handles. Separated from the typed registry so a
12//!   daemon can serialise the registry to disk for resurrection
13//!   while PTYs (which can't outlive a process) stay in memory.
14//!
15//! ## Mado integration shape (M5)
16//!
17//! At M5 mado's `render::SharedTerminal` will become an
18//! `Arc<InProcess>` instead of an `Arc<RwLock<Terminal>>`. Each
19//! mado pane is then just a `PaneId` view over the shared
20//! `InProcess`; `mado_pane.feed(bytes)` delegates to
21//! `inproc.feed_pane_bytes(pane_id, bytes)` which calls the same
22//! `vte` parser the tear-daemon uses on the headless path.
23
24use std::collections::BTreeMap;
25use std::sync::Arc;
26
27use parking_lot::{Mutex, RwLock};
28use portable_pty::PtySize;
29use tracing::{debug, info};
30
31use tear_types::{
32    ControlError, ControlResult, Direction, LayoutKind, LayoutNode, LeafRemoval,
33    MultiplexerControl, PaneId, Rect, SessionId, SplitOrientation, TearPane, TearSession,
34    TearWindow, WindowId,
35};
36
37use std::sync::mpsc;
38
39use crate::pane_grid::PaneGrid;
40use crate::pty::PtyHandle;
41use crate::reap::AllPanesExited;
42use crate::recording::PaneRecording;
43use crate::registry::Registry;
44
45/// Per-pane byte-stream fan-out state.
46///
47/// Co-locates the live subscriber senders with a `closed`
48/// end-of-stream marker so that *registering* a subscriber and
49/// *closing* the stream on child-exit are decided under a single
50/// lock. Without the co-located marker, a `subscribe` that races with
51/// (or follows) the pane's exit could push a sender that is never
52/// dropped — the exact "receiver blocks forever" failure this whole
53/// change exists to remove.
54#[derive(Default)]
55struct PaneSubscribers {
56    /// `Some(code)` once the pane's PTY child has exited; no further
57    /// bytes will ever be sent. New subscribers then receive an
58    /// already-disconnected receiver instead of a live registration.
59    closed: Option<i32>,
60    /// Live subscribers — each receives a clone of every PTY chunk.
61    senders: Vec<mpsc::Sender<Vec<u8>>>,
62}
63
64/// The native in-process multiplexer backend.
65pub struct InProcess {
66    registry: Arc<RwLock<Registry>>,
67    ptys: Arc<Mutex<BTreeMap<PaneId, PtyHandle>>>,
68    /// Per-pane VT parser + cell grid. Phase-2-MVP wires PTY bytes
69    /// into these so [`Self::pane_snapshot`] returns the rendered
70    /// state. Wrapped per-pane in `Mutex` so the PTY reader thread
71    /// and snapshot callers can race independently per pane.
72    grids: Arc<Mutex<BTreeMap<PaneId, Arc<Mutex<PaneGrid>>>>>,
73    /// Per-pane byte-stream fan-out state ([`PaneSubscribers`]): the
74    /// live subscriber senders plus a `closed` end-of-stream marker.
75    /// On send error the fan-out prunes dead subscribers; on child
76    /// exit [`Self::spawn_pty_for`]'s `on_exit` hook marks the entry
77    /// closed and drops the senders, so every [`mpsc::Receiver`]
78    /// disconnects — the end-of-stream signal mado's
79    /// `attach_live.run()` and the daemon's `serve_subscription`
80    /// block on.
81    subscribers: Arc<Mutex<BTreeMap<PaneId, PaneSubscribers>>>,
82    /// Per-pane recording (#4). Cheap when disabled — the on_bytes
83    /// hook hits a single boolean before deciding whether to
84    /// deep-copy the chunk. Recording is opt-in via
85    /// `enable_pane_recording`.
86    recordings: Arc<Mutex<BTreeMap<PaneId, Arc<PaneRecording>>>>,
87    /// UDS path the tear-daemon bound to. Stamped onto every PTY
88    /// child's `TEAR_SOCKET` env var so shells / prompts /
89    /// child processes can re-discover the daemon without
90    /// scanning the XDG runtime dir.
91    socket_path: Arc<RwLock<Option<std::path::PathBuf>>>,
92    /// Embedder-supplied env + cwd override (mado's typed capability
93    /// projection — `TERM=xterm-ghostty` + `TERMINFO` + `COLORTERM` +
94    /// `PWD`), applied to every child's env AFTER the inherited +
95    /// fallback env so the embedder's richer capability set wins over
96    /// the conservative `xterm-256color` default. Empty by default
97    /// (the pre-seam behaviour); set via [`Self::set_spawn_env`]. This
98    /// is the fix for "vim grey + wrong font in the embedded-tear
99    /// window" (operator report 2026-06-12).
100    spawn_env: Arc<RwLock<tear_types::SpawnEnv>>,
101}
102
103impl Default for InProcess {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109impl InProcess {
110    #[must_use]
111    pub fn new() -> Self {
112        Self {
113            registry: Arc::new(RwLock::new(Registry::new())),
114            ptys: Arc::new(Mutex::new(BTreeMap::new())),
115            grids: Arc::new(Mutex::new(BTreeMap::new())),
116            subscribers: Arc::new(Mutex::new(BTreeMap::new())),
117            recordings: Arc::new(Mutex::new(BTreeMap::new())),
118            socket_path: Arc::new(RwLock::new(None)),
119            spawn_env: Arc::new(RwLock::new(tear_types::SpawnEnv::none())),
120        }
121    }
122
123    /// Set the embedder's typed env + cwd override, applied to every
124    /// subsequent child PTY's env AFTER the inherited + fallback env.
125    /// mado calls this with its `caps::EnvProjection` pairs (+ the boot
126    /// cwd) so vim in an embedded-tear window sees `xterm-ghostty` +
127    /// truecolor + the vendored terminfo — identical to the local-PTY
128    /// path. Idempotent; the last write wins.
129    pub fn set_spawn_env(&self, env: tear_types::SpawnEnv) {
130        *self.spawn_env.write() = env;
131    }
132
133    /// The embedder's current spawn cwd override (`SpawnEnv.cwd`), if any.
134    ///
135    /// mado stamps this per-spawn (the focused pane's cwd / boot cwd)
136    /// before each `new_session`, so the daemon can read it at session-
137    /// create time to learn which directory a session was opened in —
138    /// the seed for praça's project↔session binding (M1 "Remember").
139    /// `None` when no embedder cwd override is set (the bare-daemon path).
140    #[must_use]
141    pub fn spawn_cwd(&self) -> Option<std::path::PathBuf> {
142        self.spawn_env
143            .read()
144            .cwd
145            .as_ref()
146            .map(std::path::PathBuf::from)
147    }
148
149    /// Record the UDS path the daemon bound to. Subsequent PTY
150    /// spawns stamp `TEAR_SOCKET=<path>` on the child env. Called
151    /// by `tear-daemon::start*` immediately after `bind`.
152    pub fn set_socket_path(&self, path: std::path::PathBuf) {
153        *self.socket_path.write() = Some(path);
154    }
155
156    /// Borrow the recorded socket path, if any.
157    pub fn socket_path(&self) -> Option<std::path::PathBuf> {
158        self.socket_path.read().clone()
159    }
160
161    /// Enable recording for `pane_id`. Idempotent — calling on an
162    /// already-enabled pane resets the recording buffer (per
163    /// `PaneRecording::enable` semantics). Reads the pane's
164    /// current size from the registry for the asciinema header.
165    pub fn enable_pane_recording(&self, pane_id: PaneId) -> ControlResult<()> {
166        let (cols, rows) = {
167            let r = self.registry.read();
168            let Some((sid, _wid)) = r.locate_pane(pane_id) else {
169                return Err(ControlError::NoSuchPane(pane_id));
170            };
171            let Some(p) = r.sessions.get(&sid).and_then(|s| s.panes.get(&pane_id)) else {
172                return Err(ControlError::NoSuchPane(pane_id));
173            };
174            p.size_cells
175        };
176        let mut recs = self.recordings.lock();
177        let rec = recs
178            .entry(pane_id)
179            .or_insert_with(|| Arc::new(PaneRecording::default()));
180        rec.enable(cols, rows);
181        Ok(())
182    }
183
184    /// Stop recording for `pane_id`. The captured buffer is
185    /// retained until the next `enable` or `kill_pane`; the
186    /// operator can still `export` after stopping.
187    pub fn disable_pane_recording(&self, pane_id: PaneId) -> ControlResult<()> {
188        let recs = self.recordings.lock();
189        match recs.get(&pane_id) {
190            Some(r) => {
191                r.disable();
192                Ok(())
193            }
194            None => Err(ControlError::NoSuchPane(pane_id)),
195        }
196    }
197
198    /// Export the pane's captured recording as asciinema v2
199    /// .cast (JSON-lines). Returns an empty string when nothing
200    /// has been captured yet (recording was never enabled or the
201    /// pane has no events).
202    pub fn export_pane_recording(&self, pane_id: PaneId) -> ControlResult<String> {
203        let recs = self.recordings.lock();
204        match recs.get(&pane_id) {
205            Some(r) => Ok(r.to_cast_json()),
206            None => Err(ControlError::NoSuchPane(pane_id)),
207        }
208    }
209
210    /// `(is_enabled, event_count)` snapshot — driven by the
211    /// pane-info / pane-record-status ergonomics.
212    pub fn pane_recording_status(&self, pane_id: PaneId) -> ControlResult<(bool, u32)> {
213        let recs = self.recordings.lock();
214        match recs.get(&pane_id) {
215            Some(r) => Ok((r.is_enabled(), r.event_count() as u32)),
216            None => Ok((false, 0)),
217        }
218    }
219
220    /// List captured blocks for a pane (oldest-first). Filters by
221    /// `since_index` — pass 0 to get every retained block. The
222    /// daemon currently caps at 10_000 blocks per pane (ring
223    /// eviction). Returns `NoSuchPane` if the pane has no grid.
224    pub fn pane_blocks_list(
225        &self,
226        pane_id: PaneId,
227        since_index: u64,
228        limit: u32,
229    ) -> ControlResult<Vec<crate::blocks::Block>> {
230        let grid_arc = {
231            let map = self.grids.lock();
232            map.get(&pane_id)
233                .cloned()
234                .ok_or(ControlError::NoSuchPane(pane_id))?
235        };
236        let grid = grid_arc.lock();
237        let out: Vec<crate::blocks::Block> = grid
238            .state
239            .blocks
240            .iter()
241            .filter(|b| b.index >= since_index)
242            .take(limit as usize)
243            .cloned()
244            .collect();
245        Ok(out)
246    }
247
248    /// Fetch one block by per-pane index. Returns NoSuchPane if
249    /// the pane is gone, or the InvalidArgument variant via
250    /// Rejected when the block has been evicted / never existed.
251    pub fn pane_block_at(
252        &self,
253        pane_id: PaneId,
254        index: u64,
255    ) -> ControlResult<crate::blocks::Block> {
256        let grid_arc = {
257            let map = self.grids.lock();
258            map.get(&pane_id)
259                .cloned()
260                .ok_or(ControlError::NoSuchPane(pane_id))?
261        };
262        let grid = grid_arc.lock();
263        grid.state
264            .blocks
265            .get(index)
266            .cloned()
267            .ok_or_else(|| ControlError::Rejected(format!(
268                "no block at index {index} (oldest evicted or never existed)"
269            )))
270    }
271
272    /// `(total_completed_blocks, current_in_progress)` — useful
273    /// for status displays. `tear top` reads this column.
274    pub fn pane_blocks_status(&self, pane_id: PaneId) -> ControlResult<(u32, bool)> {
275        let grid_arc = {
276            let map = self.grids.lock();
277            map.get(&pane_id)
278                .cloned()
279                .ok_or(ControlError::NoSuchPane(pane_id))?
280        };
281        let grid = grid_arc.lock();
282        Ok((
283            grid.state.blocks.len() as u32,
284            grid.state.blocks.current().is_some(),
285        ))
286    }
287
288    /// Register a byte-stream subscriber for the named pane.
289    /// Returns the receiver end of an `mpsc::channel`; every PTY
290    /// chunk that lands in this pane is sent on the corresponding
291    /// sender. Drop the receiver to unsubscribe — the next send
292    /// will error and the daemon prunes the dead sender.
293    ///
294    /// Returns `NoSuchPane` if the pane has no PTY (never spawned
295    /// or already killed).
296    ///
297    /// If the pane's child has already exited (remain-on-exit dead
298    /// pane), the returned receiver is born already-disconnected: no
299    /// live sender is registered, so the consumer can replay the
300    /// pane's final grid snapshot and then immediately observe
301    /// end-of-stream (`recv() -> Err`) instead of blocking forever on
302    /// a pane that will never emit again. The `closed` check and the
303    /// sender push happen under the same lock, so a `subscribe` that
304    /// races with the pane's exit can't leak a sender that never
305    /// disconnects.
306    pub fn subscribe_pane_bytes(
307        &self,
308        pane: PaneId,
309    ) -> ControlResult<mpsc::Receiver<Vec<u8>>> {
310        // Confirm the pane exists; we don't actually need the
311        // grid here (the sender is registered regardless), but
312        // subscribing to a phantom pane silently is a footgun.
313        if !self.ptys.lock().contains_key(&pane) {
314            return Err(ControlError::NoSuchPane(pane));
315        }
316        let (tx, rx) = mpsc::channel();
317        let mut subs = self.subscribers.lock();
318        let ps = subs.entry(pane).or_default();
319        if ps.closed.is_none() {
320            ps.senders.push(tx);
321        }
322        // else: stream already closed — drop `tx` here so `rx` is
323        // immediately disconnected.
324        Ok(rx)
325    }
326
327    /// Borrow the registry read-only — useful for callers that want
328    /// to scan multiple entities atomically without locking per-call.
329    pub fn with_registry<R>(&self, f: impl FnOnce(&Registry) -> R) -> R {
330        let r = self.registry.read();
331        f(&r)
332    }
333
334    /// Return a serializable snapshot of the named pane's rendered
335    /// grid. Returns `NoSuchPane` if the pane never had a grid
336    /// installed (which can only happen if it never had a PTY —
337    /// every PTY-spawning code path also installs a grid).
338    pub fn pane_snapshot(&self, pane_id: PaneId) -> ControlResult<tear_types::PaneSnapshot> {
339        let grid_arc = {
340            let map = self.grids.lock();
341            map.get(&pane_id)
342                .cloned()
343                .ok_or(ControlError::NoSuchPane(pane_id))?
344        };
345        let grid = grid_arc.lock();
346        Ok(grid.snapshot())
347    }
348
349    /// No-alloc DECCKM lookup — reads one `bool` off the live
350    /// `PaneGrid` rather than building a full `PaneSnapshot`.
351    /// Mado's embedded-tear input loop hits this on every arrow
352    /// keystroke; the snapshot path would clone the entire cell
353    /// grid (~100KB per call on an 80×40 pane).
354    pub fn pane_cursor_keys_mode(&self, pane_id: PaneId) -> ControlResult<bool> {
355        let grid_arc = {
356            let map = self.grids.lock();
357            map.get(&pane_id)
358                .cloned()
359                .ok_or(ControlError::NoSuchPane(pane_id))?
360        };
361        let grid = grid_arc.lock();
362        Ok(grid.cursor_keys_mode())
363    }
364
365    /// Spawn a PTY for the given pane. Caller pre-creates the typed
366    /// pane via the registry; this attaches the runtime + installs
367    /// the per-pane VT parser AND injects the `TEAR_*` env vars so
368    /// shells and prompts (starship) can see they're running inside
369    /// a tear session.
370    /// `args` is `shell`'s argv[1..], handed to [`PtyHandle::spawn`]
371    /// as an argument vector. It reaches `execvp` directly — there is
372    /// no shell in between, so no quoting or escaping applies.
373    /// `yurai` is threaded in rather than stamped by the caller
374    /// afterwards, deliberately: this is the ONE choke point every
375    /// pane's grid is created at, so taking provenance as a
376    /// parameter makes "spawn a pane whose blocks are unattributed"
377    /// unconstructible at the call site. A post-spawn stamp would be
378    /// a step someone can forget, and the failure would be silent —
379    /// blocks quietly reading `Unknown` forever.
380    fn spawn_pty_for(
381        &self,
382        pane_id: PaneId,
383        shell: &str,
384        args: &[String],
385        size: (u16, u16),
386        yurai: tear_types::Yurai,
387    ) -> anyhow::Result<()> {
388        // Typed cross-tool env-var names (the SAME source seki's prompt
389        // reads) — hoisted to the top of the fn so it's an item, not a
390        // statement-position import.
391        use ishou_tokens::FleetStateVar as Fsv;
392        let pty_size = PtySize {
393            rows: size.1,
394            cols: size.0,
395            pixel_width: 0,
396            pixel_height: 0,
397        };
398        // Resolve the session this pane belongs to so we can stamp
399        // TEAR_SESSION_{ID,NAME} on the child's env. Look-up is
400        // cheap (BTreeMap walk over typically <10 sessions); the
401        // alternative — caller threading session_id in — would
402        // bloat every call site.
403        let (session_id, session_name) = {
404            let r = self.registry.read();
405            r.sessions
406                .values()
407                .find(|s| s.panes.contains_key(&pane_id))
408                .map(|s| (s.id.to_string(), s.name.clone()))
409                .unwrap_or_else(|| (String::new(), String::new()))
410        };
411        // portable_pty's CommandBuilder uses an explicit env-vec
412        // — any env we pass REPLACES the parent process's env
413        // rather than augmenting it. The tear-daemon typically
414        // runs under launchd (macOS) / systemd-user (Linux) with
415        // a minimal env, so we MUST inherit the daemon's env
416        // first (which carries PATH from blackmatter-shell's
417        // session-vars), THEN stamp TEAR_* on top, THEN ensure
418        // TERM is set so terminfo-based programs (`clear`, vi,
419        // anything that reads $TERM) work.
420        let mut env: Vec<(String, String)> = std::env::vars().collect();
421        // The env-var NAMES come from the typed cross-tool contract
422        // (`Fsv`, hoisted above) — the SAME source seki's prompt reads,
423        // so a rename is a compile-time change on both sides. The VALUES
424        // are unchanged.
425        env.push(("TEAR".into(), "1".into()));
426        env.push((Fsv::TearSessionId.name().into(), session_id));
427        env.push((Fsv::TearSessionName.name().into(), session_name));
428        env.push((Fsv::TearPaneId.name().into(), pane_id.to_string()));
429        if let Some(p) = self.socket_path() {
430            env.push((
431                Fsv::TearSocket.name().into(),
432                p.to_string_lossy().to_string(),
433            ));
434        }
435        // TERM fallback — if the daemon was spawned by launchd
436        // and doesn't have TERM set, every shell inside tear
437        // would see `TERM environment variable not set` and
438        // `clear` / `tput` / readline arrow keys would break.
439        // xterm-256color is the modern conservative default.
440        if !env.iter().any(|(k, _)| k == "TERM") {
441            env.push(("TERM".into(), "xterm-256color".into()));
442        }
443        // COLORTERM advertises 24-bit colour support to apps
444        // that opt-in (newer vim/neovim, modern btop, etc.).
445        if !env.iter().any(|(k, _)| k == "COLORTERM") {
446            env.push(("COLORTERM".into(), "truecolor".into()));
447        }
448        // PATH augmentation — when the daemon is spawned by
449        // launchd (macOS) / systemd-user (Linux), its inherited
450        // PATH is the minimal `/usr/bin:/bin:/usr/sbin:/sbin`.
451        // Shells that try to invoke `tear` from a starship custom
452        // block, or any home-manager-installed binary, fail —
453        // and starship's prompt rendering hangs / errors silently.
454        // We prepend the operator's home-manager + nix-profile
455        // bin dirs to whatever PATH was inherited so the shell
456        // can find the same binaries the user sees outside tear.
457        if let Some(home) = env.iter().find(|(k, _)| k == "HOME").map(|(_, v)| v.clone()) {
458            let user = env
459                .iter()
460                .find(|(k, _)| k == "USER")
461                .map(|(_, v)| v.clone())
462                .unwrap_or_default();
463            let extra_paths = [
464                format!("/etc/profiles/per-user/{user}/bin"),
465                format!("{home}/.nix-profile/bin"),
466                "/run/current-system/sw/bin".to_string(),
467                "/nix/var/nix/profiles/default/bin".to_string(),
468                "/usr/local/bin".to_string(),
469            ];
470            // Find existing PATH entry to prepend to; if missing,
471            // build PATH from scratch with sensible defaults.
472            let existing_path = env
473                .iter()
474                .find(|(k, _)| k == "PATH")
475                .map(|(_, v)| v.clone())
476                .unwrap_or_else(|| "/usr/bin:/bin:/usr/sbin:/sbin".to_string());
477            // Prepend extras that aren't already in PATH (de-dupe
478            // so we don't bloat PATH on every nested spawn).
479            let mut new_path = String::new();
480            for p in &extra_paths {
481                if !existing_path
482                    .split(':')
483                    .any(|seg| seg == p.as_str())
484                {
485                    if !new_path.is_empty() {
486                        new_path.push(':');
487                    }
488                    new_path.push_str(p);
489                }
490            }
491            if !new_path.is_empty() {
492                new_path.push(':');
493                new_path.push_str(&existing_path);
494            } else {
495                new_path = existing_path;
496            }
497            // Replace existing PATH entry (or append if missing).
498            if let Some(slot) = env.iter_mut().find(|(k, _)| k == "PATH") {
499                slot.1 = new_path;
500            } else {
501                env.push(("PATH".into(), new_path));
502            }
503        }
504        // Embedder env + cwd override (mado's capability projection),
505        // applied LAST so its TERM=xterm-ghostty + TERMINFO + COLORTERM
506        // win over the xterm-256color fallback above (the "vim grey"
507        // fix), and PWD is stamped to match the cwd. Empty pre-seam.
508        let spawn_env = self.spawn_env.read().clone();
509        spawn_env.apply_to(&mut env);
510        let cwd = spawn_env.cwd.clone();
511        // Allocate the per-pane grid and register it BEFORE spawning
512        // the PTY — the reader thread starts immediately on spawn,
513        // and we want the first bytes to find their grid.
514        let grid = Arc::new(Mutex::new(PaneGrid::new(size.0 as usize, size.1 as usize)));
515        // Stamp provenance BEFORE the grid is registered, so the
516        // first byte the reader thread feeds already lands in an
517        // attributed extractor. Registering first would leave a
518        // window in which a fast-printing shell mints `Unknown`
519        // blocks for a pane whose provenance we already knew.
520        grid.lock().stamp_yurai(yurai);
521        self.grids.lock().insert(pane_id, grid.clone());
522
523        let grid_for_callback = grid.clone();
524        let subscribers_for_callback = self.subscribers.clone();
525        let recordings_for_callback = self.recordings.clone();
526        let on_bytes = Box::new(move |bytes: &[u8]| {
527            grid_for_callback.lock().feed(bytes);
528            // Fan out to subscribers (Phase-2.5 push subscriptions).
529            // Cheap when there are zero subscribers; per-subscriber
530            // cost is a Vec clone + mpsc::send. On send error the
531            // sender is dead — prune it.
532            let mut subs = subscribers_for_callback.lock();
533            if let Some(ps) = subs.get_mut(&pane_id) {
534                let senders = &mut ps.senders;
535                let mut i = 0;
536                while i < senders.len() {
537                    if senders[i].send(bytes.to_vec()).is_err() {
538                        senders.swap_remove(i);
539                    } else {
540                        i += 1;
541                    }
542                }
543            }
544            drop(subs);
545            // Push to the recording (#4). The Arc-cloned
546            // recording handle's `push` is a single Mutex-lock
547            // + early return when disabled, so this is cheap
548            // even when nothing's recording.
549            if let Some(rec) = recordings_for_callback.lock().get(&pane_id) {
550                rec.push(bytes);
551            }
552            debug!(pane_id = %pane_id, n = bytes.len(), "tear-core: pty bytes fed to grid + subscribers");
553        });
554        // on_exit — fired once by the PTY reader thread when the child
555        // exits (PTY EOF). Three typed consequences:
556        //
557        //  1. Mark the pane `PaneState::Exited { code }` in the typed
558        //     registry. A *watched* pane + its final grid stay (tmux
559        //     remain-on-exit) so `tear list` / snapshots still see it;
560        //     only the live byte stream ends.
561        //  2. Mark the subscriber entry `closed` + drop every live
562        //     sender. Each engate/daemon `Receiver.recv()` then
563        //     returns `Err` — the end-of-stream signal mado's
564        //     `attach_live.run()` and the daemon's `serve_subscription`
565        //     block on. Without this the channel stays open forever and
566        //     a single-pane GUI (mado embedded) never learns the shell
567        //     exited, so its window never closes.
568        //  3. Reap-on-exit (session-leak fix, 2026-07-06): when EVERY
569        //     pane of the owning session has exited AND no pane carried
570        //     a live subscriber at exit time (nothing was watching),
571        //     the whole session is removed from the registry and its
572        //     runtime artifacts are detached. Without this, sessions
573        //     whose shell exited unwatched (agent spawns that never
574        //     attached, one-shot commands) linger in the registry
575        //     forever — surfacing as ghost rows in mado's Ctrl-S
576        //     picker. Conservative by construction: any live sender on
577        //     any pane of the session (a mado window, a recorder)
578        //     preserves the remain-on-exit behavior unchanged, and a
579        //     session with any Running/Spawning pane is never touched.
580        //
581        // Lock order matches the kill paths (the registry write, the
582        // subscribers lock, and the reap's detach scope are separate
583        // critical sections — never nested — so no inversion). The
584        // subscribers step is gated on the pane still being present in
585        // the registry so an explicit `kill_pane` that races with
586        // natural exit doesn't leave a lingering empty entry. The reap
587        // runs on this pane's own reader thread, which is safe: on the
588        // natural-exit path the child slot is already `take()`n +
589        // reaped (so `PtyHandle::drop` skips the kill) and every
590        // sibling pane is `Exited` (their readers finished), and the
591        // handle drops happen with NO `InProcess` lock held
592        // (`detach_panes`' deadlock contract).
593        let subscribers_for_exit = Arc::clone(&self.subscribers);
594        let registry_for_exit = Arc::clone(&self.registry);
595        let ptys_for_exit = Arc::clone(&self.ptys);
596        let grids_for_exit = Arc::clone(&self.grids);
597        let recordings_for_exit = Arc::clone(&self.recordings);
598        let on_exit = Box::new(move |code: Option<i32>| {
599            let (still_present, fully_exited) = {
600                let mut r = registry_for_exit.write();
601                let mut found = false;
602                let mut fully: Option<SessionId> = None;
603                for s in r.sessions.values_mut() {
604                    if let Some(p) = s.panes.get_mut(&pane_id) {
605                        p.state = tear_types::PaneState::Exited {
606                            code: code.unwrap_or(-1),
607                        };
608                        found = true;
609                        if s.panes.values().all(|p| {
610                            matches!(p.state, tear_types::PaneState::Exited { .. })
611                        }) {
612                            fully = Some(s.id);
613                        }
614                        break;
615                    }
616                }
617                (found, fully)
618            };
619            // `watched` = any pane of the (fully-exited) session still
620            // carried a live sender at exit time — this pane's senders
621            // inspected BEFORE the clear, siblings' as they stand
622            // (their own on_exit already cleared them if they exited
623            // unwatched). Watched sessions keep remain-on-exit.
624            let watched = {
625                let mut subs = subscribers_for_exit.lock();
626                let this_pane_watched = if still_present {
627                    let ps = subs.entry(pane_id).or_default();
628                    let had_live_sender = !ps.senders.is_empty();
629                    ps.closed = Some(code.unwrap_or(-1));
630                    ps.senders.clear();
631                    had_live_sender
632                } else {
633                    // Pane was explicitly killed concurrently — drop any
634                    // entry rather than recreating one for a dead id.
635                    subs.remove(&pane_id);
636                    true
637                };
638                this_pane_watched
639                    || fully_exited.is_some_and(|sid| {
640                        registry_for_exit.read().sessions.get(&sid).is_some_and(|s| {
641                            s.panes.keys().any(|p| {
642                                *p != pane_id
643                                    && subs.get(p).is_some_and(|ps| !ps.senders.is_empty())
644                            })
645                        })
646                    })
647            };
648            debug!(pane_id = %pane_id, ?code, "tear-core: pane child exited — marked Exited + disconnected subscribers");
649            if let Some(sid) = fully_exited.filter(|_| !watched) {
650                // The proof is re-taken under the write lock inside
651                // `reap_proven_dead`, so a window/pane spawned into the
652                // session between the mark and here (or a concurrent
653                // explicit kill) aborts the reap.
654                //
655                // BOUND TO A `let` ON PURPOSE — do not inline this into
656                // the `if let` scrutinee. Rust 2024's if-let rescoping
657                // drops scrutinee temporaries before the `else` arm but
658                // NOT before the success arm, so the read guard would
659                // still be alive inside the block and `reap_proven_dead`'s
660                // `registry.write()` would wait forever on a reader held
661                // by its own thread. Measured 2026-07-31: the daemon
662                // wedged on the first pane whose child exited, with
663                // `tear-pty-reader` parked in `wait_for_readers` and
664                // every later `list_sessions` parked behind it.
665                let proof = registry_for_exit
666                    .read()
667                    .sessions
668                    .get(&sid)
669                    .and_then(AllPanesExited::witness);
670                if let Some(proof) = proof {
671                    reap_proven_dead(
672                        &registry_for_exit,
673                        &ptys_for_exit,
674                        &grids_for_exit,
675                        &subscribers_for_exit,
676                        &recordings_for_exit,
677                        proof,
678                    );
679                }
680            }
681        });
682        let pty = PtyHandle::spawn(
683            shell,
684            args,
685            cwd.as_deref(),
686            &env,
687            pty_size,
688            on_bytes,
689            on_exit,
690        )?;
691        self.ptys.lock().insert(pane_id, pty);
692        // Reap-race guard: a child that exits faster than this insert
693        // lands may already have been reaped (session gone from the
694        // registry) — pull the handle straight back out so a dead
695        // session can't strand a PtyHandle in the map. The handle (if
696        // any) drops outside every lock, per `detach_panes`' contract.
697        let orphaned = {
698            let in_registry = self
699                .registry
700                .read()
701                .sessions
702                .values()
703                .any(|s| s.panes.contains_key(&pane_id));
704            if in_registry {
705                None
706            } else {
707                self.grids.lock().remove(&pane_id);
708                self.subscribers.lock().remove(&pane_id);
709                self.recordings.lock().remove(&pane_id);
710                self.ptys.lock().remove(&pane_id)
711            }
712        };
713        drop(orphaned);
714        Ok(())
715    }
716
717    /// Detach every runtime artifact for `panes` — PTY handle, VT grid,
718    /// subscriber fan-out — under the three map locks, RETURNING the
719    /// PTY handles instead of dropping them. Callers drop the returned
720    /// vec only after every `InProcess` lock is released.
721    ///
722    /// DEADLOCK CONTRACT (mado L1 teardown wedge, 2026-06-10):
723    /// dropping a [`PtyHandle`] kills + reaps the child, and the
724    /// pane's `tear-pty-reader` thread may simultaneously be blocked
725    /// acquiring `subscribers` (inside `on_bytes`) or `registry`
726    /// (inside `on_exit`). Dropping the handle while this thread holds
727    /// those locks is a mutual wait: the reap can't finish until the
728    /// reader drains, the reader can't drain until the locks release —
729    /// observed as a 20+ minute wedge. The handles therefore ALWAYS
730    /// leave the maps inside the lock scope and die outside it (the
731    /// reap itself is additionally bounded — see `pty::reap_with_deadline`).
732    fn detach_panes(&self, panes: &[PaneId]) -> Vec<PtyHandle> {
733        let detached = {
734            let mut ptys = self.ptys.lock();
735            let mut grids = self.grids.lock();
736            let mut subs = self.subscribers.lock();
737            let mut detached = Vec::with_capacity(panes.len());
738            for p in panes {
739                if let Some(h) = ptys.remove(p) {
740                    detached.push(h);
741                }
742                grids.remove(p);
743                // Dropping the sender vec disconnects subscribers
744                // cleanly — their recv() returns Err on next read.
745                subs.remove(p);
746            }
747            detached
748        };
749        // Drop the recording buffer as well — see the note in
750        // `reap_proven_dead` for why this is a separate scope taken after
751        // the three-map region rather than a fourth lock inside it.
752        {
753            let mut recs = self.recordings.lock();
754            for p in panes {
755                recs.remove(p);
756            }
757        }
758        detached
759    }
760
761    /// Remove a session the caller has **proven** dead — every pane's
762    /// child process has exited.
763    ///
764    /// This is the *only* way for a daemon to end a session on its own
765    /// initiative, and [`AllPanesExited`] is the only ticket in. A rule
766    /// keyed on "nobody is attached", on an idle timer, or on the
767    /// session's [`tear_types::SessionSource`] cannot construct one —
768    /// see [`crate::reap`] for the incident that motivated the narrowing.
769    ///
770    /// Returns `true` if the session was removed. `false` means the proof
771    /// went stale between witnessing and now (a `new_window` landed, or
772    /// an explicit `kill_session` won the race) — the proof is re-taken
773    /// under the write lock, so a resurrected session is never reaped.
774    ///
775    /// **Call with no registry guard held.** Mint the proof from an owned
776    /// [`TearSession`] (`list_sessions()` / `get_session()` both clone) or
777    /// from a `read()` bound to its own `let` statement. Passing a proof
778    /// witnessed inside a still-live read guard deadlocks on the `write()`
779    /// below — `parking_lot`'s `RwLock` is not reentrant and this thread
780    /// would be waiting for itself.
781    pub fn reap_proven_dead_session(&self, proof: AllPanesExited) -> bool {
782        reap_proven_dead(
783            &self.registry,
784            &self.ptys,
785            &self.grids,
786            &self.subscribers,
787            &self.recordings,
788            proof,
789        )
790    }
791}
792
793/// Free-standing so both `spawn_pty_for`'s `on_exit` hook (which owns
794/// cloned `Arc`s, not a `&self`) and [`InProcess::reap_proven_dead_session`]
795/// share one implementation — the session-removal path exists once.
796///
797/// Same shape as [`InProcess::detach_panes`]: artifacts leave the maps
798/// under the locks, handles die outside them (that deadlock contract).
799fn reap_proven_dead(
800    registry: &RwLock<Registry>,
801    ptys: &Mutex<BTreeMap<PaneId, PtyHandle>>,
802    grids: &Mutex<BTreeMap<PaneId, Arc<Mutex<PaneGrid>>>>,
803    subscribers: &Mutex<BTreeMap<PaneId, PaneSubscribers>>,
804    recordings: &Mutex<BTreeMap<PaneId, Arc<PaneRecording>>>,
805    proof: AllPanesExited,
806) -> bool {
807    let sid = proof.session();
808    let panes_to_detach: Vec<PaneId> = {
809        let mut r = registry.write();
810        // Re-witness under the write lock: the caller's proof was taken
811        // outside it and a racing `new_window` / `split_pane` /
812        // `kill_session` invalidates it.
813        if r.sessions
814            .get(&sid)
815            .and_then(AllPanesExited::witness)
816            .is_some()
817        {
818            r.sessions
819                .remove(&sid)
820                .map(|s| s.panes.keys().copied().collect())
821                .unwrap_or_default()
822        } else {
823            Vec::new()
824        }
825    };
826    if panes_to_detach.is_empty() {
827        return false;
828    }
829    let detached: Vec<PtyHandle> = {
830        let mut ptys = ptys.lock();
831        let mut grids = grids.lock();
832        let mut subs = subscribers.lock();
833        panes_to_detach
834            .iter()
835            .filter_map(|p| {
836                grids.remove(p);
837                subs.remove(p);
838                ptys.remove(p)
839            })
840            .collect()
841    };
842    // Drop the recording buffer too. Taken in its OWN scope, after the
843    // three-map scope above, deliberately: the pty reader acquires
844    // `subscribers` and `recordings` sequentially (it drops `subs` before
845    // taking `recordings` in `on_bytes`), so keeping this outside the
846    // triple-lock region adds no new lock-order edge to the deadlock
847    // contract on `detach_panes`. The `Arc<PaneRecording>` keeps any
848    // in-flight `push` alive until it returns.
849    {
850        let mut recs = recordings.lock();
851        for p in &panes_to_detach {
852            recs.remove(p);
853        }
854    }
855    drop(detached);
856    info!(session = %sid, "tear-core: reaped fully-exited unwatched session");
857    true
858}
859
860impl MultiplexerControl for InProcess {
861    fn list_sessions(&self) -> ControlResult<Vec<TearSession>> {
862        Ok(self.registry.read().sessions_in_order())
863    }
864
865    fn get_session(&self, id: SessionId) -> ControlResult<TearSession> {
866        self.registry
867            .read()
868            .sessions
869            .get(&id)
870            .cloned()
871            .ok_or(ControlError::NoSuchSession(id))
872    }
873
874    fn get_window(&self, id: WindowId) -> ControlResult<(SessionId, TearWindow)> {
875        let r = self.registry.read();
876        for s in r.sessions.values() {
877            if let Some(w) = s.windows.get(&id) {
878                return Ok((s.id, w.clone()));
879            }
880        }
881        Err(ControlError::NoSuchWindow(id))
882    }
883
884    fn get_pane(&self, id: PaneId) -> ControlResult<TearPane> {
885        let r = self.registry.read();
886        for s in r.sessions.values() {
887            if let Some(p) = s.panes.get(&id) {
888                return Ok(p.clone());
889            }
890        }
891        Err(ControlError::NoSuchPane(id))
892    }
893
894    fn new_session_with_source_and_size(
895        &self,
896        name: &str,
897        shell: &str,
898        args: &[String],
899        source: tear_types::SessionSource,
900        size_cells: (u16, u16),
901    ) -> ControlResult<SessionId> {
902        self.new_session_yurai(name, shell, args, source, size_cells, tear_types::Yurai::Unknown)
903    }
904
905    fn new_window(&self, session: SessionId, name: &str, shell: &str, args: &[String])
906        -> ControlResult<WindowId>
907    {
908        self.new_window_yurai(session, name, shell, args, tear_types::Yurai::Unknown)
909    }
910
911    fn split_pane(
912        &self,
913        origin: PaneId,
914        direction: tear_types::Direction,
915        shell: &str,
916        args: &[String],
917    ) -> ControlResult<PaneId> {
918        self.split_pane_yurai(origin, direction, shell, args, tear_types::Yurai::Unknown)
919    }
920
921
922    fn rename_session(&self, id: SessionId, new_name: &str) -> ControlResult<()> {
923        let mut r = self.registry.write();
924        let s = r.sessions.get_mut(&id).ok_or(ControlError::NoSuchSession(id))?;
925        s.name = new_name.into();
926        Ok(())
927    }
928
929    fn kill_session(&self, id: SessionId) -> ControlResult<()> {
930        let panes_to_kill: Vec<PaneId> = {
931            let r = self.registry.read();
932            let s = r.sessions.get(&id).ok_or(ControlError::NoSuchSession(id))?;
933            s.panes.keys().copied().collect()
934        };
935        // Pull the runtime artifacts out under the locks…
936        let detached = self.detach_panes(&panes_to_kill);
937        self.registry.write().sessions.remove(&id);
938        // …and kill + reap the PTY children with NO InProcess lock
939        // held (detach_panes' deadlock contract).
940        drop(detached);
941        info!(session = %id, "tear-core: killed session");
942        Ok(())
943    }
944
945
946    fn kill_window(&self, id: WindowId) -> ControlResult<()> {
947        let panes_to_kill: Vec<PaneId> = {
948            let r = self.registry.read();
949            let mut out = Vec::new();
950            for s in r.sessions.values() {
951                if let Some(w) = s.windows.get(&id) {
952                    out.extend(w.layout.panes());
953                    break;
954                }
955            }
956            if out.is_empty() {
957                return Err(ControlError::NoSuchWindow(id));
958            }
959            out
960        };
961        // Same shape as kill_session: artifacts leave the maps under
962        // the locks, handles die only after every lock is released
963        // (detach_panes' deadlock contract).
964        let detached = self.detach_panes(&panes_to_kill);
965        {
966            let mut r = self.registry.write();
967            for s in r.sessions.values_mut() {
968                if s.windows.remove(&id).is_some() {
969                    for p in &panes_to_kill {
970                        s.panes.remove(p);
971                    }
972                    // Retarget focus off the removed window, else
973                    // active_window dangles and the s.windows[&active_window]
974                    // index sites panic.
975                    if s.active_window == id {
976                        s.active_window =
977                            s.windows.keys().next().copied().unwrap_or(WindowId::NULL);
978                    }
979                    break;
980                }
981            }
982        }
983        drop(detached);
984        info!(window = %id, "tear-core: killed window");
985        Ok(())
986    }
987
988    fn select_window(&self, id: WindowId) -> ControlResult<()> {
989        let mut r = self.registry.write();
990        for s in r.sessions.values_mut() {
991            if s.windows.contains_key(&id) {
992                s.active_window = id;
993                return Ok(());
994            }
995        }
996        Err(ControlError::NoSuchWindow(id))
997    }
998
999
1000    fn kill_pane(&self, id: PaneId) -> ControlResult<()> {
1001        // NOTE pre-fix this was `self.ptys.lock().remove(&id);` — the
1002        // returned PtyHandle was a temporary dropped BEFORE the lock
1003        // guard (reverse creation order), i.e. the kill + reap ran
1004        // with the ptys lock held. Same wedge class as kill_session;
1005        // same cure: detach under the locks, drop outside them.
1006        let Some((sid, wid)) = self.registry.read().locate_pane(id) else {
1007            return Err(ControlError::NoSuchPane(id));
1008        };
1009        let detached = self.detach_panes(&[id]);
1010        // true → pane removed from a multi-pane window, geometry needs a
1011        // reflow; false → the window collapsed (was its last pane) so
1012        // there's nothing left to reflow.
1013        let needs_reflow = {
1014            let mut r = self.registry.write();
1015            let Some(s) = r.sessions.get_mut(&sid) else {
1016                drop(detached);
1017                return Err(ControlError::NoSuchSession(sid));
1018            };
1019            let outcome = match s.windows.get_mut(&wid) {
1020                Some(w) => w.layout.remove_leaf(id),
1021                None => LeafRemoval::NotFound,
1022            };
1023            match outcome {
1024                // NotFound means `locate_pane` said this pane was in BOTH
1025                // `s.panes` and the window's tree, and then `remove_leaf`
1026                // could not find it in the tree — i.e. the two had already
1027                // diverged. That is structural corruption, and it used to
1028                // share an arm with the normal close: the record was
1029                // silently dropped and the caller got Ok(()), so a real bug
1030                // was indistinguishable from a routine kill.
1031                //
1032                // Roll the record back in and refuse. NoSuchPane already
1033                // exists and already round-trips over the wire, so this
1034                // costs no new type and no wire churn.
1035                LeafRemoval::NotFound => {
1036                    drop(detached);
1037                    return Err(ControlError::NoSuchPane(id));
1038                }
1039                // Parent split collapsed into the sibling — the flat pane
1040                // record goes too, and active_pane retargets if it pointed
1041                // at the dead pane.
1042                LeafRemoval::Removed => {
1043                    s.panes.remove(&id);
1044                    if let Some(w) = s.windows.get_mut(&wid) {
1045                        if w.active_pane == id {
1046                            w.active_pane =
1047                                w.layout.panes().first().copied().unwrap_or(PaneId::NULL);
1048                        }
1049                    }
1050                    true
1051                }
1052                // The window's only pane — nothing the tree can represent
1053                // remains, so the whole window closes (tmux semantics).
1054                // Retarget active_window if it pointed here, else it would
1055                // dangle at a removed id and panic the
1056                // s.windows[&active_window] index sites.
1057                LeafRemoval::WasRoot => {
1058                    s.panes.remove(&id);
1059                    s.windows.remove(&wid);
1060                    if s.active_window == wid {
1061                        s.active_window = s.windows.keys().next().copied().unwrap_or(WindowId::NULL);
1062                    }
1063                    false
1064                }
1065            }
1066        };
1067        drop(detached);
1068        if needs_reflow {
1069            self.apply_layout_geometry(sid, wid);
1070        }
1071        Ok(())
1072    }
1073
1074    fn select_pane(&self, id: PaneId) -> ControlResult<()> {
1075        let (sid, wid) = self
1076            .registry
1077            .read()
1078            .locate_pane(id)
1079            .ok_or(ControlError::NoSuchPane(id))?;
1080        let mut r = self.registry.write();
1081        if let Some(s) = r.sessions.get_mut(&sid) {
1082            if let Some(w) = s.windows.get_mut(&wid) {
1083                w.active_pane = id;
1084            }
1085        }
1086        Ok(())
1087    }
1088
1089    fn resize_pane(
1090        &self,
1091        id: PaneId,
1092        direction: Direction,
1093        delta_cells: i16,
1094    ) -> ControlResult<()> {
1095        // Slide the divider of the split governing `id` along `direction`.
1096        // delta_cells is converted to a fraction of the window's span on
1097        // the relevant axis, then resize_leaf clamps + applies it. If the
1098        // pane has no governing split that way (single pane / wrong axis)
1099        // it's a no-op — still Ok, like tmux.
1100        let (sid, wid) = self
1101            .registry
1102            .read()
1103            .locate_pane(id)
1104            .ok_or(ControlError::NoSuchPane(id))?;
1105        {
1106            let mut r = self.registry.write();
1107            let Some(s) = r.sessions.get_mut(&sid) else {
1108                return Err(ControlError::NoSuchSession(sid));
1109            };
1110            let Some(w) = s.windows.get_mut(&wid) else {
1111                return Err(ControlError::NoSuchWindow(wid));
1112            };
1113            let span = match direction.orientation() {
1114                SplitOrientation::Vertical => w.size_cells.0,
1115                SplitOrientation::Horizontal => w.size_cells.1,
1116            };
1117            let delta_frac = if span > 0 {
1118                f32::from(delta_cells) / f32::from(span)
1119            } else {
1120                0.0
1121            };
1122            w.layout.resize_leaf(id, direction, delta_frac);
1123        }
1124        self.apply_layout_geometry(sid, wid);
1125        Ok(())
1126    }
1127
1128    fn apply_layout(&self, window: WindowId, kind: LayoutKind) -> ControlResult<()> {
1129        // Locate the window's session under a read lock; release it before
1130        // taking the write lock (the lock discipline this file keeps).
1131        let sid = {
1132            let r = self.registry.read();
1133            r.sessions
1134                .iter()
1135                .find(|(_, s)| s.windows.contains_key(&window))
1136                .map(|(id, _)| *id)
1137        }
1138        .ok_or(ControlError::NoSuchWindow(window))?;
1139        // Re-arrange the window's existing panes (display order) into the
1140        // named layout. Panes keep their PTYs — only the tree changes.
1141        // Custom / empty → from_kind returns None → no-op.
1142        let applied = {
1143            let mut r = self.registry.write();
1144            let Some(s) = r.sessions.get_mut(&sid) else {
1145                return Err(ControlError::NoSuchWindow(window));
1146            };
1147            let Some(w) = s.windows.get_mut(&window) else {
1148                return Err(ControlError::NoSuchWindow(window));
1149            };
1150            match LayoutNode::from_kind(kind, &w.layout.panes()) {
1151                Some(tree) => {
1152                    w.layout = tree;
1153                    true
1154                }
1155                None => false,
1156            }
1157        };
1158        if applied {
1159            self.apply_layout_geometry(sid, window);
1160        }
1161        info!(window = %window, ?kind, applied, "tear-core: apply layout");
1162        Ok(())
1163    }
1164
1165    fn send_keys(&self, id: PaneId, bytes: &[u8]) -> ControlResult<()> {
1166        // Input-policy gate (#2).
1167        //
1168        // - Locked: always reject — operator-explicit "no input
1169        //   now". Surfaced before we touch the PTY so a Locked
1170        //   pane never writes a partial frame.
1171        // - Leader: identity-gating semantics are enforced ONE
1172        //   layer up by the daemon's serve_connection_with_auth
1173        //   path (which carries per-connection client_id). The
1174        //   in-process trait surface has no client identity, so
1175        //   Leader is treated as Free here — once the daemon's
1176        //   gate authorises a SendKeys, this layer accepts. Pure
1177        //   in-process consumers (mado tier-3) that need Leader
1178        //   semantics must gate at their own layer.
1179        {
1180            let r = self.registry.read();
1181            let Some((sid, _wid)) = r.locate_pane(id) else {
1182                return Err(ControlError::NoSuchPane(id));
1183            };
1184            let Some(pane) = r.sessions.get(&sid).and_then(|s| s.panes.get(&id)) else {
1185                return Err(ControlError::NoSuchPane(id));
1186            };
1187            if matches!(pane.input_policy, tear_types::InputPolicy::Locked) {
1188                return Err(ControlError::Rejected(format!(
1189                    "pane {id} input_policy=locked — send_keys rejected"
1190                )));
1191            }
1192        }
1193        let ptys = self.ptys.lock();
1194        let pty = ptys.get(&id).ok_or(ControlError::NoSuchPane(id))?;
1195        pty.write(bytes)
1196            .map_err(|e| ControlError::Transport(e.to_string()))?;
1197        Ok(())
1198    }
1199
1200    fn pane_subscriber_count(&self, id: PaneId) -> ControlResult<u32> {
1201        // Mirrors the byte-stream fan-out path: subscribers are
1202        // indexed per-pane in InProcess.subscribers. Counting +
1203        // sender liveness check (try_send via a synthetic
1204        // empty-byte cycle would be too heavy) means we just
1205        // report the slot length — a sender drops naturally on
1206        // next broadcast if dead, so the count is an upper bound.
1207        let count = self
1208            .subscribers
1209            .lock()
1210            .get(&id)
1211            .map(|ps| ps.senders.len() as u32)
1212            .unwrap_or(0);
1213        Ok(count)
1214    }
1215
1216    fn set_input_policy(
1217        &self,
1218        id: PaneId,
1219        policy: tear_types::InputPolicy,
1220    ) -> ControlResult<()> {
1221        let mut r = self.registry.write();
1222        // Walk the session→panes maps to find the target. Mirrors
1223        // locate_pane's address logic but with mutable access.
1224        for s in r.sessions.values_mut() {
1225            if let Some(p) = s.panes.get_mut(&id) {
1226                p.input_policy = policy;
1227                return Ok(());
1228            }
1229        }
1230        Err(ControlError::NoSuchPane(id))
1231    }
1232
1233    /// Phase-2 override of the trait's default `pane_snapshot`.
1234    /// Delegates to the inherent [`Self::pane_snapshot`] method,
1235    /// which reads the per-pane `PaneGrid` installed by
1236    /// [`Self::spawn_pty_for`].
1237    fn pane_snapshot(&self, id: PaneId) -> ControlResult<tear_types::PaneSnapshot> {
1238        InProcess::pane_snapshot(self, id)
1239    }
1240
1241    /// Override the trait default with the no-alloc lookup —
1242    /// mado's input loop calls this per keystroke, so the
1243    /// fast path matters here.
1244    fn pane_cursor_keys_mode(&self, id: PaneId) -> ControlResult<bool> {
1245        InProcess::pane_cursor_keys_mode(self, id)
1246    }
1247
1248    /// Phase-3.1 override — resize the underlying PTY (fires
1249    /// SIGWINCH at the child) AND resize the per-pane PaneGrid
1250    /// so subsequent snapshots reflect the new geometry.
1251    fn pane_resize_absolute(
1252        &self,
1253        id: PaneId,
1254        cols: u16,
1255        rows: u16,
1256    ) -> ControlResult<()> {
1257        use portable_pty::PtySize;
1258        let pty_size = PtySize {
1259            rows,
1260            cols,
1261            pixel_width: 0,
1262            pixel_height: 0,
1263        };
1264        // Resize the PTY (delivers SIGWINCH to child).
1265        {
1266            let ptys = self.ptys.lock();
1267            let pty = ptys.get(&id).ok_or(ControlError::NoSuchPane(id))?;
1268            pty.resize(pty_size)
1269                .map_err(|e| ControlError::Internal(anyhow::anyhow!(e)))?;
1270        }
1271        // Resize the parser-backed grid so subsequent snapshots
1272        // honour the new geometry.
1273        if let Some(grid) = self.grids.lock().get(&id).cloned() {
1274            grid.lock().resize(cols as usize, rows as usize);
1275        }
1276        Ok(())
1277    }
1278}
1279
1280/// Inherent helpers that aren't part of the [`MultiplexerControl`] trait
1281/// surface — internal reflow + geometry plumbing the trait methods call.
1282impl InProcess {
1283    /// Reflow a window's panes from its [`LayoutNode`] tree — the single
1284    /// geometry source. Computes every pane's rect against the window's
1285    /// cell size, writes the cell size/origin onto each flat pane record,
1286    /// and SIGWINCHes each PTY to match. `split_pane`/`kill_pane`/
1287    /// `resize_pane` all call this after mutating the tree, so what a
1288    /// window displays always matches its layout. Best-effort: a pane
1289    /// squeezed to zero cells (too-small window) or whose PTY hasn't
1290    /// spawned yet is skipped, never an error.
1291    fn apply_layout_geometry(&self, sid: SessionId, wid: WindowId) {
1292        // Phase 1 — under the registry lock, recompute rects + stamp them
1293        // onto the pane records. We collect the rect set to apply to PTYs
1294        // AFTER dropping the lock (pane_resize_absolute takes ptys/grids
1295        // locks; holding registry across that is the wedge class we avoid
1296        // elsewhere in this file).
1297        let rects = {
1298            let mut r = self.registry.write();
1299            let Some(s) = r.sessions.get_mut(&sid) else {
1300                return;
1301            };
1302            let rects = match s.windows.get(&wid) {
1303                Some(w) => w
1304                    .layout
1305                    .compute_rects(Rect::sized(w.size_cells.0, w.size_cells.1)),
1306                None => return,
1307            };
1308            for (pane, rect) in &rects {
1309                if let Some(p) = s.panes.get_mut(pane) {
1310                    p.size_cells = (rect.w.max(1), rect.h.max(1));
1311                    p.origin_cells = (rect.x, rect.y);
1312                }
1313            }
1314            rects
1315        };
1316        // Phase 2 — SIGWINCH each PTY to its new geometry.
1317        for (pane, rect) in rects {
1318            if rect.w == 0 || rect.h == 0 {
1319                continue;
1320            }
1321            let _ = self.pane_resize_absolute(pane, rect.w, rect.h);
1322        }
1323    }
1324}
1325
1326#[cfg(test)]
1327mod tests {
1328    use super::*;
1329
1330    /// How long a test waits for a REAL child shell to produce output.
1331    ///
1332    /// This is a **timeout, not a performance assertion**: every waiter
1333    /// below returns as soon as it sees what it wants, so a generous bound
1334    /// costs nothing on the passing path and only decides how long a
1335    /// genuinely-broken run takes to fail.
1336    ///
1337    /// It exists because the same wait was written with FOUR different
1338    /// numbers (2s ×4, 5s, 10s ×3), and the 2-second ones were flaky:
1339    /// measured 2026-07-31, `pty_env_includes_tear_session_pane_socket_vars`
1340    /// failed in a full parallel workspace run — the shell's echo was
1341    /// captured but the sentinel had not arrived — while passing 3/3 in
1342    /// isolation. Under a full run many PTY-spawning tests compete for the
1343    /// machine and a real `/bin/sh` misses a tight deadline.
1344    ///
1345    /// One name, one number. If a test genuinely needs a different bound it
1346    /// should say why at its own site rather than quietly picking another.
1347    const CHILD_OUTPUT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
1348
1349    #[test]
1350    fn new_inproc_starts_empty() {
1351        let inproc = InProcess::new();
1352        let sessions = inproc.list_sessions().unwrap();
1353        assert!(sessions.is_empty());
1354    }
1355
1356    /// A diverged tree is refused rather than silently healed.
1357    ///
1358    /// ★ READ THIS BEFORE TRUSTING IT — it does NOT prove what its first
1359    /// draft claimed. `kill_pane`'s `LeafRemoval::NotFound` arm was split out
1360    /// from the ordinary `Removed` close (they shared an arm, so structural
1361    /// corruption was indistinguishable from a routine kill). This test was
1362    /// written to earn that split, and MEASURED VACUOUS against it: it passes
1363    /// identically with the old merged arm restored.
1364    ///
1365    /// The reason is `locate_pane` (registry.rs), which requires membership
1366    /// in BOTH `s.panes` and the window's tree and runs at inproc.rs's read
1367    /// lock *before* `remove_leaf` is ever called. So a diverged pane is
1368    /// rejected there, and the refusal this asserts comes from that guard.
1369    ///
1370    /// Consequence, stated honestly: **`LeafRemoval::NotFound` is unreachable
1371    /// from a single-threaded caller.** It is a guard on the TOCTOU window
1372    /// between the read lock that resolves the pane and the write lock that
1373    /// mutates the tree — real under concurrency, and not reachable by any
1374    /// test that does not race those two locks. The arm split is kept as
1375    /// defence in depth, and is graded accordingly: **only-mitigated, ceiling
1376    /// = no single-threaded test can reach it; earning a green here needs a
1377    /// concurrent test that interleaves the two lock acquisitions.**
1378    ///
1379    /// What this test DOES pin, and it is worth pinning: `locate_pane`'s
1380    /// both-memberships requirement is load-bearing, and a refactor that
1381    /// relaxed it to a single lookup would make this go red.
1382    #[test]
1383    fn kill_pane_refuses_when_tree_and_pane_map_have_diverged() {
1384        let inproc = InProcess::new();
1385        let sid = inproc.new_session("divergence", "/bin/sh").unwrap();
1386        let pane = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1387
1388        // Break the invariant behind the API's back: evict the pane from the
1389        // layout tree while leaving its record in `s.panes`, so `locate_pane`
1390        // still resolves it but `remove_leaf` cannot find it.
1391        {
1392            let mut r = inproc.registry.write();
1393            let s = r.sessions.get_mut(&sid).unwrap();
1394            let wid = *s.windows.keys().next().unwrap();
1395            let w = s.windows.get_mut(&wid).unwrap();
1396            w.layout = tear_types::LayoutNode::leaf(tear_types::PaneId::from_seed("decoy"));
1397        }
1398
1399        let err = inproc
1400            .kill_pane(pane)
1401            .expect_err("a diverged tree must be refused, not silently healed");
1402        assert!(
1403            matches!(err, ControlError::NoSuchPane(p) if p == pane),
1404            "expected NoSuchPane for the diverged pane, got {err:?}"
1405        );
1406    }
1407
1408    /// Forcing function: the TEAR_* env-var names tear stamps onto every
1409    /// spawned pane come from the typed cross-tool contract
1410    /// (`ishou_tokens::FleetStateVar`), which seki's prompt reads from the
1411    /// same source. Pinning the variant→name mapping here makes a rename
1412    /// on the producer side a compile+test failure on the single source of
1413    /// truth, so it can never silently drift from the consumer.
1414    #[test]
1415    fn pane_env_var_names_come_from_fleet_state_contract() {
1416        use ishou_tokens::FleetStateVar;
1417        assert_eq!(FleetStateVar::TearSessionId.name(), "TEAR_SESSION_ID");
1418        assert_eq!(FleetStateVar::TearSessionName.name(), "TEAR_SESSION_NAME");
1419        assert_eq!(FleetStateVar::TearPaneId.name(), "TEAR_PANE_ID");
1420        assert_eq!(FleetStateVar::TearSocket.name(), "TEAR_SOCKET");
1421    }
1422
1423    #[test]
1424    fn pty_env_path_includes_nix_profile_dirs() {
1425        // Reproduces the production bug where the launchd-spawned
1426        // tear-daemon inherited PATH = "/usr/bin:/bin:/usr/sbin:
1427        // /sbin" — every shell tear spawned then couldn't find
1428        // `tear` (or any home-manager binary), and starship's
1429        // [custom.tear] prompt block hung trying to invoke it.
1430        // The fix prepends /etc/profiles/per-user/$USER/bin +
1431        // ~/.nix-profile/bin + /run/current-system/sw/bin so
1432        // home-manager binaries resolve.
1433        let inproc = Arc::new(InProcess::new());
1434        let sid = inproc.new_session("path-test", "/bin/sh").unwrap();
1435        let pane = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1436
1437        let (tx, rx) = mpsc::channel::<Vec<u8>>();
1438        inproc.subscribers.lock().entry(pane).or_default().senders.push(tx);
1439
1440        inproc.send_keys(pane, b"printf 'PATH=[%s]\\n' \"$PATH\"\n").expect("send_keys");
1441
1442        let deadline = std::time::Instant::now() + CHILD_OUTPUT_TIMEOUT;
1443        let mut buf = Vec::<u8>::new();
1444        while std::time::Instant::now() < deadline {
1445            if let Ok(chunk) = rx.recv_timeout(std::time::Duration::from_millis(100)) {
1446                buf.extend_from_slice(&chunk);
1447                if let Ok(s) = std::str::from_utf8(&buf) {
1448                    if s.contains("PATH=[") && s.contains("]\n") { break; }
1449                }
1450            }
1451        }
1452        let text = String::from_utf8_lossy(&buf);
1453        assert!(text.contains("PATH=["), "no PATH output: {text:?}");
1454        assert!(
1455            text.contains("/etc/profiles/per-user/")
1456                || text.contains("/.nix-profile/bin")
1457                || text.contains("/run/current-system/sw/bin"),
1458            "PATH missing Nix profile dirs — home-manager binaries (tear, starship, etc.) wouldn't resolve: {text:?}"
1459        );
1460    }
1461
1462    #[test]
1463    fn pty_env_provides_term_default_when_parent_lacks_it() {
1464        // Reproduces the production bug where launchd-spawned
1465        // tear-daemons had no TERM in their env, so every shell
1466        // they spawned reported "TERM environment variable not
1467        // set" and `clear`/arrow keys broke. We can't perfectly
1468        // simulate the launchd-clean env in-process, but we can
1469        // assert TERM is always non-empty in the spawned child.
1470        let inproc = Arc::new(InProcess::new());
1471        let sid = inproc.new_session("term-test", "/bin/sh").unwrap();
1472        let pane = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1473
1474        let (tx, rx) = mpsc::channel::<Vec<u8>>();
1475        inproc.subscribers.lock().entry(pane).or_default().senders.push(tx);
1476
1477        inproc
1478            .send_keys(pane, b"printf 'TERM=[%s]\\n' \"${TERM:-MISSING}\"\n")
1479            .expect("send_keys");
1480
1481        let deadline = std::time::Instant::now() + CHILD_OUTPUT_TIMEOUT;
1482        let mut buf = Vec::<u8>::new();
1483        while std::time::Instant::now() < deadline {
1484            if let Ok(chunk) = rx.recv_timeout(std::time::Duration::from_millis(100)) {
1485                buf.extend_from_slice(&chunk);
1486                if let Ok(s) = std::str::from_utf8(&buf) {
1487                    if s.contains("TERM=[") && s.contains("]\n") {
1488                        break;
1489                    }
1490                }
1491            }
1492        }
1493        let text = String::from_utf8_lossy(&buf);
1494        assert!(text.contains("TERM=["), "no TERM output: {text:?}");
1495        assert!(
1496            !text.contains("TERM=[MISSING]"),
1497            "TERM unset in child shell — terminfo would fail: {text:?}"
1498        );
1499    }
1500
1501    #[test]
1502    fn pty_env_includes_tear_session_pane_socket_vars() {
1503        // Spawn a fresh shell that prints the TEAR_* env vars + a
1504        // sentinel string, then subscribe to its bytes and assert
1505        // we see the sentinel + the env values land. Proves the
1506        // spawn_pty_for env injection works end-to-end (the
1507        // shell's child PROCESS observes them).
1508        let inproc = Arc::new(InProcess::new());
1509        inproc.set_socket_path(std::path::PathBuf::from("/tmp/tear-test-env.sock"));
1510
1511        let sid = inproc
1512            .new_session("env-test", "/bin/sh")
1513            .expect("new_session");
1514        let pane = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1515
1516        let (tx, rx) = mpsc::channel::<Vec<u8>>();
1517        inproc.subscribers.lock().entry(pane).or_default().senders.push(tx);
1518
1519        inproc
1520            .send_keys(
1521                pane,
1522                b"printf 'SENTINEL[T=%s][S=%s][P=%s][SOCK=%s]\\n' \"${TEAR}\" \"${TEAR_SESSION_NAME}\" \"${TEAR_PANE_ID}\" \"${TEAR_SOCKET}\"\n",
1523            )
1524            .expect("send_keys");
1525
1526        // Collect output for up to 2 seconds.
1527        let deadline = std::time::Instant::now() + CHILD_OUTPUT_TIMEOUT;
1528        let mut buf = Vec::<u8>::new();
1529        while std::time::Instant::now() < deadline {
1530            if let Ok(chunk) = rx.recv_timeout(std::time::Duration::from_millis(100)) {
1531                buf.extend_from_slice(&chunk);
1532                if let Ok(s) = std::str::from_utf8(&buf) {
1533                    if s.contains("SENTINEL[") && s.contains(']') {
1534                        // Wait briefly for the rest of the line to arrive.
1535                        std::thread::sleep(std::time::Duration::from_millis(50));
1536                        while let Ok(more) = rx.try_recv() {
1537                            buf.extend_from_slice(&more);
1538                        }
1539                        break;
1540                    }
1541                }
1542            }
1543        }
1544        let text = String::from_utf8_lossy(&buf);
1545        assert!(text.contains("SENTINEL["), "no sentinel in output: {text:?}");
1546        assert!(text.contains("T=1"), "TEAR=1 not present: {text:?}");
1547        assert!(text.contains("S=env-test"), "TEAR_SESSION_NAME wrong: {text:?}");
1548        assert!(text.contains("SOCK=/tmp/tear-test-env.sock"), "TEAR_SOCKET wrong: {text:?}");
1549    }
1550
1551    /// **`SpawnEnv` override reaches the child + wins over the
1552    /// fallback** (operator report 2026-06-12: vim grey + wrong font in
1553    /// the embedded-tear window came from the embedded path stamping only
1554    /// xterm-256color). An embedder (mado) sets a `SpawnEnv` whose
1555    /// `TERM` override + `COLORTERM` must land on the child's env,
1556    /// overriding the conservative fallback `spawn_pty_for` would
1557    /// otherwise stamp. PTY-gated (openpty); passes in isolation.
1558    #[test]
1559    fn spawn_env_override_reaches_child_and_wins_over_fallback() {
1560        let inproc = Arc::new(InProcess::new());
1561        // The embedder's capability projection: a richer TERM than the
1562        // xterm-256color fallback + the truecolor signal vim needs.
1563        inproc.set_spawn_env(tear_types::SpawnEnv::from_overrides(vec![
1564            ("TERM".into(), "xterm-ghostty".into()),
1565            ("COLORTERM".into(), "truecolor".into()),
1566        ]));
1567        let sid = inproc.new_session("spawnenv-test", "/bin/sh").unwrap();
1568        let pane = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1569
1570        let (tx, rx) = mpsc::channel::<Vec<u8>>();
1571        inproc.subscribers.lock().entry(pane).or_default().senders.push(tx);
1572        inproc
1573            .send_keys(
1574                pane,
1575                b"printf 'SENV[T=%s][C=%s]\\n' \"${TERM}\" \"${COLORTERM}\"\n",
1576            )
1577            .expect("send_keys");
1578
1579        let deadline = std::time::Instant::now() + CHILD_OUTPUT_TIMEOUT;
1580        let mut buf = Vec::<u8>::new();
1581        while std::time::Instant::now() < deadline {
1582            if let Ok(chunk) = rx.recv_timeout(std::time::Duration::from_millis(100)) {
1583                buf.extend_from_slice(&chunk);
1584                if let Ok(s) = std::str::from_utf8(&buf) {
1585                    if s.contains("SENV[") && s.contains(']') {
1586                        std::thread::sleep(std::time::Duration::from_millis(50));
1587                        while let Ok(more) = rx.try_recv() {
1588                            buf.extend_from_slice(&more);
1589                        }
1590                        break;
1591                    }
1592                }
1593            }
1594        }
1595        let text = String::from_utf8_lossy(&buf);
1596        assert!(text.contains("SENV["), "no sentinel in output: {text:?}");
1597        assert!(
1598            text.contains("T=xterm-ghostty"),
1599            "embedder TERM override did not reach the child (fallback won): {text:?}"
1600        );
1601        assert!(
1602            text.contains("C=truecolor"),
1603            "embedder COLORTERM override did not reach the child: {text:?}"
1604        );
1605    }
1606
1607    #[test]
1608    fn get_nonexistent_session_errors() {
1609        let inproc = InProcess::new();
1610        let err = inproc.get_session(SessionId(99)).unwrap_err();
1611        assert!(matches!(err, ControlError::NoSuchSession(_)));
1612    }
1613
1614    #[test]
1615    fn subscribe_pane_bytes_on_nonexistent_pane_returns_nosuch() {
1616        let inproc = InProcess::new();
1617        let pane = PaneId::from_seed("phantom");
1618        let err = inproc.subscribe_pane_bytes(pane).unwrap_err();
1619        assert!(matches!(err, ControlError::NoSuchPane(p) if p == pane));
1620    }
1621
1622    #[test]
1623    fn child_exit_disconnects_subscribers_and_marks_pane_exited() {
1624        // Regression (mado embedded-tear "typing `exit` does nothing"):
1625        // when the shell exits, the per-pane byte channel MUST
1626        // disconnect so a single-pane GUI learns the child is gone and
1627        // can close its window. Before the fix the PTY reader thread
1628        // just ended on EOF, leaving every engate/daemon Receiver
1629        // blocked forever on a phantom-Running pane whose senders were
1630        // never dropped.
1631        let inproc = Arc::new(InProcess::new());
1632        let sid = inproc
1633            .new_session("exit-test", "/bin/sh")
1634            .expect("new_session");
1635        let pane = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1636
1637        // Subscribe while the pane is alive (mirrors mado's attach).
1638        let rx = inproc.subscribe_pane_bytes(pane).expect("subscribe");
1639
1640        // Drive the shell to exit with a specific code.
1641        inproc.send_keys(pane, b"exit 7\n").expect("send_keys");
1642
1643        // The receiver must eventually disconnect — that Err is the
1644        // end-of-stream signal engate's run() / the daemon's
1645        // serve_subscription block on.
1646        let deadline = std::time::Instant::now() + CHILD_OUTPUT_TIMEOUT;
1647        let mut disconnected = false;
1648        while std::time::Instant::now() < deadline {
1649            match rx.recv_timeout(std::time::Duration::from_millis(100)) {
1650                Ok(_) => continue, // drain echoed input / shell output
1651                Err(mpsc::RecvTimeoutError::Timeout) => continue,
1652                Err(mpsc::RecvTimeoutError::Disconnected) => {
1653                    disconnected = true;
1654                    break;
1655                }
1656            }
1657        }
1658        assert!(
1659            disconnected,
1660            "subscriber channel never disconnected after `exit` — a single-pane GUI would hang open"
1661        );
1662
1663        // The pane must be modeled as Exited (remain-on-exit) with the
1664        // child's real exit code propagated.
1665        let session = inproc.get_session(sid).unwrap();
1666        let state = session.panes.get(&pane).map(|p| p.state);
1667        assert_eq!(
1668            state,
1669            Some(tear_types::PaneState::Exited { code: 7 }),
1670            "pane should be Exited{{ code: 7 }}, got {state:?}"
1671        );
1672
1673        // A subscribe AFTER exit must return an already-disconnected
1674        // receiver, never a live registration that would block forever.
1675        let rx2 = inproc
1676            .subscribe_pane_bytes(pane)
1677            .expect("subscribe after exit still resolves (remain-on-exit pane)");
1678        assert!(
1679            matches!(rx2.recv(), Err(mpsc::RecvError)),
1680            "post-exit subscribe must yield an immediately-disconnected receiver"
1681        );
1682    }
1683
1684    #[test]
1685    fn fully_exited_unwatched_session_is_reaped() {
1686        // Session-leak fix (2026-07-06): a session whose shell exits
1687        // with NO subscriber ever attached (agent spawn-and-walk-away,
1688        // one-shot command) must leave the registry entirely — before
1689        // the fix it lingered forever as a ghost row in mado's Ctrl-S
1690        // picker.
1691        let inproc = Arc::new(InProcess::new());
1692        let sid = inproc
1693            .new_session("reap-test", "/bin/sh")
1694            .expect("new_session");
1695        let pane = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1696
1697        // No subscriber attaches. Exit the shell.
1698        inproc.send_keys(pane, b"exit\n").expect("send_keys");
1699
1700        let deadline = std::time::Instant::now() + CHILD_OUTPUT_TIMEOUT;
1701        while std::time::Instant::now() < deadline {
1702            if inproc.list_sessions().unwrap().is_empty() {
1703                break;
1704            }
1705            std::thread::sleep(std::time::Duration::from_millis(50));
1706        }
1707        assert!(
1708            inproc.list_sessions().unwrap().is_empty(),
1709            "fully-exited unwatched session was not reaped from the registry"
1710        );
1711        // Runtime artifacts must be gone too — a reaped session that
1712        // strands a PtyHandle/grid/subscriber entry is a slow leak.
1713        assert!(inproc.ptys.lock().is_empty(), "reap left a PtyHandle behind");
1714        assert!(inproc.grids.lock().is_empty(), "reap left a grid behind");
1715        assert!(
1716            inproc.subscribers.lock().is_empty(),
1717            "reap left a subscriber entry behind"
1718        );
1719    }
1720
1721    #[test]
1722    fn watched_session_survives_exit_with_remain_on_exit() {
1723        // The conservative half of the reap: a session SOMEONE was
1724        // watching (live sender at exit time — a mado window, a
1725        // recorder) keeps tmux remain-on-exit semantics: pane marked
1726        // Exited, final grid inspectable, session still listed.
1727        let inproc = Arc::new(InProcess::new());
1728        let sid = inproc
1729            .new_session("remain-test", "/bin/sh")
1730            .expect("new_session");
1731        let pane = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1732
1733        let rx = inproc.subscribe_pane_bytes(pane).expect("subscribe");
1734        inproc.send_keys(pane, b"exit\n").expect("send_keys");
1735
1736        // Wait for the end-of-stream disconnect (fires after the exit
1737        // handler ran its reap decision with `watched = true`).
1738        let deadline = std::time::Instant::now() + CHILD_OUTPUT_TIMEOUT;
1739        let mut disconnected = false;
1740        while std::time::Instant::now() < deadline {
1741            match rx.recv_timeout(std::time::Duration::from_millis(100)) {
1742                Ok(_) => continue,
1743                Err(mpsc::RecvTimeoutError::Timeout) => continue,
1744                Err(mpsc::RecvTimeoutError::Disconnected) => {
1745                    disconnected = true;
1746                    break;
1747                }
1748            }
1749        }
1750        assert!(disconnected, "subscriber never disconnected after exit");
1751
1752        let sessions = inproc.list_sessions().unwrap();
1753        assert_eq!(
1754            sessions.len(),
1755            1,
1756            "watched session must remain-on-exit, not be reaped"
1757        );
1758        let state = sessions[0].panes.get(&pane).map(|p| p.state);
1759        assert!(
1760            matches!(state, Some(tear_types::PaneState::Exited { .. })),
1761            "pane should be Exited, got {state:?}"
1762        );
1763    }
1764
1765    #[test]
1766    fn pane_snapshot_on_nonexistent_pane_returns_nosuch() {
1767        let inproc = InProcess::new();
1768        let pane = PaneId::from_seed("phantom");
1769        let err = inproc.pane_snapshot(pane).unwrap_err();
1770        assert!(matches!(err, ControlError::NoSuchPane(p) if p == pane));
1771    }
1772
1773    #[test]
1774    fn pane_resize_absolute_on_nonexistent_pane_returns_nosuch() {
1775        let inproc = InProcess::new();
1776        let pane = PaneId::from_seed("phantom");
1777        let err = inproc.pane_resize_absolute(pane, 80, 24).unwrap_err();
1778        assert!(matches!(err, ControlError::NoSuchPane(p) if p == pane));
1779    }
1780
1781    #[test]
1782    fn rename_session_to_same_name_is_idempotent() {
1783        let inproc = InProcess::new();
1784        let sid = inproc.new_session("work", "/bin/sh").unwrap();
1785        // Rename to current name should succeed without error.
1786        inproc.rename_session(sid, "work").unwrap();
1787        let s = inproc.get_session(sid).unwrap();
1788        assert_eq!(s.name, "work");
1789        // Now rename to a different name.
1790        inproc.rename_session(sid, "play").unwrap();
1791        let s2 = inproc.get_session(sid).unwrap();
1792        assert_eq!(s2.name, "play");
1793    }
1794
1795    #[test]
1796    fn kill_session_with_active_subscriber_returns_promptly() {
1797        // Regression (mado L1 teardown wedge, 2026-06-10): kill_session
1798        // used to drop PtyHandles INSIDE the ptys/grids/subscribers
1799        // lock scope; PtyHandle::Drop then block-waited on the child
1800        // while the pane's reader thread sat blocked acquiring the
1801        // same subscribers lock from on_bytes — mutual wait, observed
1802        // as a 20+ minute wedge. Post-fix the handles leave the maps
1803        // under the locks but die outside them, and the reap itself is
1804        // bounded (pty::reap_with_deadline).
1805        //
1806        // /bin/cat echoes everything, so a feeder thread keeps the
1807        // reader thread hot (contending the subscribers lock) while
1808        // another thread kills the session. The watchdog channel turns
1809        // a re-introduced deadlock into a <5s test failure instead of
1810        // a hung suite.
1811        let inproc = Arc::new(InProcess::new());
1812        let sid = inproc
1813            .new_session("kill-fast", "/bin/cat")
1814            .expect("new_session");
1815        let pane = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1816
1817        // Active subscriber (mirrors mado's attach_live).
1818        let rx = inproc.subscribe_pane_bytes(pane).expect("subscribe");
1819
1820        // Feeder — keeps bytes flowing through on_bytes so the reader
1821        // thread is actively taking the subscribers lock during kill.
1822        let feeder_inproc = Arc::clone(&inproc);
1823        let feeding = Arc::new(std::sync::atomic::AtomicBool::new(true));
1824        let feeding_for_thread = Arc::clone(&feeding);
1825        let feeder = std::thread::spawn(move || {
1826            let chunk = vec![b'x'; 4096];
1827            while feeding_for_thread.load(std::sync::atomic::Ordering::Relaxed) {
1828                if feeder_inproc.send_keys(pane, &chunk).is_err() {
1829                    break; // pane gone — the kill landed
1830                }
1831            }
1832        });
1833        // Drain one echo so we know the reader thread is live.
1834        let _ = rx.recv_timeout(std::time::Duration::from_secs(2));
1835
1836        // kill_session on a helper thread + watchdog recv.
1837        let killer_inproc = Arc::clone(&inproc);
1838        let (done_tx, done_rx) = mpsc::channel();
1839        std::thread::spawn(move || {
1840            let started = std::time::Instant::now();
1841            let result = killer_inproc.kill_session(sid);
1842            let _ = done_tx.send((result, started.elapsed()));
1843        });
1844        let (result, elapsed) = done_rx
1845            .recv_timeout(std::time::Duration::from_secs(5))
1846            .expect("kill_session deadlocked — did not return within 5s");
1847        feeding.store(false, std::sync::atomic::Ordering::Relaxed);
1848        result.expect("kill_session errored");
1849        assert!(
1850            elapsed < std::time::Duration::from_secs(5),
1851            "kill_session took {elapsed:?} with an active subscriber"
1852        );
1853        let _ = feeder.join();
1854
1855        // The subscriber must observe end-of-stream (senders dropped by
1856        // detach_panes), never block forever on a dead pane.
1857        let deadline = std::time::Instant::now() + CHILD_OUTPUT_TIMEOUT;
1858        let mut disconnected = false;
1859        while std::time::Instant::now() < deadline {
1860            match rx.recv_timeout(std::time::Duration::from_millis(100)) {
1861                Ok(_) => continue, // drain buffered echo
1862                Err(mpsc::RecvTimeoutError::Timeout) => continue,
1863                Err(mpsc::RecvTimeoutError::Disconnected) => {
1864                    disconnected = true;
1865                    break;
1866                }
1867            }
1868        }
1869        assert!(
1870            disconnected,
1871            "subscriber channel never disconnected after kill_session"
1872        );
1873    }
1874
1875    #[test]
1876    fn new_session_then_kill_then_subscribe_returns_nosuch() {
1877        // The kill-session path must prune subscribers + grids + ptys
1878        // so a later subscribe to the dead pane errors cleanly.
1879        let inproc = InProcess::new();
1880        let sid = inproc.new_session("temp", "/bin/sh").unwrap();
1881        let pane = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1882        inproc.kill_session(sid).unwrap();
1883        let err = inproc.subscribe_pane_bytes(pane).unwrap_err();
1884        assert!(matches!(err, ControlError::NoSuchPane(_)));
1885    }
1886
1887    #[test]
1888    fn kill_session_prunes_the_recording_buffer() {
1889        // The `recordings` map had NO `.remove()` anywhere in the crate, so
1890        // every recorded pane kept its whole captured byte buffer for the
1891        // DAEMON'S LIFETIME — invisible to `tear list`, `pane_stats` and
1892        // `daemon_status`'s pane counts, which all read other maps. A few
1893        // recorded build logs is routinely hundreds of MB.
1894        //
1895        // Sibling of `new_session_then_kill_then_subscribe_returns_nosuch`,
1896        // which pins the same teardown contract for subscribers + grids +
1897        // ptys. Recordings were simply missing from that list.
1898        let inproc = InProcess::new();
1899        let sid = inproc.new_session("rec-leak", "/bin/sh").unwrap();
1900        let pane = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1901        inproc.enable_pane_recording(pane).unwrap();
1902        assert_eq!(
1903            inproc.recordings.lock().len(),
1904            1,
1905            "precondition: the recording must actually be registered, or this \
1906             test would pass vacuously against an empty map"
1907        );
1908        inproc.kill_session(sid).unwrap();
1909        assert!(
1910            inproc.recordings.lock().is_empty(),
1911            "kill_session must drop the pane's recording buffer; leaking it \
1912             retains the captured bytes until the daemon exits"
1913        );
1914    }
1915
1916    // ── #2 input policy ────────────────────────────────────────
1917
1918    #[test]
1919    fn send_keys_rejected_when_pane_locked() {
1920        let inproc = InProcess::new();
1921        let sid = inproc.new_session("policy", "/bin/sh").unwrap();
1922        let session = inproc.get_session(sid).unwrap();
1923        let pane_id = *session.panes.keys().next().unwrap();
1924
1925        // Lock it.
1926        inproc
1927            .set_input_policy(pane_id, tear_types::InputPolicy::Locked)
1928            .unwrap();
1929        // send_keys must error with Rejected.
1930        let err = inproc.send_keys(pane_id, b"x").unwrap_err();
1931        assert!(
1932            matches!(err, tear_types::ControlError::Rejected(_)),
1933            "expected Rejected, got {err:?}"
1934        );
1935
1936        // Unlock — send_keys works again.
1937        inproc
1938            .set_input_policy(pane_id, tear_types::InputPolicy::Free)
1939            .unwrap();
1940        inproc.send_keys(pane_id, b"y").unwrap();
1941    }
1942
1943    #[test]
1944    fn set_input_policy_on_nonexistent_pane_returns_nosuch() {
1945        let inproc = InProcess::new();
1946        let err = inproc
1947            .set_input_policy(tear_types::PaneId(0xdead_beef), tear_types::InputPolicy::Locked)
1948            .unwrap_err();
1949        assert!(matches!(err, tear_types::ControlError::NoSuchPane(_)));
1950    }
1951
1952    #[test]
1953    fn set_input_policy_is_idempotent() {
1954        let inproc = InProcess::new();
1955        let sid = inproc.new_session("idem", "/bin/sh").unwrap();
1956        let pane_id = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1957        inproc.set_input_policy(pane_id, tear_types::InputPolicy::Locked).unwrap();
1958        inproc.set_input_policy(pane_id, tear_types::InputPolicy::Locked).unwrap();
1959        inproc.set_input_policy(pane_id, tear_types::InputPolicy::Free).unwrap();
1960        inproc.set_input_policy(pane_id, tear_types::InputPolicy::Free).unwrap();
1961        // No assertion needed — the test is that none of these panic
1962        // or return Err on duplicate state.
1963    }
1964
1965    #[test]
1966    fn send_keys_treats_leader_as_free_at_inproc_layer() {
1967        // In-process consumers have no per-client identity at the
1968        // trait surface, so Leader collapses to Free here — the
1969        // daemon adds the identity-gating layer on top via
1970        // serve_connection_with_auth. This test pins the semantic
1971        // so future refactors don't accidentally start rejecting.
1972        let inproc = InProcess::new();
1973        let sid = inproc.new_session("leader-inproc", "/bin/sh").unwrap();
1974        let pane_id = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1975        inproc
1976            .set_input_policy(pane_id, tear_types::InputPolicy::leader(7))
1977            .unwrap();
1978        // No error — InProcess::send_keys does not enforce Leader.
1979        inproc.send_keys(pane_id, b"x").unwrap();
1980    }
1981
1982    #[test]
1983    fn send_keys_unaffected_when_policy_remains_free() {
1984        // Default policy is Free; send_keys should accept right away
1985        // without the operator touching the policy. Smoke-checks the
1986        // policy gate's no-op path.
1987        let inproc = InProcess::new();
1988        let sid = inproc.new_session("free-default", "/bin/sh").unwrap();
1989        let pane_id = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1990        inproc.send_keys(pane_id, b"hello").unwrap();
1991    }
1992
1993    #[test]
1994    fn send_keys_after_unlock_round_trip() {
1995        // Locked → Free → Locked → Free. Each Free interval must
1996        // accept input.
1997        let inproc = InProcess::new();
1998        let sid = inproc.new_session("rt", "/bin/sh").unwrap();
1999        let pane_id = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
2000        for round in 0..2 {
2001            inproc
2002                .set_input_policy(pane_id, tear_types::InputPolicy::Locked)
2003                .unwrap();
2004            assert!(
2005                inproc.send_keys(pane_id, b"x").is_err(),
2006                "round {round}: Locked accepted send_keys"
2007            );
2008            inproc
2009                .set_input_policy(pane_id, tear_types::InputPolicy::Free)
2010                .unwrap();
2011            inproc
2012                .send_keys(pane_id, b"y")
2013                .unwrap_or_else(|e| panic!("round {round}: Free rejected send_keys: {e:?}"));
2014        }
2015    }
2016
2017    // ── Pane-as-block (OSC 133) ────────────────────────────
2018
2019    #[test]
2020    fn pane_blocks_captures_osc_133_round_trip() {
2021        let inproc = InProcess::new();
2022        let sid = inproc.new_session("blocks-test", "/bin/sh").unwrap();
2023        let pane_id = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
2024
2025        // Drive a full OSC 133 cycle through the PTY by sending
2026        // the bytes via send_keys. The shell's echo back loops
2027        // them through PaneGrid → block extractor.
2028        //
2029        // We use raw escape bytes: ESC ] 133 ; X BEL
2030        //
2031        // bash + readline echo the bytes back to the PTY only on
2032        // INPUT, not on output. The cleanest test path: write
2033        // the OSC 133 sequence directly into the pty's slave
2034        // side via send_keys, then drain.
2035        //
2036        // For the unit test we bypass the PTY shell and feed the
2037        // grid directly via a synthetic call. The block
2038        // extractor is fully covered by tear-core/src/blocks.rs
2039        // unit tests; here we verify the wiring through
2040        // pane_blocks_list returns what we'd expect when blocks
2041        // exist.
2042        let grid_arc = {
2043            let map = inproc.grids.lock();
2044            map.get(&pane_id).cloned().unwrap()
2045        };
2046        {
2047            let mut grid = grid_arc.lock();
2048            grid.feed(b"\x1b]133;A\x07");
2049            grid.feed(b"$ ");
2050            grid.feed(b"\x1b]133;B\x07");
2051            grid.feed(b"echo hi");
2052            grid.feed(b"\x1b]133;C\x07");
2053            grid.feed(b"hi\r\n");
2054            grid.feed(b"\x1b]133;D;0\x07");
2055        }
2056
2057        let blocks = inproc.pane_blocks_list(pane_id, 0, 10).unwrap();
2058        assert_eq!(blocks.len(), 1);
2059        let b = &blocks[0];
2060        assert_eq!(b.prompt, "$ ");
2061        assert_eq!(b.command, "echo hi");
2062        assert!(b.output.contains("hi"));
2063        assert_eq!(b.exit_code, Some(0));
2064
2065        let (total, in_progress) = inproc.pane_blocks_status(pane_id).unwrap();
2066        assert_eq!(total, 1);
2067        assert!(!in_progress);
2068
2069        let one = inproc.pane_block_at(pane_id, 0).unwrap();
2070        assert_eq!(one.index, 0);
2071    }
2072
2073    /// END-TO-END attribution: a session an AGENT spawned produces blocks
2074    /// that say so, all the way out through the same `pane_blocks_list`
2075    /// an operator or an MCP client reads.
2076    ///
2077    /// This is the test that matters. `blocks.rs` already proves the
2078    /// extractor stamps what it is told; this proves the daemon actually
2079    /// TELLS it — the wiring, not the mechanism. Without this, a
2080    /// `stamp_yurai` nobody calls would keep every unit test green while
2081    /// every real block read `Unknown`, which is precisely the
2082    /// declared-but-unwired failure this repo has been bitten by before.
2083    #[test]
2084    fn an_agent_spawned_session_produces_attributed_blocks() {
2085        let inproc = InProcess::new();
2086        let sid = inproc
2087            .new_session_yurai(
2088                "attributed",
2089                "/bin/sh",
2090                &[],
2091                tear_types::SessionSource::Agent,
2092                (80, 24),
2093                tear_types::Yurai::Automation {
2094                    label: Some("claude-code".into()),
2095                },
2096            )
2097            .unwrap();
2098        let pane_id = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
2099
2100        let grid_arc = {
2101            let map = inproc.grids.lock();
2102            map.get(&pane_id).cloned().unwrap()
2103        };
2104        {
2105            let mut grid = grid_arc.lock();
2106            grid.feed(b"\x1b]133;A\x07$ \x1b]133;B\x07rm -rf /tmp/x\x1b]133;C\x07\x1b]133;D;0\x07");
2107        }
2108
2109        let blocks = inproc.pane_blocks_list(pane_id, 0, 10).unwrap();
2110        assert_eq!(blocks.len(), 1);
2111        assert_eq!(
2112            blocks[0].yurai,
2113            tear_types::Yurai::Automation {
2114                label: Some("claude-code".into())
2115            },
2116            "an agent-spawned pane's blocks must carry the agent's label — \
2117             without it this command is indistinguishable from the operator's"
2118        );
2119    }
2120
2121    /// The companion honesty case: a session spawned with no declared
2122    /// provenance stays `Unknown`, never silently upgraded to `Human`.
2123    #[test]
2124    fn an_undeclared_session_produces_unknown_blocks_not_human() {
2125        let inproc = InProcess::new();
2126        let sid = inproc.new_session("undeclared", "/bin/sh").unwrap();
2127        let pane_id = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
2128        let grid_arc = {
2129            let map = inproc.grids.lock();
2130            map.get(&pane_id).cloned().unwrap()
2131        };
2132        {
2133            let mut grid = grid_arc.lock();
2134            grid.feed(b"\x1b]133;A\x07$ \x1b]133;B\x07ls\x1b]133;C\x07\x1b]133;D;0\x07");
2135        }
2136        let blocks = inproc.pane_blocks_list(pane_id, 0, 10).unwrap();
2137        assert_eq!(blocks.len(), 1);
2138        assert_eq!(
2139            blocks[0].yurai,
2140            tear_types::Yurai::Unknown,
2141            "unknown must stay unknown — a silent upgrade to Human would \
2142             launder exactly the commands worth attributing"
2143        );
2144    }
2145
2146    #[test]
2147    fn pane_block_at_on_missing_index_returns_rejected() {
2148        let inproc = InProcess::new();
2149        let sid = inproc.new_session("missing-block", "/bin/sh").unwrap();
2150        let pane_id = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
2151        let err = inproc.pane_block_at(pane_id, 99).unwrap_err();
2152        assert!(matches!(err, ControlError::Rejected(_)));
2153    }
2154
2155    #[test]
2156    fn pane_blocks_on_nonexistent_pane_returns_nosuch() {
2157        let inproc = InProcess::new();
2158        let err = inproc
2159            .pane_blocks_list(PaneId(0xdead_beef), 0, 10)
2160            .unwrap_err();
2161        assert!(matches!(err, ControlError::NoSuchPane(_)));
2162    }
2163
2164    #[test]
2165    fn list_sessions_returns_all_created_sessions() {
2166        let inproc = InProcess::new();
2167        let a = inproc.new_session("alpha", "/bin/sh").unwrap();
2168        let b = inproc.new_session("beta", "/bin/sh").unwrap();
2169        let c = inproc.new_session("gamma", "/bin/sh").unwrap();
2170        let sessions = inproc.list_sessions().unwrap();
2171        assert_eq!(sessions.len(), 3);
2172        let mut names: Vec<&str> = sessions.iter().map(|s| s.name.as_str()).collect();
2173        names.sort();
2174        assert_eq!(names, vec!["alpha", "beta", "gamma"]);
2175        let ids: std::collections::HashSet<_> =
2176            sessions.iter().map(|s| s.id).collect();
2177        for id in [a, b, c] {
2178            assert!(ids.contains(&id));
2179        }
2180        // Note: sessions_in_order sorts by created_at_unix (second
2181        // precision); same-second creates are ordered by BTreeMap key
2182        // (BLAKE3-derived SessionId). Tests asserting strict
2183        // insertion order would be flaky.
2184    }
2185
2186    /// The active pane of a session's active window — the usual split
2187    /// origin.
2188    fn active_pane(inproc: &InProcess, sid: SessionId) -> PaneId {
2189        let s = inproc.get_session(sid).unwrap();
2190        s.windows[&s.active_window].active_pane
2191    }
2192
2193    #[test]
2194    fn split_pane_targets_the_origin_leaf_and_validates() {
2195        let inproc = InProcess::new();
2196        let sid = inproc.new_session("split", "/bin/sh").unwrap();
2197        let origin = active_pane(&inproc, sid);
2198        let new = inproc.split_pane(origin, Direction::Right, "/bin/sh", &[]).unwrap();
2199
2200        let s = inproc.get_session(sid).unwrap();
2201        let w = &s.windows[&s.active_window];
2202        // The tree now holds exactly two leaves, origin then new (Right
2203        // puts the new pane after the origin).
2204        assert_eq!(w.layout.pane_count(), 2);
2205        assert_eq!(w.layout.panes(), vec![origin, new]);
2206        assert_eq!(w.active_pane, new);
2207        assert!(s.panes.contains_key(&origin) && s.panes.contains_key(&new));
2208        // Structurally sound — no NULL leaf, no aliasing, ratio in range.
2209        w.layout.validate().unwrap();
2210    }
2211
2212    #[test]
2213    fn kill_pane_collapses_tree_without_dangling_leaf() {
2214        let inproc = InProcess::new();
2215        let sid = inproc.new_session("kill", "/bin/sh").unwrap();
2216        let origin = active_pane(&inproc, sid);
2217        let new = inproc.split_pane(origin, Direction::Below, "/bin/sh", &[]).unwrap();
2218
2219        inproc.kill_pane(new).unwrap();
2220        let s = inproc.get_session(sid).unwrap();
2221        let w = &s.windows[&s.active_window];
2222        // The split collapsed back into the surviving origin leaf — no
2223        // PaneId::NULL hole, validate() proves it.
2224        assert_eq!(w.layout, tear_types::LayoutNode::leaf(origin));
2225        assert_eq!(w.active_pane, origin);
2226        assert!(!s.panes.contains_key(&new));
2227        w.layout.validate().unwrap();
2228    }
2229
2230    #[test]
2231    fn killing_active_windows_last_pane_retargets_active_window() {
2232        // Regression: kill_pane's WasRoot arm removed the window but left
2233        // session.active_window pointing at the removed id, panicking the
2234        // s.windows[&active_window] index sites. A second window must
2235        // inherit focus when the active window's last pane is killed.
2236        let inproc = InProcess::new();
2237        let sid = inproc.new_session("multi", "/bin/sh").unwrap();
2238        let w1 = inproc.get_session(sid).unwrap().active_window;
2239        // A second window — and make it the active one we then kill.
2240        let w2 = inproc.new_window(sid, "second", "/bin/sh", &[]).unwrap();
2241        inproc.select_window(w2).unwrap();
2242        let only_pane_w2 = {
2243            let s = inproc.get_session(sid).unwrap();
2244            s.windows[&w2].active_pane
2245        };
2246        inproc.kill_pane(only_pane_w2).unwrap();
2247        let s = inproc.get_session(sid).unwrap();
2248        // w2 gone, focus fell back to the surviving window — and crucially
2249        // active_window still indexes a live window (no panic).
2250        assert!(!s.windows.contains_key(&w2));
2251        assert_eq!(s.active_window, w1);
2252        let _ = &s.windows[&s.active_window]; // must not panic
2253    }
2254
2255    #[test]
2256    fn kill_last_pane_closes_the_window() {
2257        let inproc = InProcess::new();
2258        let sid = inproc.new_session("solo", "/bin/sh").unwrap();
2259        let only = active_pane(&inproc, sid);
2260        // Killing the window's only pane closes the window (tmux
2261        // semantics) — nothing the layout tree can represent remains.
2262        inproc.kill_pane(only).unwrap();
2263        let s = inproc.get_session(sid).unwrap();
2264        assert!(s.windows.is_empty());
2265        assert!(!s.panes.contains_key(&only));
2266    }
2267
2268    #[test]
2269    fn resize_pane_shifts_the_governing_divider() {
2270        let inproc = InProcess::new();
2271        let sid = inproc.new_session("resize", "/bin/sh").unwrap();
2272        let origin = active_pane(&inproc, sid);
2273        let _new = inproc.split_pane(origin, Direction::Right, "/bin/sh", &[]).unwrap();
2274
2275        let w_before = {
2276            let s = inproc.get_session(sid).unwrap();
2277            s.windows[&s.active_window].size_cells.0
2278        };
2279        let origin_w_before = inproc.get_session(sid).unwrap().panes[&origin].size_cells.0;
2280        // Grow the (left) origin pane rightward by a quarter of the window.
2281        inproc
2282            .resize_pane(origin, Direction::Right, (w_before / 4) as i16)
2283            .unwrap();
2284        let origin_w_after = inproc.get_session(sid).unwrap().panes[&origin].size_cells.0;
2285        assert!(
2286            origin_w_after > origin_w_before,
2287            "origin pane should have widened: {origin_w_after} !> {origin_w_before}"
2288        );
2289    }
2290
2291    #[test]
2292    fn apply_layout_rearranges_existing_panes_into_a_named_preset() {
2293        let inproc = InProcess::new();
2294        let sid = inproc.new_session("layout", "/bin/sh").unwrap();
2295        let s0 = inproc.get_session(sid).unwrap();
2296        let wid = s0.active_window;
2297        let p0 = s0.windows[&wid].active_pane;
2298        // Build an arbitrary 3-pane shape: p0 | (p1 / p2).
2299        let p1 = inproc.split_pane(p0, Direction::Right, "/bin/sh", &[]).unwrap();
2300        let _p2 = inproc.split_pane(p1, Direction::Below, "/bin/sh", &[]).unwrap();
2301        let before: std::collections::BTreeSet<PaneId> =
2302            inproc.get_window(wid).unwrap().1.layout.panes().into_iter().collect();
2303
2304        // Re-tile into an even horizontal row — panes keep their PTYs.
2305        inproc.apply_layout(wid, LayoutKind::EvenHorizontal).unwrap();
2306
2307        let w = inproc.get_window(wid).unwrap().1;
2308        let after: std::collections::BTreeSet<PaneId> =
2309            w.layout.panes().into_iter().collect();
2310        assert_eq!(before, after, "the same panes are preserved (PTYs kept)");
2311        w.layout.validate().unwrap();
2312        // EvenHorizontal of 3 = three equal thirds (the even-ratio refinement).
2313        let rects = w.layout.compute_rects(Rect::sized(90, 24));
2314        let mut widths: Vec<u16> = rects.iter().map(|(_, r)| r.w).collect();
2315        widths.sort_unstable();
2316        assert_eq!(widths, vec![30, 30, 30]);
2317    }
2318
2319    #[test]
2320    fn apply_layout_custom_leaves_the_tree_untouched() {
2321        let inproc = InProcess::new();
2322        let sid = inproc.new_session("custom", "/bin/sh").unwrap();
2323        let wid = inproc.get_session(sid).unwrap().active_window;
2324        let p0 = inproc.get_session(sid).unwrap().windows[&wid].active_pane;
2325        inproc.split_pane(p0, Direction::Right, "/bin/sh", &[]).unwrap();
2326        let before = inproc.get_window(wid).unwrap().1.layout.clone();
2327        // Custom has no canonical arrangement → no-op.
2328        inproc.apply_layout(wid, LayoutKind::Custom).unwrap();
2329        assert_eq!(inproc.get_window(wid).unwrap().1.layout, before);
2330    }
2331
2332    #[test]
2333    fn apply_layout_unknown_window_is_no_such_window() {
2334        let inproc = InProcess::new();
2335        let err = inproc
2336            .apply_layout(WindowId::from_seed("ghost"), LayoutKind::Tiled)
2337            .unwrap_err();
2338        assert!(matches!(err, ControlError::NoSuchWindow(_)));
2339    }
2340}
2341
2342impl InProcess {
2343    /// Engage or release the operator's brake — see [`tear_types::freio`].
2344    ///
2345    /// `session: None` means every session; that is the one-gesture panic
2346    /// path. Returns, in order: every session's state after the call, the
2347    /// panes actually braked, and — critically — the panes the brake could
2348    /// NOT reach because their provenance is unknown.
2349    ///
2350    /// That third list is not diagnostics. An operator who pressed a panic
2351    /// button must be told what it did not stop, or they will believe
2352    /// everything halted.
2353    ///
2354    /// `at_unix` is stamped HERE, from the daemon's clock. No caller
2355    /// supplies it, so a backdated brake has no code path.
2356    pub fn set_freio(
2357        &self,
2358        session: Option<SessionId>,
2359        engaged: bool,
2360    ) -> (Vec<(SessionId, tear_types::Freio)>, Vec<PaneId>, Vec<PaneId>) {
2361        let at_unix = std::time::SystemTime::now()
2362            .duration_since(std::time::UNIX_EPOCH)
2363            .map_or(0, |d| d.as_secs());
2364        let next = if engaged {
2365            tear_types::Freio::Engaged { at_unix }
2366        } else {
2367            tear_types::Freio::Released
2368        };
2369
2370        let mut r = self.registry.write();
2371        let mut braked = Vec::new();
2372        let mut unbrakable = Vec::new();
2373        for (sid, s) in r.sessions.iter_mut() {
2374            if session.is_some_and(|want| want != *sid) {
2375                continue;
2376            }
2377            s.freio = next;
2378            if engaged {
2379                for (pid, p) in &s.panes {
2380                    if p.yurai.is_automation() {
2381                        braked.push(*pid);
2382                    } else if matches!(p.yurai, tear_types::Yurai::Unknown) {
2383                        unbrakable.push(*pid);
2384                    }
2385                }
2386            }
2387        }
2388        let states = r.sessions.iter().map(|(id, s)| (*id, s.freio)).collect();
2389        (states, braked, unbrakable)
2390    }
2391
2392    /// Every session's brake state.
2393    #[must_use]
2394    pub fn freio_state(&self) -> Vec<(SessionId, tear_types::Freio)> {
2395        self.registry
2396            .read()
2397            .sessions
2398            .iter()
2399            .map(|(id, s)| (*id, s.freio))
2400            .collect()
2401    }
2402}
2403
2404/// Provenance-aware spawn verbs.
2405///
2406/// Inherent rather than trait methods: `MultiplexerControl` is
2407/// implemented by backends with no provenance model (the tmux backend),
2408/// and widening the trait would force them to carry a concept they cannot
2409/// honour. The trait verbs delegate here with `Yurai::Unknown`.
2410impl InProcess {
2411
2412    /// Spawn a session, recording WHO asked. See [`tear_types::yurai`].
2413    pub fn new_session_yurai(
2414        &self,
2415        name: &str,
2416        shell: &str,
2417        args: &[String],
2418        source: tear_types::SessionSource,
2419        size_cells: (u16, u16),
2420        yurai: tear_types::Yurai,
2421    ) -> ControlResult<SessionId> {
2422        let size = (size_cells.0.max(1), size_cells.1.max(1));
2423        // The embedder's cwd projection is the same value
2424        // `spawn_pty_for` will apply, so recording it here keeps the
2425        // typed pane record and the real child in agreement.
2426        let cwd = self.spawn_env.read().cwd.clone();
2427        let mut r = self.registry.write();
2428        let sid = r.create_session(name);
2429        // Stamp provenance on the typed session entry. The
2430        // registry.create_session built it with Source::default()
2431        // (Human); overwrite when the caller asked for something
2432        // else.
2433        if let Some(s) = r.sessions.get_mut(&sid) {
2434            s.source = source.clone();
2435        }
2436        let Some((_wid, pane_id)) =
2437            r.add_window(sid, "main", shell, args, cwd.as_deref(), &[], size, yurai.clone())
2438        else {
2439            return Err(ControlError::Internal(anyhow::anyhow!(
2440                "registry.add_window returned None after fresh create_session"
2441            )));
2442        };
2443        drop(r); // release write lock before spawning PTY
2444        if let Err(e) = self.spawn_pty_for(pane_id, shell, args, size, yurai.clone()) {
2445            // Roll back the session — registry is small, easier to
2446            // remove than to leave a sessionless typed entry.
2447            self.registry.write().sessions.remove(&sid);
2448            return Err(ControlError::Internal(e));
2449        }
2450        info!(
2451            session = %sid,
2452            name,
2453            shell,
2454            source = %source.label(),
2455            cols = size.0,
2456            rows = size.1,
2457            "tear-core: new session"
2458        );
2459        Ok(sid)
2460    }
2461
2462
2463    /// Open a window, recording WHO asked.
2464    pub fn new_window_yurai(
2465        &self,
2466        session: SessionId,
2467        name: &str,
2468        shell: &str,
2469        args: &[String],
2470        yurai: tear_types::Yurai,
2471    ) -> ControlResult<WindowId> {
2472        let size = (80, 24);
2473        let cwd = self.spawn_env.read().cwd.clone();
2474        let (wid, pid) = {
2475            let mut r = self.registry.write();
2476            r.add_window(session, name, shell, args, cwd.as_deref(), &[], size, yurai.clone())
2477                .ok_or(ControlError::NoSuchSession(session))?
2478        };
2479        if let Err(e) = self.spawn_pty_for(pid, shell, args, size, yurai.clone()) {
2480            return Err(ControlError::Internal(e));
2481        }
2482        info!(session = %session, window = %wid, name, "tear-core: new window");
2483        Ok(wid)
2484    }
2485
2486
2487    /// Split a pane, recording WHO asked.
2488    ///
2489    /// The new pane inherits the provenance of the CONNECTION that asked
2490    /// for the split, not of the pane it split from: an operator splitting
2491    /// an agent's pane gets their own pane, and the brake leaves it alone.
2492    pub fn split_pane_yurai(
2493        &self,
2494        origin: PaneId,
2495        direction: Direction,
2496        shell: &str,
2497        args: &[String],
2498        yurai: tear_types::Yurai,
2499    ) -> ControlResult<PaneId> {
2500        // Correct split: replace ONLY the origin leaf in the window's
2501        // layout tree with a balanced split (origin, new). Every other
2502        // pane keeps its slot — no whole-window re-wrap. Geometry is
2503        // reflowed from the tree afterwards (apply_layout_geometry).
2504        let (sid, wid) = self
2505            .registry
2506            .read()
2507            .locate_pane(origin)
2508            .ok_or(ControlError::NoSuchPane(origin))?;
2509        // Placeholder spawn size; apply_layout_geometry SIGWINCHes the
2510        // real per-pane geometry right after the PTY is up.
2511        let size = (80, 12);
2512        let cwd = self.spawn_env.read().cwd.clone();
2513        let pid = {
2514            let mut r = self.registry.write();
2515            let Some(s) = r.sessions.get_mut(&sid) else {
2516                return Err(ControlError::NoSuchSession(sid));
2517            };
2518            let new_pid = crate::registry::mint_pane_id(wid, shell);
2519            s.panes.insert(
2520                new_pid,
2521                TearPane {
2522                    id: new_pid,
2523                    shell: shell.into(),
2524                    args: args.to_vec(),
2525                    cwd: cwd.clone(),
2526                    env: vec![],
2527                    size_cells: size,
2528                    origin_cells: (0, 0),
2529                    state: tear_types::PaneState::Running,
2530                    title: shell.into(),
2531                    input_policy: tear_types::InputPolicy::default(),
2532                    // A split inherits the provenance of whoever asked
2533                    // for the split — not of the pane it split from.
2534                    yurai: yurai.clone(),
2535                },
2536            );
2537            // Split the matched leaf only. If `origin` isn't in this
2538            // window's tree (it should be — we just located it), roll
2539            // back the pane we inserted so we never leave an orphan.
2540            let split_ok = s.windows.get_mut(&wid).is_some_and(|w| {
2541                let ok = w.layout.split_leaf(origin, new_pid, direction, 0.5);
2542                if ok {
2543                    w.active_pane = new_pid;
2544                }
2545                ok
2546            });
2547            if !split_ok {
2548                s.panes.remove(&new_pid);
2549                return Err(ControlError::NoSuchPane(origin));
2550            }
2551            new_pid
2552        };
2553        self.spawn_pty_for(pid, shell, args, size, yurai.clone())
2554            .map_err(ControlError::Internal)?;
2555        self.apply_layout_geometry(sid, wid);
2556        info!(pane = %pid, "tear-core: split pane");
2557        Ok(pid)
2558    }
2559}