600	    /// (the first host wins); the hot read path (`net()`) never blocks on it.
601	    // [impl->REQ-DAEMON-9]
602	    pub fn attach_net(&self, host: NetHost) -> bool {
603	        self.net.set(host).is_ok()
604	    }
605
606	    /// The broker-owned exactly-once anchor (for introspection / tests).
607	    pub fn journal(&self) -> &Arc<EffectJournal> {
608	        &self.journal
609	    }
610
611	    /// The broker-owned network host, if one is attached (at bind or via the
612	    /// REQ-DAEMON-9 self-heal `attach_net`). Lock-free.
613	    pub fn net(&self) -> Option<&NetHost> {
614	        self.net.get()
615	    }
616
617	    /// Accept connections forever, handling each on its own thread. The broker
618	    /// is the long-lived process; this is its main loop. Per-connection
619	    /// threading is half the KH 7.4 isolation stance: one brain's blocking
620	    /// dispatch can never stall another's (the per-session half is
621	    /// [`Broker::dispatch_spawn`]'s drain/waiter threads).
622	    // [impl->REQ-HAZARD-DAEMON-SCHED-NONBLOCKING]
623	    pub fn serve(self: &Arc<Self>) -> io::Result<()> {
624	        loop {
625	            let conn = self.listener.accept()?;
626	            let me = Arc::clone(self);
627	            thread::spawn(move || {
628	                let _ = me.handle_conn(conn);
629	            });
630	        }
631	    }
632
633	    /// Accept exactly one connection and handle it on a spawned thread, returning
634	    /// that thread's handle. The single-brain entry point the tests exercise.
635	    pub fn serve_once(self: &Arc<Self>) -> io::Result<JoinHandle<()>> {
636	        let conn = self.listener.accept()?;
637	        let me = Arc::clone(self);
638	        Ok(thread::spawn(move || {
639	            let _ = me.handle_conn(conn);
640	        }))
641	    }
642
643	    /// Number of currently hosted sessions (for tests / introspection).
644	    pub fn session_count(&self) -> usize {
645	        self.sessions.lock().unwrap().len()
646	    }
647
648	    /// The child pid of a hosted session, if present (tests assert it is stable
649	    /// across brain restarts).
650	    pub fn session_pid(&self, id: u64) -> Option<u32> {
651	        self.sessions
652	            .lock()
653	            .unwrap()
654	            .get(&id)
655	            .and_then(|h| h.session.process_id())
656	    }
657
658	    /// The count of live QUIC connections the broker-owned net endpoint holds
659	    /// (D4a, ADR-0004 §B), or 0 when net-less. A direct read of the broker's own
660	    /// state — the survival-assertion seam for the D7-1 process-level E2E: a QUIC
661	    /// connection the broker holds must outlive a brain-PROCESS swap (restoration
662	    /// D7-1), the net sibling of [`session_count`](Self::session_count).
663	    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
664	    pub fn net_conn_count(&self) -> usize {
665	        self.net.get().map(|h| h.conn_count() as usize).unwrap_or(0)
666	    }
667
668	    /// The output-log high-water (`next_seq`) of a hosted session, or `None` if
669	    /// absent. It advances every time the child emits output — the
670	    /// **functional**-survival seam for the D7-1 process-level E2E (doyle F1):
671	    /// after a brain-PROCESS swap, writing input to the broker-held PTY child and
672	    /// watching THIS advance proves the path is alive end-to-end (input → child →
673	    /// echo → broker ring), not merely that the handle is held. A read-only direct
674	    /// peek, like [`session_pid`](Self::session_pid).
675	    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
676	    pub fn session_output_seq(&self, id: u64) -> Option<u64> {
677	        self.sessions
678	            .lock()
679	            .unwrap()
680	            .get(&id)
681	            .map(|h| h.log.lock().unwrap().next_seq)
682	    }
683
684	    /// Drive one brain connection: handshake, then dispatch its command frames
685	    /// until it disconnects. On disconnect, detach this connection from any
686	    /// sessions it subscribed to — the sessions themselves persist (B2: a brain
687	    /// restart must not disturb the hosted children).
688	    fn handle_conn(self: &Arc<Self>, conn: Stream) -> io::Result<()> {
689	        let (mut recv, send) = conn.split();
690	        let send: SharedSend = Arc::new(Mutex::new(send));
691
692	        // Version handshake: this connection must be a brain (2.3).
693	        recv_hello(&mut recv, Role::Brain)?;
694
695	        let mut my_subs: Vec<u64> = Vec::new();
696	        let mut my_stream_subs: Vec<u64> = Vec::new();
697	        let mut my_presence_sub = false;
698
699	        loop {
700	            let env = match read_frame(&mut recv) {
701	                Ok(e) => e,
702	                // Any read failure (EOF included) ends the connection.
703	                Err(_) => break,
704	            };
705	            match env.kind.as_str() {
706	                KIND_SPAWN => match self.dispatch_spawn(env, &send) {
707	                    Ok(id) => my_subs.push(id),
708	                    Err(msg) => send_error(&send, &msg),
709	                },
710	                KIND_SUBSCRIBE => match self.dispatch_subscribe(env, &send) {
711	                    Ok(id) => my_subs.push(id),
712	                    Err(msg) => send_error(&send, &msg),
713	                },
714	                KIND_UNSUBSCRIBE => {
715	                    if let Err(msg) = self.dispatch_unsubscribe(env, &send) {
716	                        send_error(&send, &msg);
717	                    }
718	                }
719	                KIND_INPUT => {
720	                    if let Err(msg) = self.dispatch_input(env, &send) {
721	                        send_error(&send, &msg);
722	                    }
723	                }
724	                KIND_RESIZE => {
725	                    if let Err(msg) = self.dispatch_resize(env, &send) {
726	                        send_error(&send, &msg);
727	                    }
728	                }
729	                KIND_KILL => {
730	                    if let Err(msg) = self.dispatch_kill(env) {
731	                        send_error(&send, &msg);
732	                    }
733	                }
734	                KIND_NET_STATUS => self.dispatch_net_status(&send),
735	                KIND_NET_DIAL => {
736	                    if let Err(msg) = self.dispatch_net_dial(env, &send) {
737	                        send_error(&send, &msg);
738	                    }
739	                }
740	                KIND_NET_DIAL_LOOPBACK => {
741	                    if let Err(msg) = self.dispatch_net_dial_loopback(&send) {
742	                        send_error(&send, &msg);
743	                    }
744	                }
745	                KIND_PAIR_JOIN => {
746	                    if let Err(msg) = self.dispatch_pair_join(env, &send) {
747	                        send_error(&send, &msg);
748	                    }
749	                }
750	                KIND_NET_STREAM_OPEN => {
751	                    if let Err(msg) = self.dispatch_net_stream_open(env, &send) {
752	                        send_error(&send, &msg);
753	                    }
754	                }
755	                KIND_NET_STREAM_SEND => {
756	                    if let Err(msg) = self.dispatch_net_stream_send(env, &send) {
757	                        send_error(&send, &msg);
758	                    }
759	                }
760	                KIND_NET_STREAMS => {
761	                    if let Err(msg) = self.dispatch_net_streams(&send) {
762	                        send_error(&send, &msg);
763	                    }
764	                }
765	                KIND_SESSIONS => {
766	                    // Clone the (id, endpoint, log) out from under the sessions
767	                    // lock, then read each log's cursor under the log lock alone —
768	                    // never both locks at once (the dispatch_subscribe discipline).
769	                    let rows: Vec<(u64, String, Arc<Mutex<OutputLog>>)> = {
770	                        let sessions = self.sessions.lock().unwrap();
771	                        sessions
772	                            .iter()
773	                            .map(|(id, h)| (*id, h.endpoint.clone(), Arc::clone(&h.log)))
774	                            .collect()
775	                    };
776	                    let reply = SessionsReply {
777	                        sessions: rows
778	                            .into_iter()
779	                            .map(|(id, endpoint, log)| SessionInfo {
780	                                session_id: id,
781	                                endpoint,
782	                                resume_seq: log.lock().unwrap().delivered_through(),
783	                            })
784	                            .collect(),
785	                    };
786	                    let frame = Envelope::new(
787	                        KIND_SESSIONS_REPLY,
788	                        serde_json::to_value(reply).expect("SessionsReply serializes"),
789	                    );
790	                    send_frame(&send, &frame);
791	                }
792	                KIND_ENDPOINT_INPUT => {
793	                    if let Err(msg) = self.dispatch_endpoint_input(env, &send) {
794	                        send_error(&send, &msg);
795	                    }
796	                }
797	                KIND_NET_STREAM_SUBSCRIBE => match self.dispatch_net_stream_subscribe(env, &send) {
798	                    Ok(id) => my_stream_subs.push(id),
799	                    Err(msg) => send_error(&send, &msg),
800	                },
801	                KIND_NET_PRESENCE_SUBSCRIBE => {
802	                    match self.dispatch_net_presence_subscribe(env, &send) {
803	                        Ok(()) => my_presence_sub = true,
804	                        Err(msg) => send_error(&send, &msg),
805	                    }
806	                }
807	                KIND_BRAIN_RESTART => self.dispatch_brain_restart(&send),
808	                // An unknown kind is tolerated (forward-compat): report, don't die.
809	                other => send_error(&send, &format!("unknown command kind {other:?}")),
810	            }
811	        }
812
813	        // Brain gone: detach as subscriber but leave the sessions running.
814	        let sessions = self.sessions.lock().unwrap();
815	        for id in &my_subs {
816	            if let Some(h) = sessions.get(id) {
817	                h.log.lock().unwrap().detach_if(&send);
818	            }
819	        }
820	        drop(sessions);
821	        // Same for net streams: the streams + their rings persist (D4b).
822	        if let Some(host) = self.net.get() {
823	            for id in &my_stream_subs {
824	                host.detach_stream_sub(*id, &send);
825	            }
826	            // And presence: the liveness log + its ring persist (D4c).
827	            if my_presence_sub {
828	                host.detach_presence_sub(&send);
829	            }
830	        }
831	        Ok(())
832	    }
833
834	    /// Spawn a child under a fresh PTY, register it, auto-subscribe the spawning
835	    /// connection, start its output drain and exit waiter, and reply `spawned`.
836	    /// Every hosted session gets its **own** drain + exit-waiter threads (KH
837	    /// 7.4): a hung child blocks only its own threads — shell instances ride
838	    /// exactly this isolation (one owner's hung shell binary stalls neither
839	    /// another owner's delivery nor the broker's control dispatch).
840	    // [impl->REQ-HAZARD-DAEMON-SCHED-NONBLOCKING]
841	    fn dispatch_spawn(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
842	        let req: SpawnReq =
843	            serde_json::from_value(env.payload).map_err(|e| format!("bad spawn payload: {e}"))?;
844	        // REQ-HOST-RUN-2: spawn in the requested project cwd when carried;
845	        // `None` inherits the broker's cwd (legacy/test + W1 bringup shortcut).
846	        // Inject the manifest [env] vars (REQ-HAZARD-ENV-SUBST) on top of the
847	        // inherited env — empty for a legacy/N-1 spawn (inherit only).
848	        let session = PtySession::spawn_program_in_env(
849	            &req.program,
850	            &req.args,
851	            SurfaceSize::new(req.rows, req.cols),
852	            req.cwd.as_deref(),
853	            req.env.iter().map(|(k, v)| (k.as_str(), v.as_str())),
854	        )
855	        .map_err(|e| format!("spawn failed: {e}"))?;
856	        let session = Arc::new(session);
857	        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
858	        let pid = session.process_id();
859
860	        // The output log, with the spawning connection pre-attached as the
861	        // controller (identity `None` = LOCAL, not a remote driver) so no chunk
862	        // is missed before an explicit subscribe — and so a remote attach to a
863	        // spawned-but-undriven session takes control silently (no loud displace
864	        // of the local-spawner placeholder; REQ-RCVIEW-1).
865	        let log = Arc::new(Mutex::new(OutputLog::new(
866	            id,
867	            self.log_cap_chunks,
868	            req.endpoint.clone(),
869	            (req.rows, req.cols),
870	        )));
871	        log.lock()
872	            .unwrap()
873	            .become_controller(Arc::clone(send), None, 0);
874
875	        // Output drain → appends to the log (which live-sends to the subscriber).
876	        let log_drain = Arc::clone(&log);
877	        let drain = session
878	            .drain(move |chunk| {
879	                log_drain.lock().unwrap().append(chunk);
880	            })
881	            .map_err(|e| format!("drain failed: {e}"))?;
882
883	        // Exit waiter → one `exit` frame when the child is reaped, then the dead
884	        // session is removed from the table. Runs on its own thread; `wait`
885	        // holds the child lock but `kill` uses a split killer.
886	        let sessions_exit = Arc::clone(&self.sessions);
887	        let log_exit = Arc::clone(&log);
888	        let waiter = Arc::clone(&session);
889	        thread::spawn(move || {
890	            if let Ok(status) = waiter.wait() {
891	                let ev = ExitEvent {
892	                    session_id: id,
893	                    code: Some(status.exit_code() as i32),
894	                };
895	                let frame = Envelope::new(
896	                    KIND_EXIT,
897	                    serde_json::to_value(ev).expect("ExitEvent serializes"),
898	                );
899	                // Send to every attached sink — controller + all viewers (under
900	                // the log lock).
901	                let sinks = log_exit.lock().unwrap().all_sinks();
902	                for sink in sinks {
903	                    if let Ok(mut s) = sink.lock() {
904	                        let _ = write_frame(&mut *s, &frame);
905	                    }
906	                }
907	            }
908	            // Reap the dead session from the table.
909	            sessions_exit.lock().unwrap().remove(&id);
910	        });
911
912	        self.sessions.lock().unwrap().insert(
913	            id,
914	            HostedSession {
915	                session,
916	                drain,
917	                log,
918	                endpoint: req.endpoint.clone(),
919	            },

    /// (the first host wins); the hot read path (`net()`) never blocks on it.
    // [impl->REQ-DAEMON-9]
    pub fn attach_net(&self, host: NetHost) -> bool {
        self.net.set(host).is_ok()
    }

    /// The broker-owned exactly-once anchor (for introspection / tests).
    pub fn journal(&self) -> &Arc<EffectJournal> {
        &self.journal
    }

    /// The broker-owned network host, if one is attached (at bind or via the
    /// REQ-DAEMON-9 self-heal `attach_net`). Lock-free.
    pub fn net(&self) -> Option<&NetHost> {
        self.net.get()
    }

    /// Accept connections forever, handling each on its own thread. The broker
    /// is the long-lived process; this is its main loop. Per-connection
    /// threading is half the KH 7.4 isolation stance: one brain's blocking
    /// dispatch can never stall another's (the per-session half is
    /// [`Broker::dispatch_spawn`]'s drain/waiter threads).
    // [impl->REQ-HAZARD-DAEMON-SCHED-NONBLOCKING]
    pub fn serve(self: &Arc<Self>) -> io::Result<()> {
        loop {
            let conn = self.listener.accept()?;
            let me = Arc::clone(self);
            thread::spawn(move || {
                let _ = me.handle_conn(conn);
            });
        }
    }

    /// Accept exactly one connection and handle it on a spawned thread, returning
    /// that thread's handle. The single-brain entry point the tests exercise.
    pub fn serve_once(self: &Arc<Self>) -> io::Result<JoinHandle<()>> {
        let conn = self.listener.accept()?;
        let me = Arc::clone(self);
        Ok(thread::spawn(move || {
            let _ = me.handle_conn(conn);
        }))
    }

    /// Number of currently hosted sessions (for tests / introspection).
    pub fn session_count(&self) -> usize {
        self.sessions.lock().unwrap().len()
    }

    /// The child pid of a hosted session, if present (tests assert it is stable
    /// across brain restarts).
    pub fn session_pid(&self, id: u64) -> Option<u32> {
        self.sessions
            .lock()
            .unwrap()
            .get(&id)
            .and_then(|h| h.session.process_id())
    }

    /// The count of live QUIC connections the broker-owned net endpoint holds
    /// (D4a, ADR-0004 §B), or 0 when net-less. A direct read of the broker's own
    /// state — the survival-assertion seam for the D7-1 process-level E2E: a QUIC
    /// connection the broker holds must outlive a brain-PROCESS swap (restoration
    /// D7-1), the net sibling of [`session_count`](Self::session_count).
    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
    pub fn net_conn_count(&self) -> usize {
        self.net.get().map(|h| h.conn_count() as usize).unwrap_or(0)
    }

    /// The output-log high-water (`next_seq`) of a hosted session, or `None` if
    /// absent. It advances every time the child emits output — the
    /// **functional**-survival seam for the D7-1 process-level E2E (doyle F1):
    /// after a brain-PROCESS swap, writing input to the broker-held PTY child and
    /// watching THIS advance proves the path is alive end-to-end (input → child →
    /// echo → broker ring), not merely that the handle is held. A read-only direct
    /// peek, like [`session_pid`](Self::session_pid).
    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
    pub fn session_output_seq(&self, id: u64) -> Option<u64> {
        self.sessions
            .lock()
            .unwrap()
            .get(&id)
            .map(|h| h.log.lock().unwrap().next_seq)
    }

    /// Drive one brain connection: handshake, then dispatch its command frames
    /// until it disconnects. On disconnect, detach this connection from any
    /// sessions it subscribed to — the sessions themselves persist (B2: a brain
    /// restart must not disturb the hosted children).
    fn handle_conn(self: &Arc<Self>, conn: Stream) -> io::Result<()> {
        let (mut recv, send) = conn.split();
        let send: SharedSend = Arc::new(Mutex::new(send));

        // Version handshake: this connection must be a brain (2.3).
        recv_hello(&mut recv, Role::Brain)?;

        let mut my_subs: Vec<u64> = Vec::new();
        let mut my_stream_subs: Vec<u64> = Vec::new();
        let mut my_presence_sub = false;

        loop {
            let env = match read_frame(&mut recv) {
                Ok(e) => e,
                // Any read failure (EOF included) ends the connection.
                Err(_) => break,
            };
            match env.kind.as_str() {
                KIND_SPAWN => match self.dispatch_spawn(env, &send) {
                    Ok(id) => my_subs.push(id),
                    Err(msg) => send_error(&send, &msg),
                },
                KIND_SUBSCRIBE => match self.dispatch_subscribe(env, &send) {
                    Ok(id) => my_subs.push(id),
                    Err(msg) => send_error(&send, &msg),
                },
                KIND_UNSUBSCRIBE => {
                    if let Err(msg) = self.dispatch_unsubscribe(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_INPUT => {
                    if let Err(msg) = self.dispatch_input(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_RESIZE => {
                    if let Err(msg) = self.dispatch_resize(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_KILL => {
                    if let Err(msg) = self.dispatch_kill(env) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STATUS => self.dispatch_net_status(&send),
                KIND_NET_DIAL => {
                    if let Err(msg) = self.dispatch_net_dial(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_DIAL_LOOPBACK => {
                    if let Err(msg) = self.dispatch_net_dial_loopback(&send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_PAIR_JOIN => {
                    if let Err(msg) = self.dispatch_pair_join(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STREAM_OPEN => {
                    if let Err(msg) = self.dispatch_net_stream_open(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STREAM_SEND => {
                    if let Err(msg) = self.dispatch_net_stream_send(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STREAMS => {
                    if let Err(msg) = self.dispatch_net_streams(&send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_SESSIONS => {
                    // Clone the (id, endpoint, log) out from under the sessions
                    // lock, then read each log's cursor under the log lock alone —
                    // never both locks at once (the dispatch_subscribe discipline).
                    let rows: Vec<(u64, String, Arc<Mutex<OutputLog>>)> = {
                        let sessions = self.sessions.lock().unwrap();
                        sessions
                            .iter()
                            .map(|(id, h)| (*id, h.endpoint.clone(), Arc::clone(&h.log)))
                            .collect()
                    };
                    let reply = SessionsReply {
                        sessions: rows
                            .into_iter()
                            .map(|(id, endpoint, log)| SessionInfo {
                                session_id: id,
                                endpoint,
                                resume_seq: log.lock().unwrap().delivered_through(),
                            })
                            .collect(),
                    };
                    let frame = Envelope::new(
                        KIND_SESSIONS_REPLY,
                        serde_json::to_value(reply).expect("SessionsReply serializes"),
                    );
                    send_frame(&send, &frame);
                }
                KIND_ENDPOINT_INPUT => {
                    if let Err(msg) = self.dispatch_endpoint_input(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STREAM_SUBSCRIBE => match self.dispatch_net_stream_subscribe(env, &send) {
                    Ok(id) => my_stream_subs.push(id),
                    Err(msg) => send_error(&send, &msg),
                },
                KIND_NET_PRESENCE_SUBSCRIBE => {
                    match self.dispatch_net_presence_subscribe(env, &send) {
                        Ok(()) => my_presence_sub = true,
                        Err(msg) => send_error(&send, &msg),
                    }
                }
                KIND_BRAIN_RESTART => self.dispatch_brain_restart(&send),
                // An unknown kind is tolerated (forward-compat): report, don't die.
                other => send_error(&send, &format!("unknown command kind {other:?}")),
            }
        }

        // Brain gone: detach as subscriber but leave the sessions running.
        let sessions = self.sessions.lock().unwrap();
        for id in &my_subs {
            if let Some(h) = sessions.get(id) {
                h.log.lock().unwrap().detach_if(&send);
            }
        }
        drop(sessions);
        // Same for net streams: the streams + their rings persist (D4b).
        if let Some(host) = self.net.get() {
            for id in &my_stream_subs {
                host.detach_stream_sub(*id, &send);
            }
            // And presence: the liveness log + its ring persist (D4c).
            if my_presence_sub {
                host.detach_presence_sub(&send);
            }
        }
        Ok(())
    }

    /// Spawn a child under a fresh PTY, register it, auto-subscribe the spawning
    /// connection, start its output drain and exit waiter, and reply `spawned`.
    /// Every hosted session gets its **own** drain + exit-waiter threads (KH
    /// 7.4): a hung child blocks only its own threads — shell instances ride
    /// exactly this isolation (one owner's hung shell binary stalls neither
    /// another owner's delivery nor the broker's control dispatch).
    // [impl->REQ-HAZARD-DAEMON-SCHED-NONBLOCKING]
    fn dispatch_spawn(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
        let req: SpawnReq =
            serde_json::from_value(env.payload).map_err(|e| format!("bad spawn payload: {e}"))?;
        // REQ-HOST-RUN-2: spawn in the requested project cwd when carried;
        // `None` inherits the broker's cwd (legacy/test + W1 bringup shortcut).
        // Inject the manifest [env] vars (REQ-HAZARD-ENV-SUBST) on top of the
        // inherited env — empty for a legacy/N-1 spawn (inherit only).
        let session = PtySession::spawn_program_in_env(
            &req.program,
            &req.args,
            SurfaceSize::new(req.rows, req.cols),
            req.cwd.as_deref(),
            req.env.iter().map(|(k, v)| (k.as_str(), v.as_str())),
        )
        .map_err(|e| format!("spawn failed: {e}"))?;
        let session = Arc::new(session);
        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
        let pid = session.process_id();

        // The output log, with the spawning connection pre-attached as the
        // controller (identity `None` = LOCAL, not a remote driver) so no chunk
        // is missed before an explicit subscribe — and so a remote attach to a
        // spawned-but-undriven session takes control silently (no loud displace
        // of the local-spawner placeholder; REQ-RCVIEW-1).
        let log = Arc::new(Mutex::new(OutputLog::new(
            id,
            self.log_cap_chunks,
            req.endpoint.clone(),
            (req.rows, req.cols),
        )));
        log.lock()
            .unwrap()
            .become_controller(Arc::clone(send), None, 0);

        // Output drain → appends to the log (which live-sends to the subscriber).
        let log_drain = Arc::clone(&log);
        let drain = session
            .drain(move |chunk| {
                log_drain.lock().unwrap().append(chunk);
            })
            .map_err(|e| format!("drain failed: {e}"))?;

        // Exit waiter → one `exit` frame when the child is reaped, then the dead
        // session is removed from the table. Runs on its own thread; `wait`
        // holds the child lock but `kill` uses a split killer.
        let sessions_exit = Arc::clone(&self.sessions);
        let log_exit = Arc::clone(&log);
        let waiter = Arc::clone(&session);
        thread::spawn(move || {
            if let Ok(status) = waiter.wait() {
                let ev = ExitEvent {
                    session_id: id,
                    code: Some(status.exit_code() as i32),
                };
                let frame = Envelope::new(
                    KIND_EXIT,
                    serde_json::to_value(ev).expect("ExitEvent serializes"),
                );
                // Send to every attached sink — controller + all viewers (under
                // the log lock).
                let sinks = log_exit.lock().unwrap().all_sinks();
                for sink in sinks {
                    if let Ok(mut s) = sink.lock() {
                        let _ = write_frame(&mut *s, &frame);
                    }
                }
            }
            // Reap the dead session from the table.
            sessions_exit.lock().unwrap().remove(&id);
        });

        self.sessions.lock().unwrap().insert(
            id,
            HostedSession {
                session,
                drain,
                log,
                endpoint: req.endpoint.clone(),
            },


1000	    /// [`EffectJournal`] for exactly-once delivery: a replay of the same
1001	    /// `(session_id, op_id)` after a brain crash is deduped (the PTY is not
1002	    /// written twice), and either outcome — fresh apply or dedup — is acked back
1003	    /// with an [`crate::msg::KIND_APPLIED`] frame so the brain can retire the
1004	    /// effect (B5 / KNOWN-HAZARDS 7.2). Without an `op_id` it is the legacy
1005	    /// direct write (no dedup, no ack).
1006	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
1007	    fn dispatch_input(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
1008	        let req: InputReq =
1009	            serde_json::from_value(env.payload).map_err(|e| format!("bad input payload: {e}"))?;
1010	        let bytes = decode_bytes(&req.data_b64).map_err(|e| format!("bad input base64: {e}"))?;
1011	        // Clone the session Arc out from under the sessions lock so the PTY write
1012	        // + fsync happen without holding the table lock (mirrors dispatch_subscribe).
1013	        let session = {
1014	            let sessions = self.sessions.lock().unwrap();
1015	            let h = sessions
1016	                .get(&req.session_id)
1017	                .ok_or_else(|| format!("no such session {}", req.session_id))?;
1018	            Arc::clone(&h.session)
1019	        };
1020	        match req.op_id {
1021	            Some(op) => {
1022	                let key = (req.session_id, op);
1023	                let outcome = self
1024	                    .journal
1025	                    .apply_once(key, EffectKind::PtyWrite, || {
1026	                        session
1027	                            .write_input(&bytes)
1028	                            .map_err(|e| io::Error::other(e.to_string()))
1029	                    })
1030	                    .map_err(|e| format!("write_input failed: {e}"))?;
1031	                // Ack exactly-once: both Applied and Deduped mean "durably landed".
1032	                send_frame(
1033	                    send,
1034	                    &applied_envelope(req.session_id, op, outcome == Outcome::Applied),
1035	                );
1036	                Ok(())
1037	            }
1038	            None => session
1039	                .write_input(&bytes)
1040	                .map_err(|e| format!("write_input failed: {e}")),
1041	        }
1042	    }
1043
1044	    /// Deliver an inbound message to an spt-hosted endpoint by ENDPOINT ID:
1045	    /// resolve the endpoint to its hosted session and inject the bytes straight
1046	    /// into that session's PTY (REQ-SEND-SPT-HOSTED). The endpoint-keyed
1047	    /// counterpart of [`dispatch_input`] — a CLI `spt send` to an spt-hosted
1048	    /// endpoint (broker holds its PTY, no `api listen` relay) lands here instead
1049	    /// of spooling silently. Resolution is atomic under the sessions lock (no
1050	    /// TOCTOU); the bytes are a pre-rendered `<EVENT>` envelope (the caller owns
1051	    /// the wire format — ADR-0020). Fire-and-forget: a direct write, no journal
1052	    /// (a user message is delivered like the relay's emit, not an exactly-once
1053	    /// effect). Replies [`KIND_ENDPOINT_INJECTED`] with `delivered` so the caller
1054	    /// knows whether to fall back to the spool.
1055	    ///
1056	    /// IDLE/DIRECT path only (REQ-SEND-SPT-HOSTED, MVP per CONTEXT:188): a write
1057	    /// arriving mid-agent-turn can interleave with the agent's active output —
1058	    /// activity-gated routing (idle→inject / active→spool-for-poll-drain) is a
1059	    /// deferred follow wave. The write is logged so an active-state inject is
1060	    /// never silent.
1061	    // [impl->REQ-SEND-SPT-HOSTED]
1062	    fn dispatch_endpoint_input(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
1063	        let req: EndpointInputReq = serde_json::from_value(env.payload)
1064	            .map_err(|e| format!("bad endpoint-input payload: {e}"))?;
1065	        let bytes =
1066	            decode_bytes(&req.data_b64).map_err(|e| format!("bad endpoint-input base64: {e}"))?;
1067	        // Resolve endpoint → session under the lock, cloning the Arc out so the
1068	        // PTY write happens without holding the table lock (dispatch_input rule).
1069	        let session = {
1070	            let sessions = self.sessions.lock().unwrap();
1071	            sessions
1072	                .values()
1073	                .find(|h| h.endpoint == req.endpoint)
1074	                .map(|h| Arc::clone(&h.session))
1075	        };
1076	        match session {
1077	            Some(session) => {
1078	                // IDLE/direct inject. Logged (REQ-SEND-SPT-HOSTED MVP): activity is
1079	                // not yet gated, so note the raw write — never a silent mid-turn
1080	                // interleave passed off as done.
1081	                eprintln!(
1082	                    "ENDPOINT_INJECT:{} ({} bytes, idle-direct — activity not yet gated)",
1083	                    req.endpoint,
1084	                    bytes.len()
1085	                );
1086	                session
1087	                    .write_input(&bytes)
1088	                    .map_err(|e| format!("write_input failed: {e}"))?;
1089	                send_frame(send, &endpoint_injected_envelope(&req.endpoint, true));
1090	                Ok(())
1091	            }
1092	            // No hosted session for this endpoint — tell the caller to spool.
1093	            None => {
1094	                send_frame(send, &endpoint_injected_envelope(&req.endpoint, false));
1095	                Ok(())
1096	            }
1097	        }
1098	    }
1099
1100	    /// Resize a session's PTY — CONTROLLER-EXCLUSIVE (REQ-RCVIEW-1). The broker
1101	    /// rejects a resize arriving on any stream that is not the session's current
1102	    /// controller (a viewer's resize is refused), then updates the stored size
1103	    /// and pushes a `size` frame to every viewer (the letterbox feed).
1104	    fn dispatch_resize(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
1105	        let req: ResizeReq =
1106	            serde_json::from_value(env.payload).map_err(|e| format!("bad resize payload: {e}"))?;
1107	        let (session, log) = {
1108	            let sessions = self.sessions.lock().unwrap();
1109	            let h = sessions
1110	                .get(&req.session_id)
1111	                .ok_or_else(|| format!("no such session {}", req.session_id))?;
1112	            (Arc::clone(&h.session), Arc::clone(&h.log))
1113	        };
1114	        if !log.lock().unwrap().is_controller(send) {
1115	            return Err(
1116	                "resize is controller-exclusive: only the interactive controller may resize"
1117	                    .to_string(),
1118	            );
1119	        }
1120	        session
1121	            .resize(SurfaceSize::new(req.rows, req.cols))
1122	            .map_err(|e| format!("resize failed: {e}"))?;
1123	        log.lock().unwrap().set_size_and_notify(req.rows, req.cols);
1124	        Ok(())
1125	    }
1126
1127	    /// Report the broker-owned net endpoint's status (D4a). Answered even when
1128	    /// the broker has no network host (`enabled: false`) so a brain can probe
1129	    /// capability without treating absence as an error.

    /// [`EffectJournal`] for exactly-once delivery: a replay of the same
    /// `(session_id, op_id)` after a brain crash is deduped (the PTY is not
    /// written twice), and either outcome — fresh apply or dedup — is acked back
    /// with an [`crate::msg::KIND_APPLIED`] frame so the brain can retire the
    /// effect (B5 / KNOWN-HAZARDS 7.2). Without an `op_id` it is the legacy
    /// direct write (no dedup, no ack).
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    fn dispatch_input(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
        let req: InputReq =
            serde_json::from_value(env.payload).map_err(|e| format!("bad input payload: {e}"))?;
        let bytes = decode_bytes(&req.data_b64).map_err(|e| format!("bad input base64: {e}"))?;
        // Clone the session Arc out from under the sessions lock so the PTY write
        // + fsync happen without holding the table lock (mirrors dispatch_subscribe).
        let session = {
            let sessions = self.sessions.lock().unwrap();
            let h = sessions
                .get(&req.session_id)
                .ok_or_else(|| format!("no such session {}", req.session_id))?;
            Arc::clone(&h.session)
        };
        match req.op_id {
            Some(op) => {
                let key = (req.session_id, op);
                let outcome = self
                    .journal
                    .apply_once(key, EffectKind::PtyWrite, || {
                        session
                            .write_input(&bytes)
                            .map_err(|e| io::Error::other(e.to_string()))
                    })
                    .map_err(|e| format!("write_input failed: {e}"))?;
                // Ack exactly-once: both Applied and Deduped mean "durably landed".
                send_frame(
                    send,
                    &applied_envelope(req.session_id, op, outcome == Outcome::Applied),
                );
                Ok(())
            }
            None => session
                .write_input(&bytes)
                .map_err(|e| format!("write_input failed: {e}")),
        }
    }

    /// Deliver an inbound message to an spt-hosted endpoint by ENDPOINT ID:
    /// resolve the endpoint to its hosted session and inject the bytes straight
    /// into that session's PTY (REQ-SEND-SPT-HOSTED). The endpoint-keyed
    /// counterpart of [`dispatch_input`] — a CLI `spt send` to an spt-hosted
    /// endpoint (broker holds its PTY, no `api listen` relay) lands here instead
    /// of spooling silently. Resolution is atomic under the sessions lock (no
    /// TOCTOU); the bytes are a pre-rendered `<EVENT>` envelope (the caller owns
    /// the wire format — ADR-0020). Fire-and-forget: a direct write, no journal
    /// (a user message is delivered like the relay's emit, not an exactly-once
    /// effect). Replies [`KIND_ENDPOINT_INJECTED`] with `delivered` so the caller
    /// knows whether to fall back to the spool.
    ///
    /// IDLE/DIRECT path only (REQ-SEND-SPT-HOSTED, MVP per CONTEXT:188): a write
    /// arriving mid-agent-turn can interleave with the agent's active output —
    /// activity-gated routing (idle→inject / active→spool-for-poll-drain) is a
    /// deferred follow wave. The write is logged so an active-state inject is
    /// never silent.
    // [impl->REQ-SEND-SPT-HOSTED]
    fn dispatch_endpoint_input(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
        let req: EndpointInputReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad endpoint-input payload: {e}"))?;
        let bytes =
            decode_bytes(&req.data_b64).map_err(|e| format!("bad endpoint-input base64: {e}"))?;
        // Resolve endpoint → session under the lock, cloning the Arc out so the
        // PTY write happens without holding the table lock (dispatch_input rule).
        let session = {
            let sessions = self.sessions.lock().unwrap();
            sessions
                .values()
                .find(|h| h.endpoint == req.endpoint)
                .map(|h| Arc::clone(&h.session))
        };
        match session {
            Some(session) => {
                // IDLE/direct inject. Logged (REQ-SEND-SPT-HOSTED MVP): activity is
                // not yet gated, so note the raw write — never a silent mid-turn
                // interleave passed off as done.
                eprintln!(
                    "ENDPOINT_INJECT:{} ({} bytes, idle-direct — activity not yet gated)",
                    req.endpoint,
                    bytes.len()
                );
                session
                    .write_input(&bytes)
                    .map_err(|e| format!("write_input failed: {e}"))?;
                send_frame(send, &endpoint_injected_envelope(&req.endpoint, true));
                Ok(())
            }
            // No hosted session for this endpoint — tell the caller to spool.
            None => {
                send_frame(send, &endpoint_injected_envelope(&req.endpoint, false));
                Ok(())
            }
        }
    }

    /// Resize a session's PTY — CONTROLLER-EXCLUSIVE (REQ-RCVIEW-1). The broker
    /// rejects a resize arriving on any stream that is not the session's current
    /// controller (a viewer's resize is refused), then updates the stored size
    /// and pushes a `size` frame to every viewer (the letterbox feed).
    fn dispatch_resize(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
        let req: ResizeReq =
            serde_json::from_value(env.payload).map_err(|e| format!("bad resize payload: {e}"))?;
        let (session, log) = {
            let sessions = self.sessions.lock().unwrap();
            let h = sessions
                .get(&req.session_id)
                .ok_or_else(|| format!("no such session {}", req.session_id))?;
            (Arc::clone(&h.session), Arc::clone(&h.log))
        };
        if !log.lock().unwrap().is_controller(send) {
            return Err(
                "resize is controller-exclusive: only the interactive controller may resize"
                    .to_string(),
            );
        }
        session
            .resize(SurfaceSize::new(req.rows, req.cols))
            .map_err(|e| format!("resize failed: {e}"))?;
        log.lock().unwrap().set_size_and_notify(req.rows, req.cols);
        Ok(())
    }

    /// Report the broker-owned net endpoint's status (D4a). Answered even when
    /// the broker has no network host (`enabled: false`) so a brain can probe
    /// capability without treating absence as an error.


Now the mechanism is fully clear. Let me confirm the threading claim vs reality and the writer-mutex choke, then verify I can build a surgical repro. Key: broker claims non-blocking dispatch (`REQ-HAZARD-DAEMON-SCHED-NONBLOCKING`, broker.rs:617-622) but `write_input` (lines 1027/1087) is a **blocking** `write_all` on the dispatch thread, serialized behind the shared `writer` mutex that the drain's DSR answer also needs.

Check crate deps for repro placement + confirm injection CLI path.


<owl_messages>
<owl_message from="doyle" priority="high">W1 UPDATE + a sibling bug — operator /diagnose surfaced two rc/PTY bugs; I code-traced both. Findings committed @0fcde06 (registry).

PART 1 = YOUR W1, root located in code (CONFIRM on the real fixture — don't rediscover blind):
Broker::append (broker.rs:205-227) fans each live output chunk to the CONTROLLER via a SYNCHRONOUS BLOCKING write_frame held INLINE in the session drain thread (the 'authoritative, advances delivered_through' path, D4-1). VIEWERS instead use a dedicated writer thread + bounded evicting sync_channel (add_viewer:273 / viewer_writer) that can NEVER stall the drain. So when the controller socket backs up — or the 64KB loopback duplex fills (the ATTACH-WEDGE buffer) — the drain thread BLOCKS → output stalls → keystroke echoes stall (the operator's 'sluggish input') → unrecoverable wedge when the consumer never drains.

KEY NEW DATA that widens your repro: this fires on NORMAL interactive rc use under heavy harness output (Claude Code TUI redraw), NOT only on message injection. Same root. So your W1 repro fixture should ALSO cover: attach controller, flood session output, assert echo latency stays bounded + no wedge — independent of injection. (Injection is one trigger; output backpressure is the other; both = the inline-blocking-controller root.)

FIX DIRECTION (gate-approved target): move controller delivery off the drain thread onto a dedicated writer (the viewer_writer pattern) BUT preserve the authoritative cursor — block the WRITER thread not the drain, bound the wedge (deadline → detach/mark-gone, never park forever), NEVER silently evict the operator's authoritative view (viewers can drop; the controller can't). I sharpened REQ-HAZARD-INJECT-CONTROL-COEXIST's title with all this. Still: instrument the real fixture to PROVE append's write_frame is the park point before you patch, and ping me.

PART 2 = NEW sibling, REQ-HAZARD-RC-INPUT-KEY-ENCODING (seeded stages=[], = W7):
Backspace in an rc session always whole-word-deletes in Claude Code (behaves like ctrl+Backspace). ROOT (code-grounded, byte PENDING your HITL confirm): rc is a raw verbatim byte pump (spawn_stdin_reader rc.rs:152, forwards stdin bytes unchanged); NO console-mode setup anywhere (grep: zero SetConsoleMode/ENABLE_VIRTUAL_TERMINAL_INPUT). On Windows crossterm raw mode leaves ENABLE_VIRTUAL_TERMINAL_INPUT OFF → legacy console emits ^H (0x08) for Backspace instead of VT DEL (0x7f) → Claude Code maps ^H → backward-kill-word.
CONFIRM-FIRST: add an env-gated hexdump (SPT_RC_DEBUG_KEYS) in spawn_stdin_reader, have the operator press Backspace + ctrl+Backspace in a real rc session, capture the forwarded byte. FIX: (a) enable ENABLE_VIRTUAL_TERMINAL_INPUT on the rc stdin console on Windows (preferred — fixes the whole VT key map: arrows/Home/End too, Backspace→0x7f) or (b) narrow-normalize bare 0x08→0x7f (riskier, real ctrl+h is 0x08). Add a KNOWN-HAZARDS.md entry on landing.

Sequence: W1 (part 1) stays the keystone, lead with it. W7 (part 2) is small + independent — fit it in after W1's repro or in parallel. Ping me the W1 root-confirmation + the part-2 byte before either patch.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


1	[package]
2	name = "spt"
3	version.workspace = true
4	edition = "2021"
5	publish = false
6	description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/list/stop/whoami) + the harness-contract `api` surface (spt-runtime)."
7
8	[[bin]]
9	name = "spt"
10	path = "src/main.rs"
11
12	[dependencies]
13	spt-proto = { path = "../spt-proto" }
14	spt-store = { path = "../spt-store" }
15	spt-msg = { path = "../spt-msg" }
16	spt-runtime = { path = "../spt-runtime" }
17	spt-live = { path = "../spt-live" }
18	spt-daemon = { path = "../spt-daemon" }
19	# The `api digest-entry` push door validates the inbound record against the
20	# published digest-record contract (`spt_term::record_to_tagged`) — REQ-TERM-4.
21	spt-term = { path = "../spt-term" }
22	# The reference binary ships the WAN transport on by default (ADR-0002). D2g reuses its
23	# RFC-6238 TotpSeed (spt_net::net::pairing::totp) to render a subnet's local pairing
24	# code — reused, not reimplemented, so the CLI code matches the ceremony's exactly.
25	spt-net = { path = "../spt-net" }
26	# `wrap_help`: wrap long help text to the terminal width with a hanging indent
27	# (without it clap emits unwrapped lines and the terminal hard-wraps to col 0).
28	clap = { version = "4", features = ["derive", "wrap_help"] }
29	# `spt rc` raw terminal mode for the attach pump (M12-W1): enter/leave raw mode
30	# so keystrokes pass through to the broker-held PTY unbuffered + unechoed. The
31	# same crate W2's ratatui picker builds on, so it lands once for the milestone.
32	crossterm = "0.28"
33	# `spt endpoint run` interactive picker (M12-W2, REQ-RUN-PICKER). ratatui's
34	# double-buffered renderer removes the manual erase/redraw + wrapped-row-counting
35	# hazard; it renders to a testable `Buffer` (buffer + state-model unit asserts,
36	# no live terminal). 0.29 pairs with crossterm 0.28. nucleo-matcher = the `/`
37	# type-to-filter fuzzy match (the searchMultiselect feel, lifted from
38	# vercel-labs/skills).
39	ratatui = "0.29"
40	nucleo-matcher = "0.3"
41	# The WAN send leg (D9-1-4) serializes the dial address + reads registry
42	# snapshots - same pinned shape as spt-daemon's.
43	serde_json = { version = "1", features = ["preserve_order"] }
44	# Terminal QR render for `spt subnet create` (M7 plan decision 11): the
45	# otpauth:// URI as unicode half-blocks. default-features off drops the
46	# image/svg renderers — the unicode renderer is all the CLI needs.
47	qrcode = { version = "0.14", default-features = false }
48	# Local-timezone rendering of the pairing code's expiry instant (std has no
49	# local tz). Clock feature only — no serde, no wasm.
50	chrono = { version = "0.4", default-features = false, features = ["clock"] }
51	# `spt update fetch` (REQ-UPD-7): HTTPS GET of the signed release from the
52	# GitHub origin. Blocking client + rustls (no OpenSSL); already in the tree
53	# transitively (iroh), so no new vendor. The fetch runs in the CLI, NOT the
54	# daemon — the daemon keeps zero HTTP surface (iroh QUIC only).
55	reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] }
56
57	# OS privilege-elevation probe for the node-bound code fetch (REQ-PAIR-6). Only the
58	# binary pulls the OS deps; the core stays dep-minimal. Matches the workspace's existing
59	# libc/windows-sys usage (spt-msg, spt-store) rather than the heavyweight `windows`.
60	[target.'cfg(unix)'.dependencies]
61	libc = "0.2"
62
63	[target.'cfg(windows)'.dependencies]
64	windows-sys = { version = "0.59", features = [
65	    "Win32_Foundation",
66	    "Win32_Security",
67	    "Win32_System_Threading",
68	    # REQ-ELEVATE-1: ShellExecuteW("runas") launches the UAC-elevated console;
69	    # GetConsoleProcessList lets the elevated child detect a fresh (sole-owner)
70	    # console so it pauses before exit instead of vanishing.
71	    "Win32_UI_Shell",
72	    "Win32_UI_WindowsAndMessaging",
73	    "Win32_System_Console",
74	] }
75
76	[dev-dependencies]
77	tempfile = "3"
78	toml = "0.8"
79	# install_e2e stages the built binary as a release asset (SHA256SUMS) for the
80	# D2 installer-script rung — same sha2 the workspace already carries.
81	sha2 = "0.10"
82	# The D7-1 process-level survival int-test (brain_survive.rs) drives the
83	# production supervisor + an in-process broker/NetHost while spawning the REAL
84	# `spt daemon brain` child (CARGO_BIN_EXE_spt). Integration tests don't inherit
85	# the lib's normal deps, so the daemon/net/identity surfaces it touches directly
86	# are re-declared here (the spt-daemon dev-dep pattern).
87	spt-daemon = { path = "../spt-daemon" }
88	spt-net = { path = "../spt-net" }
89	spt-proto = { path = "../spt-proto" }
90

[package]
name = "spt"
version.workspace = true
edition = "2021"
publish = false
description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/list/stop/whoami) + the harness-contract `api` surface (spt-runtime)."

[[bin]]
name = "spt"
path = "src/main.rs"

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
spt-daemon = { path = "../spt-daemon" }
# The `api digest-entry` push door validates the inbound record against the
# published digest-record contract (`spt_term::record_to_tagged`) — REQ-TERM-4.
spt-term = { path = "../spt-term" }
# The reference binary ships the WAN transport on by default (ADR-0002). D2g reuses its
# RFC-6238 TotpSeed (spt_net::net::pairing::totp) to render a subnet's local pairing
# code — reused, not reimplemented, so the CLI code matches the ceremony's exactly.
spt-net = { path = "../spt-net" }
# `wrap_help`: wrap long help text to the terminal width with a hanging indent
# (without it clap emits unwrapped lines and the terminal hard-wraps to col 0).
clap = { version = "4", features = ["derive", "wrap_help"] }
# `spt rc` raw terminal mode for the attach pump (M12-W1): enter/leave raw mode
# so keystrokes pass through to the broker-held PTY unbuffered + unechoed. The
# same crate W2's ratatui picker builds on, so it lands once for the milestone.
crossterm = "0.28"
# `spt endpoint run` interactive picker (M12-W2, REQ-RUN-PICKER). ratatui's
# double-buffered renderer removes the manual erase/redraw + wrapped-row-counting
# hazard; it renders to a testable `Buffer` (buffer + state-model unit asserts,
# no live terminal). 0.29 pairs with crossterm 0.28. nucleo-matcher = the `/`
# type-to-filter fuzzy match (the searchMultiselect feel, lifted from
# vercel-labs/skills).
ratatui = "0.29"
nucleo-matcher = "0.3"
# The WAN send leg (D9-1-4) serializes the dial address + reads registry
# snapshots - same pinned shape as spt-daemon's.
serde_json = { version = "1", features = ["preserve_order"] }
# Terminal QR render for `spt subnet create` (M7 plan decision 11): the
# otpauth:// URI as unicode half-blocks. default-features off drops the
# image/svg renderers — the unicode renderer is all the CLI needs.
qrcode = { version = "0.14", default-features = false }
# Local-timezone rendering of the pairing code's expiry instant (std has no
# local tz). Clock feature only — no serde, no wasm.
chrono = { version = "0.4", default-features = false, features = ["clock"] }
# `spt update fetch` (REQ-UPD-7): HTTPS GET of the signed release from the
# GitHub origin. Blocking client + rustls (no OpenSSL); already in the tree
# transitively (iroh), so no new vendor. The fetch runs in the CLI, NOT the
# daemon — the daemon keeps zero HTTP surface (iroh QUIC only).
reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] }

# OS privilege-elevation probe for the node-bound code fetch (REQ-PAIR-6). Only the
# binary pulls the OS deps; the core stays dep-minimal. Matches the workspace's existing
# libc/windows-sys usage (spt-msg, spt-store) rather than the heavyweight `windows`.
[target.'cfg(unix)'.dependencies]
libc = "0.2"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [
    "Win32_Foundation",
    "Win32_Security",
    "Win32_System_Threading",
    # REQ-ELEVATE-1: ShellExecuteW("runas") launches the UAC-elevated console;
    # GetConsoleProcessList lets the elevated child detect a fresh (sole-owner)
    # console so it pauses before exit instead of vanishing.
    "Win32_UI_Shell",
    "Win32_UI_WindowsAndMessaging",
    "Win32_System_Console",
] }

[dev-dependencies]
tempfile = "3"
toml = "0.8"
# install_e2e stages the built binary as a release asset (SHA256SUMS) for the
# D2 installer-script rung — same sha2 the workspace already carries.
sha2 = "0.10"
# The D7-1 process-level survival int-test (brain_survive.rs) drives the
# production supervisor + an in-process broker/NetHost while spawning the REAL
# `spt daemon brain` child (CARGO_BIN_EXE_spt). Integration tests don't inherit
# the lib's normal deps, so the daemon/net/identity surfaces it touches directly
# are re-declared here (the spt-daemon dev-dep pattern).
spt-daemon = { path = "../spt-daemon" }
spt-net = { path = "../spt-net" }
spt-proto = { path = "../spt-proto" }


1	//! The OS-neutral session-surface contract.
2	//!
3	//! A [`SessionSurface`] is "anything you can write input to and read output
4	//! from" — the abstraction every hosted agent session is driven through. The
5	//! native PTY backend ([`crate::pty::PtySession`]) is the M3a implementation;
6	//! M4's network-attached surface and the GUI text pane slot in behind the same
7	//! trait later.
8	//!
9	//! **OS stream-contract neutrality (Spike #4 / #5, binding):** the surface is
10	//! modeled as a byte stream with explicit, OS-independent attach/replay
11	//! semantics. The trait deliberately exposes *write input* + *resize* only and
12	//! says nothing about whether the underlying output is a raw ordered pipe
13	//! (`forkpty`) or a repaint-on-resize screen buffer (ConPTY) — output streaming
14	//! is a separate, equally OS-neutral concern (A4, [`crate::pty::PtySession`]'s
15	//! reader). No backend's stream semantics leak through this contract.
16
17	use portable_pty::PtySize;
18
19	/// Window geometry for a surface — OS-neutral (`rows × cols` plus optional pixel
20	/// hints some backends fill and others ignore). Mirrors `portable_pty::PtySize`
21	/// without leaking the dependency into the trait's public shape.
22	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23	pub struct SurfaceSize {
24	    /// Number of text rows.
25	    pub rows: u16,
26	    /// Number of text columns.
27	    pub cols: u16,
28	    /// Cell width in pixels (0 = unset; many systems ignore this).
29	    pub pixel_width: u16,
30	    /// Cell height in pixels (0 = unset; many systems ignore this).
31	    pub pixel_height: u16,
32	}
33
34	impl SurfaceSize {
35	    /// A `rows × cols` geometry with pixel hints left unset.
36	    pub const fn new(rows: u16, cols: u16) -> Self {
37	        Self {
38	            rows,
39	            cols,
40	            pixel_width: 0,
41	            pixel_height: 0,
42	        }
43	    }
44	}
45
46	impl Default for SurfaceSize {
47	    /// The conventional 24×80 terminal.
48	    fn default() -> Self {
49	        Self::new(24, 80)
50	    }
51	}
52
53	impl From<SurfaceSize> for PtySize {
54	    fn from(s: SurfaceSize) -> Self {
55	        PtySize {
56	            rows: s.rows,
57	            cols: s.cols,
58	            pixel_width: s.pixel_width,
59	            pixel_height: s.pixel_height,
60	        }
61	    }
62	}
63
64	/// A session-surface failure. Carries an operator-facing message; never panics.
65	#[derive(Debug)]
66	pub enum SurfaceError {
67	    /// The OS refused to allocate a PTY pair.
68	    OpenPty(String),
69	    /// The OS refused to spawn the child under the PTY.
70	    Spawn(String),
71	    /// An I/O operation on the surface failed (read/write/resize).
72	    Io(std::io::Error),
73	    /// The single writer handle was already taken (PTY writers are once-only).
74	    WriterTaken,
75	    /// The single reader handle was already taken.
76	    ReaderTaken,
77	}
78
79	impl std::fmt::Display for SurfaceError {
80	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81	        match self {
82	            SurfaceError::OpenPty(e) => write!(f, "failed to open a pty: {e}"),
83	            SurfaceError::Spawn(e) => write!(f, "failed to spawn a child under the pty: {e}"),
84	            SurfaceError::Io(e) => write!(f, "surface i/o failed: {e}"),
85	            SurfaceError::WriterTaken => write!(f, "the surface writer was already taken"),
86	            SurfaceError::ReaderTaken => write!(f, "the surface reader was already taken"),
87	        }
88	    }
89	}
90
91	impl std::error::Error for SurfaceError {}
92
93	/// Anything a hosted session can be driven through: input goes in, output comes
94	/// out, geometry can change. See the module docs for the OS-neutrality contract.
95	///
96	/// `Send + Sync` so the M3b broker can host the surface behind a shared handle
97	/// and drive `write_input` / `resize` from its IPC thread while a separate
98	/// reader thread drains output.
99	//
100	// [impl->REQ-TERM-1]
101	pub trait SessionSurface: Send + Sync {
102	    /// Write raw bytes to the child's input. Escape sequences pass through
103	    /// verbatim (the cooked/raw `send_line` / `send_keys` distinction lands on
104	    /// the concrete surface in A3). Flushed on return.
105	    fn write_input(&self, bytes: &[u8]) -> Result<(), SurfaceError>;
106
107	    /// Inform the surface (and thus the child) of a new window geometry. On a
108	    /// real PTY this updates the kernel winsize and raises `SIGWINCH`.
109	    fn resize(&self, size: SurfaceSize) -> Result<(), SurfaceError>;
110
111	    /// **send-keys** — raw keystroke injection. Bytes go to the child verbatim,
112	    /// including control bytes and escape sequences (e.g. `Ctrl-C` = `0x03`, an
113	    /// arrow key = `ESC [ C`). A semantic alias of [`SessionSurface::write_input`]
114	    /// for the "type these exact keys" intent. Ordering is preserved: each call
115	    /// is one serialized write.
116	    //
117	    // [impl->REQ-TERM-2]
118	    fn send_keys(&self, bytes: &[u8]) -> Result<(), SurfaceError> {
119	        self.write_input(bytes)
120	    }
121
122	    /// **send-line** — cooked line submission. Appends a carriage return (`\r`,
123	    /// the byte a real Enter keypress emits; the slave line discipline maps it
124	    /// to newline where applicable) and writes the whole line in one serialized
125	    /// flush, so a concurrent injector can never split the line.
126	    //
127	    // [impl->REQ-TERM-2]
128	    fn send_line(&self, line: &str) -> Result<(), SurfaceError> {
129	        let mut buf = Vec::with_capacity(line.len() + 1);
130	        buf.extend_from_slice(line.as_bytes());
131	        buf.push(b'\r');
132	        self.write_input(&buf)
133	    }
134	}
135

//! The OS-neutral session-surface contract.
//!
//! A [`SessionSurface`] is "anything you can write input to and read output
//! from" — the abstraction every hosted agent session is driven through. The
//! native PTY backend ([`crate::pty::PtySession`]) is the M3a implementation;
//! M4's network-attached surface and the GUI text pane slot in behind the same
//! trait later.
//!
//! **OS stream-contract neutrality (Spike #4 / #5, binding):** the surface is
//! modeled as a byte stream with explicit, OS-independent attach/replay
//! semantics. The trait deliberately exposes *write input* + *resize* only and
//! says nothing about whether the underlying output is a raw ordered pipe
//! (`forkpty`) or a repaint-on-resize screen buffer (ConPTY) — output streaming
//! is a separate, equally OS-neutral concern (A4, [`crate::pty::PtySession`]'s
//! reader). No backend's stream semantics leak through this contract.

use portable_pty::PtySize;

/// Window geometry for a surface — OS-neutral (`rows × cols` plus optional pixel
/// hints some backends fill and others ignore). Mirrors `portable_pty::PtySize`
/// without leaking the dependency into the trait's public shape.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SurfaceSize {
    /// Number of text rows.
    pub rows: u16,
    /// Number of text columns.
    pub cols: u16,
    /// Cell width in pixels (0 = unset; many systems ignore this).
    pub pixel_width: u16,
    /// Cell height in pixels (0 = unset; many systems ignore this).
    pub pixel_height: u16,
}

impl SurfaceSize {
    /// A `rows × cols` geometry with pixel hints left unset.
    pub const fn new(rows: u16, cols: u16) -> Self {
        Self {
            rows,
            cols,
            pixel_width: 0,
            pixel_height: 0,
        }
    }
}

impl Default for SurfaceSize {
    /// The conventional 24×80 terminal.
    fn default() -> Self {
        Self::new(24, 80)
    }
}

impl From<SurfaceSize> for PtySize {
    fn from(s: SurfaceSize) -> Self {
        PtySize {
            rows: s.rows,
            cols: s.cols,
            pixel_width: s.pixel_width,
            pixel_height: s.pixel_height,
        }
    }
}

/// A session-surface failure. Carries an operator-facing message; never panics.
#[derive(Debug)]
pub enum SurfaceError {
    /// The OS refused to allocate a PTY pair.
    OpenPty(String),
    /// The OS refused to spawn the child under the PTY.
    Spawn(String),
    /// An I/O operation on the surface failed (read/write/resize).
    Io(std::io::Error),
    /// The single writer handle was already taken (PTY writers are once-only).
    WriterTaken,
    /// The single reader handle was already taken.
    ReaderTaken,
}

impl std::fmt::Display for SurfaceError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SurfaceError::OpenPty(e) => write!(f, "failed to open a pty: {e}"),
            SurfaceError::Spawn(e) => write!(f, "failed to spawn a child under the pty: {e}"),
            SurfaceError::Io(e) => write!(f, "surface i/o failed: {e}"),
            SurfaceError::WriterTaken => write!(f, "the surface writer was already taken"),
            SurfaceError::ReaderTaken => write!(f, "the surface reader was already taken"),
        }
    }
}

impl std::error::Error for SurfaceError {}

/// Anything a hosted session can be driven through: input goes in, output comes
/// out, geometry can change. See the module docs for the OS-neutrality contract.
///
/// `Send + Sync` so the M3b broker can host the surface behind a shared handle
/// and drive `write_input` / `resize` from its IPC thread while a separate
/// reader thread drains output.
//
// [impl->REQ-TERM-1]
pub trait SessionSurface: Send + Sync {
    /// Write raw bytes to the child's input. Escape sequences pass through
    /// verbatim (the cooked/raw `send_line` / `send_keys` distinction lands on
    /// the concrete surface in A3). Flushed on return.
    fn write_input(&self, bytes: &[u8]) -> Result<(), SurfaceError>;

    /// Inform the surface (and thus the child) of a new window geometry. On a
    /// real PTY this updates the kernel winsize and raises `SIGWINCH`.
    fn resize(&self, size: SurfaceSize) -> Result<(), SurfaceError>;

    /// **send-keys** — raw keystroke injection. Bytes go to the child verbatim,
    /// including control bytes and escape sequences (e.g. `Ctrl-C` = `0x03`, an
    /// arrow key = `ESC [ C`). A semantic alias of [`SessionSurface::write_input`]
    /// for the "type these exact keys" intent. Ordering is preserved: each call
    /// is one serialized write.
    //
    // [impl->REQ-TERM-2]
    fn send_keys(&self, bytes: &[u8]) -> Result<(), SurfaceError> {
        self.write_input(bytes)
    }

    /// **send-line** — cooked line submission. Appends a carriage return (`\r`,
    /// the byte a real Enter keypress emits; the slave line discipline maps it
    /// to newline where applicable) and writes the whole line in one serialized
    /// flush, so a concurrent injector can never split the line.
    //
    // [impl->REQ-TERM-2]
    fn send_line(&self, line: &str) -> Result<(), SurfaceError> {
        let mut buf = Vec::with_capacity(line.len() + 1);
        buf.extend_from_slice(line.as_bytes());
        buf.push(b'\r');
        self.write_input(&buf)
    }
}


7:# ACTIVATION MODEL: every requirement starts inactive (`required_stages = []`)
8-# so `check` stays green pre-code. A milestone (M0-M5) ACTIVATES its requirements
9:# by setting their real `required_stages`. Validate this seed against the
10-# installed CLI at M0 (the binary isn't installed yet).
11-
12-[scan]
13-# CONTEXT.md is a file root: the glossary is authoritative for meaning and
14-# carries doc-stage tags (e.g. §Installation). installer/ added M6-D2.
15-roots = ["src", "tests", "docs", "crates", "installer", "CONTEXT.md", "docs-site", ".github"]
--
19:# reqs add "int". Until a req is activated, its per-req `required_stages = []`
20-# keeps it out of the coverage gate.
21:required_stages = []
22-
23-# ───────────────────────────── Architecture / workspace ─────────────────────
24-[[requirements]]
25-id = "REQ-ARCH-1"
26-title = "Many small acyclically-layered crates"
27:required_stages = ["impl"]  # activated M0: spt-proto<-spt-store one-way layering (T14). impl-only: architecture is build-evidenced (cargo rejects cycles); grows as crates are added M1+.
28-[[requirements]]
29-id = "REQ-ARCH-2"
30-title = "Public SDK surface is spt-proto, spt-runtime, spt-msg"
31:required_stages = ["impl"]
32-[[requirements]]
33-id = "REQ-ARCH-3"
34-title = "Wire-protocol version independent of crate semver, N-1 compat window"
35:required_stages = ["impl", "unit"]  # activated M0: spt-proto wire version + compat window (T6)
36-[[requirements]]
37-id = "REQ-ARCH-4"
38-title = "Copy-verbatim the commodity layer from the sister project"
39:required_stages = ["impl", "unit"]  # activated M0: spt-proto EVENT grammar (T2)
40-
41-# ───────────────────────────── Daemon ───────────────────────────────────────
42-[[requirements]]
43-id = "REQ-DAEMON-1"
44-title = "One per-machine spt-daemon owning all per-machine state"
45:required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E (spawn→Psyche-loop→commune→brain-restart-survives→graceful-signoff) drives the consolidated daemon-hosted lifecycle through the real IPC
46-[[requirements]]
47-id = "REQ-DAEMON-2"
48-title = "Broker/brain split for seamless self-update"
49:[Omitted long matching line]
50-[[requirements]]
51-id = "REQ-DAEMON-3"
52-title = "Any api invocation auto-starts the daemon if absent"
53:required_stages = ["impl", "unit", "int"]
54-[[requirements]]
55-id = "REQ-DAEMON-4"
56-title = "Honor every KNOWN-HAZARDS invariant"
57:required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E proves graceful signoff runs the echo-before-teardown ordering (3.3) and removes the Self's ready marker in the consolidated daemon
58-
59-# ───────────────────────────── Storage ──────────────────────────────────────
60-# git-KV BranchStore substrate (ADR-0011, 2026-06-02; implement M4, optional M3c)
61-[[requirements]]
62-id = "REQ-STORE-1"
63-title = "spt-store::BranchStore (git branch as versioned KV; commit=checkpoint/tip=resume, atomic multi-key, merge-native sync) is the substrate for coarse/durable/audited state (context, registry snapshot+distribution, daemon checkpoint); hot paths (B5 fsync journal) + indexed queries (SQLite spool) excluded (ADR-0011)"
64:[Omitted long matching line]
65-
66-# ───────────────────────────── Harness contract — manifest seams ────────────
67-[[requirements]]
68-id = "REQ-MANIFEST-1"
69-title = "Per-adapter manifest with adapter_name and min_spt_core_version"
70:required_stages = ["doc", "impl", "unit"]
71-[[requirements]]
72-id = "REQ-MANIFEST-2"
73-title = "Adapter profiles — sparse leaf-replace overlays (shipped + local), composite <adapter>:<profile> addressing, shadow-refusal, tighten-only consent floors"
74:required_stages = ["doc", "impl", "unit"]
75-[[requirements]]
76-id = "REQ-MANIFEST-3"
77-title = "Adapter strings — [strings] KV tree, dot-path get-string resolving through the profile leaf-replace overlay, set-string editing a local profile's [strings] only; data-only (nothing executes a string)"
78:required_stages = ["doc", "impl", "unit"]  # activated 2026-06-12: M9-T2 (CONTEXT.md §adapter strings, adapter-string get/set CLI + overlay resolution)
79-[[requirements]]
80-id = "REQ-MANIFEST-4"
81-title = "Keyword hints — [[hints]] {keywords (literal/regex), text}; spt api hint --session emits at most one matched hint per message, once per session (seen-set), declaration-order first match; profiles overlay [[hints]] by leaf-replace"
82:required_stages = ["doc", "impl", "unit"]  # activated 2026-06-12: M9-T3 (CONTEXT.md §keyword hints, api hint surface + per-session seen-set)
83-[[requirements]]
84-id = "REQ-MANIFEST-5"
85-[Omitted long context line]
86:required_stages = ["doc", "impl", "unit"]  # activated M12-W3
87-[[requirements]]
88-id = "REQ-MANIFEST-6"
89-[Omitted long context line]
90:required_stages = ["doc", "unit"]  # activated M12-W3 (contract-only: no int)
91-[[requirements]]
92-id = "REQ-MANIFEST-7"
93-[Omitted long context line]
94:required_stages = ["doc", "impl", "unit"]  # activated M12-W2 follow-on
95-[[requirements]]
96-id = "REQ-MANIFEST-8"
97-[Omitted long context line]
98:[Omitted long matching line]
99-[[requirements]]
100-id = "REQ-SEAM-SPAWN"
101-title = "spawn-session seam"
102:required_stages = ["impl", "unit"]
103-[[requirements]]
104-id = "REQ-SEAM-POSTSPAWN"
105-title = "post-spawn / api bind seam with boot nonce"
106:required_stages = ["impl", "unit"]
107-[[requirements]]
108-id = "REQ-SEAM-PSYCHE"
109-title = "spawn-psyche seam (fresh + resume templates)"
110:required_stages = ["impl", "unit", "int"]
111-[[requirements]]
112-id = "REQ-SEAM-HISTORY"
113-title = "History subsystem (fetcher / locate-normalize / native store)"
114:required_stages = ["impl", "unit", "int"]
115-[[requirements]]
116-id = "REQ-SEAM-ACTIVITY"
117-title = "Activity/idle reported via api sentinels, not PTY quiescence"
118:required_stages = ["impl", "unit"]
119-[[requirements]]
120-id = "REQ-SEAM-INJECT"
121-title = "inject-input methods configurable per activity-state"
122:required_stages = ["impl", "unit"]
123-[[requirements]]
124-id = "REQ-SEAM-RESUME"
125-title = "resume-session seam (fresh-with-preload / continue-existing)"
126:required_stages = ["impl", "unit"]
127-[[requirements]]
128-id = "REQ-SEAM-CAPABILITY"
129-title = "Hostable endpoint-types capability declaration"
130:required_stages = ["impl", "unit"]
131-[[requirements]]
132-id = "REQ-SEAM-UPDATE"
133-title = "Adapter-update avenue (file-pull / delegated command)"
134:required_stages = ["impl", "unit"]  # activated M3c-C3: manifest [update] gains signing_key (file_pull content key) + self_verifies (delegated attest); adapter_update::plan_adapter_update dispatches each avenue (file_pull verified against the adapter key, delegated delegated-or-skipped)
135-
136-# ───────────────────────────── Harness contract — api surface ───────────────
137-[[requirements]]
138-id = "REQ-API-1"
139-title = "api prefix and adapter_name on every machinery invocation"
140:required_stages = ["impl", "unit", "int"]
141-[[requirements]]
142-id = "REQ-API-2"
143-title = "The api subcommand surface (bind/listen/poll/state/worker/boundary/...)"
144:required_stages = ["impl", "unit", "int"]
145-[[requirements]]
146-id = "REQ-API-3"
147-title = "commune/signoff are file-drops, not commands"
148:required_stages = ["impl", "unit", "int"]
149-[[requirements]]
150-id = "REQ-API-4"
151-[Omitted long context line]
152:[Omitted long matching line]
153-
154-# ───────────────────────────── Startup flows ────────────────────────────────
155-[[requirements]]
156-id = "REQ-START-1"
157-title = "Adapters never resolve SPT_HOME; binary on PATH; api bridging only"
158:required_stages = ["impl", "unit"]
159-[[requirements]]
160-id = "REQ-START-2"
161-title = "Harness-hosted startup: api seed then listen"
162:required_stages = ["impl", "unit", "int"]
163-[[requirements]]
164-id = "REQ-START-3"
165-title = "spt-hosted startup: spawn-session then api bind (no file)"
166:required_stages = ["impl", "unit", "int"]
167-[[requirements]]
168-id = "REQ-START-4"
169-title = "Adapter-injected env aliases (SPT/OWL/LIVE)"
170:required_stages = ["impl", "unit"]
171-[[requirements]]
172-id = "REQ-START-5"
173-[Omitted long context line]
174:[Omitted long matching line]
175-
176-# ───────────────────────────── Endpoints / Shells ───────────────────────────
177-[[requirements]]
178-id = "REQ-EP-1"
179-title = "Day-one endpoint types; open type system"
180:required_stages = ["impl", "unit"]  # activated M0: spt-proto endpoint taxonomy (T4)
181-[[requirements]]
182-id = "REQ-EP-2"
183-title = "Agent endpoints vs Shells distinction in the type model"
184:required_stages = ["impl", "unit"]  # activated M0: spt-proto endpoint taxonomy (T4)
185-[[requirements]]
186-id = "REQ-EP-3"
187-title = "Messaging payloads carry typed operation commands + file blobs"
188:required_stages = ["impl", "unit"]  # activated M0: spt-proto payload model (T7)
189-[[requirements]]
190-id = "REQ-EP-4"
191-title = "PresenceChannel broker endpoint (seam day-one)"
192:[Omitted long matching line]
193-[[requirements]]
194-id = "REQ-EP-5"
195-title = "Concrete shell instantiation model: spawn-mints-instance (vs relink/online), registered-on-node permission + broadcast-is-discovery, per-shell require_approval gate, max_instances_per_owner + over_cap, instance aliasing, discovery scope"
196:required_stages = ["impl", "unit", "int"]  # activated M5-D3a; int activated M5-D9b: the D3e shell E2E + the real-shell E2E (notify_shell_e2e.rs, CI-hook-driven against the standalone spt-shell-notify adapter) carry the int tags
197-[[requirements]]
198-id = "REQ-EP-6"
199-title = "Gateway type acceptance: a Gateway-typed perch binds (api bind --type, open type system — un-hardcode the live_agent default), advertises/addressable like any endpoint, owns shells (owner validation not agent-family-gated), subscribes to digests, and is the user-msg identity gate's user-backed origin (REQ-MSG-5); in-tree mock-gateway fixture (R-DOCS-2 pattern, no downstream adapter code). Cross-node WAN Gateway-origin (registry endpoint_type trust) tracked by REQ-MSG-6"
200:required_stages = ["doc", "impl", "unit"]  # activated 2026-06-13: M9-T5 (CONTEXT.md §Gateway; api bind --type un-hardcode at establish_perch; gateway-owns-shell + local user-backed-origin E2E via mock-gateway fixture; the WAN fail-closed row tested at the receive_wan funnel)
201-[[requirements]]
202-id = "REQ-EP-7"
203-[Omitted long context line]
204:required_stages = ["doc", "impl", "unit"]  # activated 2026-06-13: M9-T6 (CONTEXT.md §live role; contextstore live_role_file/live_role_path seam; resume::download_psyche_context role-first injection order; spt endpoint role print/--overwrite sole writer; injection-order + no-automated-writer tests; M9-WAVE2-ROLE-WRITERS.md survey)
205-
206-# ───────────────────────────── Instances ────────────────────────────────────
207-[[requirements]]
208-id = "REQ-INST-1"
209-title = "endpoint ID vs instance split (adapter-agnostic ID)"
210:required_stages = []  # rule 5: the data-model split shipped across M4 D3 (registry Instance rows) + D9-5 (home/adapter on the endpoint record); this umbrella req activates when instantiate-anywhere makes the split user-facing (instance minting) — which DEFERS PAST M5 (user decision 2026-06-04, M5-PLAN scope decision 2: consent framework seam only); its concrete slices are tracked by REQ-INST-7..15
211-[[requirements]]
212-id = "REQ-INST-2"
213-title = "Per-node files, synced Psyche mind"
214:[Omitted long matching line]
215-[[requirements]]
216-id = "REQ-INST-3"
217-title = "Dormant (warm) / suspended (cold) resting states"
218:[Omitted long matching line]
219-[[requirements]]
220-id = "REQ-INST-4"
221-title = "active to dormant/suspended fires a transition echo commune"
222:[Omitted long matching line]
223-[[requirements]]
224-id = "REQ-INST-5"
225-title = "Two-tier context sync (live to all, project to same-project)"
226:[Omitted long matching line]
227-[[requirements]]
228-id = "REQ-INST-6"
229-title = "Deferred messages not delivered to dormant/suspended instances"
230:[Omitted long matching line]
231-[[requirements]]
232-id = "REQ-INST-7"
233-title = "Subnet registry + bare-id resolution policy"
234:[Omitted long matching line]
235-[[requirements]]
236-id = "REQ-INST-8"
237-title = "Remote-control mode distinct from local operation"
238:[Omitted long matching line]
239-# ── Multi-subnet membership model (ADR-0006, 2026-06-01; activate M4) ──
240-[[requirements]]
241-id = "REQ-INST-9"
242-title = "Multi-subnet membership (same-user N subnets; cross-user seam)"
243:[Omitted long matching line]
244-[[requirements]]
245-id = "REQ-INST-10"
246-title = "Qualified addressing [subnet:]id[@node] + ambiguity forces qualification"
247:[Omitted long matching line]
248-[[requirements]]
249-id = "REQ-INST-11"
250-title = "spt rename <id> rippled to all instances (collision-checked, 6.5-reconciled)"
251:[Omitted long matching line]
252-[[requirements]]
253-id = "REQ-INST-12"
254-title = "Endpoint visibility per-(endpoint,subnet): excluded semantics, OR-of-defaults + override, gates sync"
255:[Omitted long matching line]
256-[[requirements]]
257-id = "REQ-INST-13"
258-title = "Subnet-exclusive sync + per-endpoint subnet-membership list"

[Showing results with pagination = limit: 250]


doyle high-priority update absorbed. Root sharpened + a sibling bug (W7). Acking now before continuing.


Bash

$OWL send doyle <<'EOF'
ACK both parts.

PART 1 (W1 keystone) — got it. Your code-trace: Broker::append (broker.rs:205-227) fans each chunk to the CONTROLLER via a SYNCHRONOUS BLOCKING write_frame INLINE on the session drain thread (authoritative delivered_through path), while VIEWERS get a dedicated writer thread + bounded evicting sync_channel. Controller socket backpressure (or 64KB loopback duplex full) → drain blocks → output+echo stall → wedge. Fires on NORMAL heavy harness output (TUI redraw), not just injection — same root.

Note from my own trace before your msg: I'd located a SECOND inline-blocking point on the INPUT side — write_input (pty.rs:238 write_all under the shared writer mutex; dispatch_endpoint_input:1087 / dispatch_input:1027 call it ON the dispatch thread; the drain's DSR auto-answer reader.rs:136 contends for the same writer). That's a distinct park point (input-side) from your append (output-side). Will confirm yours is THE operator-visible one and report whether mine is a real second facet or benign. Repro will cover BOTH triggers: (a) injection, (b) output flood w/ controller socket backpressure — assert echo latency bounded + no wedge, independent of injection.

Plan: instrument real dummy-harness fixture + real daemon, PROVE append's write_frame is the park point (and check the write_input facet), then ping you with the confirmed root BEFORE any patch.

PART 2 (W7, REQ-HAZARD-RC-INPUT-KEY-ENCODING) — got it. rc raw verbatim pump (spawn_stdin_reader rc.rs:152), no console-mode setup → Windows legacy console emits ^H(0x08) not VT DEL(0x7f) → CC backward-kill-word. CONFIRM-FIRST plan: add SPT_RC_DEBUG_KEYS env-gated hexdump in spawn_stdin_reader; operator presses Backspace + ctrl+Backspace in a real rc session; capture forwarded byte. Will ping you the byte BEFORE the fix. Preferred fix = enable ENABLE_VIRTUAL_TERMINAL_INPUT on rc stdin console (Windows).

Sequence acked: W1 part1 = keystone, lead with it; W7 fits after W1 repro / parallel. No patch on either until I ping you the W1 root-confirmation + the part-2 byte.
EOF

Ack doyle W1 update + W7 sibling bug


➜ SENT:doyle


Acked. Now confirm doyle's root in code: read `OutputLog::append`, `become_controller`, `add_viewer`, `viewer_writer` (broker.rs:120-300, 460-490).