grep -n "fn .*() {" crates/spt-daemon/tests/inject_control_wedge.rs | grep -v "fn unique_name\|fn kill_pid\|fn flood\|fn send\|fn probe\|fn init_wedge\|fn hermetic\|fn net_broker\|fn connect_retry\|fn count"; echo "---test count---"; grep -c "#\[test\]" crates/spt-daemon/tests/inject_control_wedge.rs; echo "---serial in tree?---"; grep -rn "serial_test" Cargo.toml crates/*/Cargo.toml 2>/dev/null

List test fns + check serial_test presence in workspace


141:fn a_backed_up_controller_does_not_wedge_the_session() {
220:fn injecting_a_large_payload_into_a_stdin_ignoring_child() {
442:fn a_journaled_input_wedge_does_not_starve_a_concurrent_rc_attach() {
---test count---
3
---serial in tree?---


373	fn init_wedge_home() {
374	    WEDGE_HOME.get_or_init(|| {
375	        let dir = tempfile::TempDir::new().expect("temp dir");
376	        std::env::set_var("SPT_HOME", dir.path());
377	        dir
378	    });
379	}
380
381	fn hermetic() -> NetConfig {
382	    NetConfig {
383	        identity: Identity::generate(),
384	        relay: RelayPolicy::Disabled,
385	        discovery: LocalDiscovery::Off,
386	        scope: BindScope::Loopback,
387	        membership: None,
388	    }
389	}
390
391	fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
392	    let host = NetHost::start(hermetic()).expect("net host start");
393	    let broker =
394	        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
395	    let serve = Arc::clone(&broker);
396	    thread::spawn(move || {
397	        let _ = serve.serve();
398	    });
399	    broker
400	}
401
402	fn connect_retry(name: &str) -> Brain {

fn init_wedge_home() {
    WEDGE_HOME.get_or_init(|| {
        let dir = tempfile::TempDir::new().expect("temp dir");
        std::env::set_var("SPT_HOME", dir.path());
        dir
    });
}

fn hermetic() -> NetConfig {
    NetConfig {
        identity: Identity::generate(),
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
        membership: None,
    }
}

fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
    let host = NetHost::start(hermetic()).expect("net host start");
    let broker =
        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });
    broker
}

fn connect_retry(name: &str) -> Brain {


`init_wedge_home` does `std::env::set_var("SPT_HOME")` — a process-global mutation shared across parallel test threads. That race + parallel brokers = the hang. A doc note only warns; a `#[serial]` guard mechanically prevents it. Check if serial_test is already vendored:


Bash

grep -n "serial_test\|name = \"serial\"" Cargo.lock | head; echo "EXIT=$?"

Check serial_test in Cargo.lock


3659:name = "serial"
EXIT=0


serial_test not vendored — adding it means a network fetch + new workspace dep for a dev-only footgun, when CI is already safe via nextest. doyle sized F1 as "serial_test guard OR module-doc note." Proportionate call: **module-doc note** (zero-dep, documents the real runner contract + the root). Add prominent note at file top:


26	//! asserts that fixed behavior.
27
28	use std::sync::Arc;
29	use std::thread;
30	use std::time::Duration;
31
32	use interprocess::local_socket::Stream;
33	use spt_daemon::codec::{read_frame, write_frame};
34	use spt_daemon::frame::{Envelope, Role};
35	use spt_daemon::msg::{
36	    encode_bytes, EndpointInputReq, SpawnReq, Spawned, KIND_ENDPOINT_INJECTED, KIND_ENDPOINT_INPUT,
37	    KIND_NET_STATUS, KIND_NET_STATUS_REPLY, KIND_SESSIONS, KIND_SESSIONS_REPLY, KIND_SPAWN,
38	    KIND_SPAWNED,
39	};
40	use spt_daemon::transport::{send_hello, LocalSocketTransport};
41	use spt_daemon::Broker;
42
43	// ── W1b additions (REQ-HAZARD-EFFECT-JOURNAL-PTY-WEDGE): the real-fixture attach
44	//    path, the Brain client API, and a net-backed broker for loopback attach. ──
45	use std::sync::atomic::{AtomicBool, AtomicU64};
46	use std::sync::OnceLock;
47	use spt_daemon::attach::{request_attach, serve_attach};
48	use spt_daemon::brain::{Brain, BrokerEvent};
49	use spt_daemon::msg::decode_bytes;
50	use spt_daemon::nethost::{NetConfig, NetHost};
51	use spt_net::net::attach::{AttachDecoder, AttachIntent, AttachRecord};
52	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
53	use spt_proto::identity::Identity;
54
55	use std::sync::atomic::{AtomicU32, Ordering};
56	static SEQ: AtomicU32 = AtomicU32::new(0);
57	fn unique_name() -> String {

//! asserts that fixed behavior.

use std::sync::Arc;
use std::thread;
use std::time::Duration;

use interprocess::local_socket::Stream;
use spt_daemon::codec::{read_frame, write_frame};
use spt_daemon::frame::{Envelope, Role};
use spt_daemon::msg::{
    encode_bytes, EndpointInputReq, SpawnReq, Spawned, KIND_ENDPOINT_INJECTED, KIND_ENDPOINT_INPUT,
    KIND_NET_STATUS, KIND_NET_STATUS_REPLY, KIND_SESSIONS, KIND_SESSIONS_REPLY, KIND_SPAWN,
    KIND_SPAWNED,
};
use spt_daemon::transport::{send_hello, LocalSocketTransport};
use spt_daemon::Broker;

// ── W1b additions (REQ-HAZARD-EFFECT-JOURNAL-PTY-WEDGE): the real-fixture attach
//    path, the Brain client API, and a net-backed broker for loopback attach. ──
use std::sync::atomic::{AtomicBool, AtomicU64};
use std::sync::OnceLock;
use spt_daemon::attach::{request_attach, serve_attach};
use spt_daemon::brain::{Brain, BrokerEvent};
use spt_daemon::msg::decode_bytes;
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_net::net::attach::{AttachDecoder, AttachIntent, AttachRecord};
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_proto::identity::Identity;

use std::sync::atomic::{AtomicU32, Ordering};
static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {


Edit

//! asserts that fixed behavior.

use std::sync::Arc;

//! asserts that fixed behavior.
//!
//! ## RUNNER CONTRACT — run process-per-test (nextest) or single-threaded (W1b F1)
//!
//! These tests share ONE process-global `SPT_HOME` (set once via
//! [`std::env::set_var`] in [`init_wedge_home`]) and each spins a REAL broker +
//! PTY child. Under the default `cargo test` harness — N tests as parallel
//! THREADS in ONE process — the `set_var` mutation races and the concurrent
//! brokers contend, which HANGS on Windows (cost a 30-min wedge during W1b).
//!
//! Run them isolated:
//!   - `cargo nextest run --test inject_control_wedge`  (process-per-test — CI's path, always safe), or
//!   - `cargo test --test inject_control_wedge -- --test-threads=1`  (single-threaded fallback).
//!
//! Do NOT run a bare `cargo test --test inject_control_wedge` on Windows.

use std::sync::Arc;


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\inject_control_wedge.rs has been updated successfully. (file state is current in your context — no need to Read it back)

//! asserts that fixed behavior.

use std::sync::Arc;

//! asserts that fixed behavior.
//!
//! ## RUNNER CONTRACT — run process-per-test (nextest) or single-threaded (W1b F1)
//!
//! These tests share ONE process-global `SPT_HOME` (set once via
//! [`std::env::set_var`] in [`init_wedge_home`]) and each spins a REAL broker +
//! PTY child. Under the default `cargo test` harness — N tests as parallel
//! THREADS in ONE process — the `set_var` mutation races and the concurrent
//! brokers contend, which HANGS on Windows (cost a 30-min wedge during W1b).
//!
//! Run them isolated:
//!   - `cargo nextest run --test inject_control_wedge`  (process-per-test — CI's path, always safe), or
//!   - `cargo test --test inject_control_wedge -- --test-threads=1`  (single-threaded fallback).
//!
//! Do NOT run a bare `cargo test --test inject_control_wedge` on Windows.

use std::sync::Arc;

//! after `CONTROLLER_WRITE_DEADLINE`. So even while the controller is fully backed

//! up, the session does NOT wedge — `KIND_SESSIONS` still answers. THIS test

+//! ## RUNNER CONTRACT — run process-per-test (nextest) or single-threaded (W1b F1)

+//! These tests share ONE process-global `SPT_HOME` (set once via

+//! [`std::env::set_var`] in [`init_wedge_home`]) and each spins a REAL broker +

+//! PTY child. Under the default `cargo test` harness — N tests as parallel

+//! THREADS in ONE process — the `set_var` mutation races and the concurrent

+//! brokers contend, which HANGS on Windows (cost a 30-min wedge during W1b).

+//!   - `cargo nextest run --test inject_control_wedge`  (process-per-test — CI's path, always safe), or

+//!   - `cargo test --test inject_control_wedge -- --test-threads=1`  (single-threaded fallback).

+//! Do NOT run a bare `cargo test --test inject_control_wedge` on Windows.


Doc-only. Quick compile-check the test target + commit (F1 adds no evidence, so no traceable change needed):


Bash

cargo test -p spt-daemon --test inject_control_wedge --no-run 2>&1 | tail -4

Compile-check the int test target


   Compiling spt-daemon v0.12.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 4.67s
  Executable tests\inject_control_wedge.rs (target\debug\deps\inject_control_wedge-237ab1e3f78e76cc.exe)


git add -A && git commit -m "$(cat <<'EOF'
docs(test): W1b F1 — runner-contract note on inject_control_wedge (no bare cargo test)

These int tests share one process-global SPT_HOME (set_var) + each spins a
real broker/PTY; the default parallel-thread harness races set_var and the
brokers contend -> HANGS on Windows (cost 30min in W1b). CI is safe (nextest
= process-per-test). Document the contract: run via nextest or
--test-threads=1; never a bare `cargo test --test inject_control_wedge`.

Co-authored by: todlando
EOF
)"

Commit F1 doc guard


warning: in the working copy of 'crates/spt-daemon/tests/inject_control_wedge.rs', LF will be replaced by CRLF the next time Git touches it
[v0.13.0-delivery-control 6034479] docs(test): W1b F1 — runner-contract note on inject_control_wedge (no bare cargo test)
 1 file changed, 14 insertions(+)


W7-swap @95f742f and F1 @6034479 both done, all gates green. Surface to doyle for gating, then start the big W2 Layer C build.


$OWL send doyle <<'EOF'
SURFACING for gate — W7-swap + F1 banked, both green.

W7-swap @95f742f: normalize_key_byte one-way -> SWAP (0x08<->0x7f). CC reads ^H=kill-word, DEL=char-delete, so swapping input bytes => Backspace=char-delete + Ctrl+Backspace=word-delete (native Win11). cfg(windows); 0x02 detach consumed upstream; else passthrough. Updated: rc.rs fn+doc+forward-path comment, unit normalize_maps_backspace (asserts 0x7f->0x08), KH 7.13 (one-way->swap, heading+body+table row), REQ note (extend [impl,unit], no re-mint). Gates: clippy workspace clean, unit pass, traceable EXIT=0 (RC-INPUT-KEY-ENCODING [impl,unit] OK), real bin rebuilt.

F1 @6034479 (doc-only): module-doc runner-contract note on inject_control_wedge — root is shared process-global SPT_HOME (set_var) racing under the parallel-thread harness + contending brokers => Windows hang. Chose the doc note over serial_test (not vendored; CI already safe via nextest). Documents: nextest or --test-threads=1; never bare cargo test --test.

Next: starting W2 Layer C proper (broker atomic-apply: inject-worker thread owns TranslationChild+Receiver<KeyCmd>, per-event open-floor->feed->drain key/text/delay/commit->close+flush, INJECT_COMMIT_DEADLINE+FAULT, dispatch_input buffer-if-held + {type:input} ping). Layer G int via spt-test-engineer at the end. Will surface Layer C for your gate when built. Gate W7/F1 at your pace — not blocking on it.
EOF
echo "SENT exit=$?"

Surface W7-swap + F1 to doyle


➜ SENT:doyle
SENT exit=0


Surfaced. Now **W2 Layer C proper** — the keystone build. First map the banked kernel pieces (InjectFloor, TranslationChild, dispatch paths).


echo "=== InjectFloor ==="; grep -rln "InjectFloor\|inject_floor\|buffer_if_held" crates/ ; echo "=== dispatch_input / dispatch_endpoint_input ==="; grep -rln "fn dispatch_input\|fn dispatch_endpoint_input\|translation_binary\|TranslationChild" crates/; echo "=== files ==="; ls crates/spt-daemon/src/

Locate kernel pieces + dispatch sites


=== InjectFloor ===
crates/spt-daemon/src/translation.rs
=== dispatch_input / dispatch_endpoint_input ===
crates/spt/tests/brain_survive.rs
crates/spt-daemon/src/applyhost.rs
crates/spt-daemon/src/broker.rs
crates/spt-daemon/src/harnesshost.rs
crates/spt-daemon/src/livehost.rs
crates/spt-daemon/src/msg.rs
crates/spt-daemon/src/shellhost.rs
crates/spt-daemon/src/translation.rs
crates/spt-daemon/tests/attach.rs
crates/spt-daemon/tests/brain_swap.rs
crates/spt-daemon/tests/broker.rs
crates/spt-daemon/tests/budget.rs
crates/spt-daemon/tests/daemon_e2e.rs
crates/spt-daemon/tests/dispatch.rs
crates/spt-daemon/tests/handoff.rs
crates/spt-daemon/tests/idempotent.rs
crates/spt-daemon/tests/inject_control_wedge.rs
crates/spt-daemon/tests/resume.rs
crates/spt-daemon/tests/twohost.rs
crates/spt-runtime/src/manifest.rs
=== files ===
access.rs
adapter_update.rs
applyhost.rs
attach.rs
brain.rs
brainproc.rs
broker.rs
codec.rs
config.rs
consent.rs
daemon.rs
deadline.rs
deelevate.rs
digest.rs
digesthub.rs
dispatch.rs
drivehub.rs
effect.rs
endpoint.rs
frame.rs
grants.rs
harnesshost.rs
lib.rs
lifecycle.rs
linkhost.rs
livehost.rs
machineid.rs
msg.rs
nethost.rs
notif.rs
notifsync.rs
pairhost.rs
presence.rs
propagate.rs
psyrelay.rs
pump
reap.rs
reconcile.rs
registryhost.rs
relay.rs
relcache.rs
release.rs
resthost.rs
resting.rs
rollback_compat.rs
seedmap.rs
seedproofx.rs
serveprobe.rs
service.rs
shellchan.rs
shelldisc.rs
shellhost.rs
shellwake.rs
sync.rs
test_home.rs
translation.rs
transport.rs
tunnelhub.rs
update.rs
wan.rs
xfer.rs


1	//! ADR-0022 idle-delivery **translation-binary** driver (v0.13.0 W2 Layer B).
2	//!
3	//! An adapter may declare a `[message-idle-translation-binary]` (see
4	//! [`spt_runtime::manifest::IdleTranslationBinary`]). It is a **pure
5	//! stdin→stdout JSON-lines filter** spt-core lifecycle-manages: spt-core feeds it
6	//! the inbound `<EVENT>` feed plus content-free user-activity pings, and reads
7	//! back keystroke-commands which spt-core (NOT the binary) applies to the
8	//! broker-held PTY. The binary never touches the PTY — that is what lets injection
9	//! coexist atomically with a live `spt rc` controller (the W1 coordination,
10	//! REQ-HAZARD-INJECT-CONTROL-COEXIST). This module owns only the **process + wire**
11	//! seam (spawn, the two JSON-lines protocols, the send-keys byte map, and a
12	//! bounded reap); the atomic PTY apply + controller-input buffering lives in the
13	//! broker (Layer C).
14	//!
15	//! **Privacy invariant (doyle gate #1):** the `input` ping is CONTENT-FREE — the
16	//! operator's PTY keystrokes are never duplicated into the binary's stdin. See
17	//! [`ToBinary::Input`].
18	//!
19	//! **No-zombie invariant (doyle gate #2):** dropping a [`TranslationChild`] closes
20	//! its stdin (EOF → a well-behaved filter exits), waits a bounded window, then
21	//! kills + reaps — a session-down never leaks a translation child.
22	//
23	// [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
24
25	use std::io::{self, BufRead, BufReader, Write};
26	use std::process::{Child, ChildStdin, Command, Stdio};
27	use std::sync::mpsc::Sender;
28	use std::sync::Mutex;
29	use std::thread::{self, JoinHandle};
30	use std::time::{Duration, Instant};
31
32	use serde::{Deserialize, Serialize};
33
34	/// Bounded window a dropped child gets to exit on its own (after stdin EOF)
35	/// before it is killed — the no-zombie reap bound (doyle gate #2). Kept short:
36	/// a translation filter has nothing to flush on EOF.
37	const REAP_GRACE: Duration = Duration::from_millis(500);
38	/// Poll interval while waiting out [`REAP_GRACE`].
39	const REAP_POLL: Duration = Duration::from_millis(20);
40
41	/// One stdin line, spt-core → binary (internally tagged on `type`).
42	#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
43	#[serde(tag = "type", rename_all = "snake_case")]
44	pub enum ToBinary {
45	    /// First line: identity. `{"type":"init","endpoint_id":…,"node":…}`.
46	    Init { endpoint_id: String, node: String },
47	    /// Per inbound message: the whole ADR-0020 `<EVENT>` envelope, verbatim.
48	    /// `{"type":"event","envelope":"<EVENT…>"}`.
49	    Event { envelope: String },
50	    /// A CONTENT-FREE ping each time the operator types, so the binary can track
51	    /// user-idle state for its own idle-gated buffering. `{"type":"input"}`.
52	    /// **The PTY input bytes are NEVER included** (doyle gate #1, privacy).
53	    Input,
54	}
55
56	/// One stdout line, binary → spt-core: a single keystroke-command. **Untagged** —
57	/// each line carries exactly one command shape (no discriminator field), matching
58	/// the ADR-0022 wire (`{key}` / `{text}` / `{delay_ms}` / `{commit}`). Unknown
59	/// extra fields on a recognized shape are ignored (forward-compat); a line
60	/// matching no shape is a parse error the reader logs and skips (one bad line
61	/// never kills the binary).
62	#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
63	#[serde(untagged)]
64	pub enum KeyCmd {
65	    /// A named key or `ctrl+<x>` chord → mapped to PTY bytes by [`key_to_bytes`].
66	    Key { key: String },
67	    /// Literal text typed verbatim into the PTY (the message payload).
68	    Text { text: String },
69	    /// Pause this many milliseconds between keystrokes (the choreography's stash
70	    /// settle, e.g. `{"delay_ms":50}`). Carried in the sequence, applied by the
71	    /// broker's atomic applier.
72	    Delay { delay_ms: u64 },
73	    /// Sequence terminator (doyle OPT-1 ruling, pending confirm): the binary
74	    /// emits `{"commit":true}` after the last keystroke of its response to an
75	    /// event, so the broker knows the atomic sequence is complete and can flush
76	    /// the buffered controller input race-free. The degenerate v0.11.0 raw-inject
77	    /// is `{text:payload}{key:"enter"}{commit:true}`.
78	    Commit { commit: bool },
79	}
80
81	/// Map a `{key}` command name to the raw PTY bytes (the "send-keys" API).
82	///
83	/// Returns `None` for an unknown key name — skipped, never a panic (the opaque
84	/// adapter-template stance: a bad key silences itself rather than wedging the
85	/// applier). `Delay`/`Commit` are not keys and are handled by the applier, not
86	/// here.
87	///
88	/// Vocabulary: the common control keys, the four arrows + Home/End/PageUp/
89	/// PageDown/Delete/Insert as xterm CSI sequences, `ctrl+<letter|char>` chords, and
90	/// a single literal character passed through as itself (so `{key:"y"}` works).
91	// [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
92	pub fn key_to_bytes(key: &str) -> Option<Vec<u8>> {
93	    let k = key.trim();
94	    let lower = k.to_ascii_lowercase();
95	    let named: &[u8] = match lower.as_str() {
96	        "enter" | "return" | "cr" => b"\r",
97	        "newline" | "lf" => b"\n",
98	        "tab" => b"\t",
99	        "esc" | "escape" => b"\x1b",
100	        "backspace" | "bs" => b"\x7f",
101	        "space" => b" ",
102	        "up" => b"\x1b[A",
103	        "down" => b"\x1b[B",
104	        "right" => b"\x1b[C",
105	        "left" => b"\x1b[D",
106	        "home" => b"\x1b[H",
107	        "end" => b"\x1b[F",
108	        "pageup" | "pgup" => b"\x1b[5~",
109	        "pagedown" | "pgdn" => b"\x1b[6~",
110	        "delete" | "del" => b"\x1b[3~",
111	        "insert" | "ins" => b"\x1b[2~",
112	        _ => {
113	            // ctrl+<x>: a control byte. `ctrl+s` → 0x13, `ctrl+space` → NUL,
114	            // `ctrl+[` → ESC, etc. Only single-char chords are mapped.
115	            if let Some(rest) = lower.strip_prefix("ctrl+").or_else(|| lower.strip_prefix("c-")) {
116	                return ctrl_byte(rest).map(|b| vec![b]);
117	            }
118	            // A single literal character → its own bytes (e.g. `{key:"y"}`).
119	            let mut chars = k.chars();
120	            if let (Some(c), None) = (chars.next(), chars.next()) {
121	                return Some(c.to_string().into_bytes());
122	            }
123	            return None;
124	        }
125	    };
126	    Some(named.to_vec())
127	}
128
129	/// The control byte for a `ctrl+<x>` chord. ASCII letters map to `0x01..=0x1a`;
130	/// the conventional symbol chords (`@ [ \ ] ^ _ space`) map to `0x00..=0x1f`.
131	fn ctrl_byte(rest: &str) -> Option<u8> {
132	    if rest == "space" {
133	        return Some(0x00);
134	    }
135	    let mut chars = rest.chars();
136	    let (c, None) = (chars.next()?, chars.next()) else {
137	        return None;
138	    };
139	    let b = c as u32;
140	    match c {
141	        'a'..='z' => Some((b as u8) & 0x1f),
142	        'A'..='Z' => Some((b as u8) & 0x1f),
143	        '@' => Some(0x00),
144	        '[' => Some(0x1b),
145	        '\\' => Some(0x1c),
146	        ']' => Some(0x1d),
147	        '^' => Some(0x1e),
148	        '_' => Some(0x1f),
149	        '?' => Some(0x7f),
150	        _ => None,
151	    }
152	}
153
154	/// The per-session controller-input buffer + inject-floor flag (W2 Layer C,
155	/// REQ-HAZARD-INJECT-CONTROL-COEXIST / the W1 coordination mechanism).
156	///
157	/// While the floor is **held** (an injected keystroke sequence is mid-flight,
158	/// between a `{type:event}` dispatch and its `{commit}`), the operator's
159	/// keystrokes from `dispatch_input` are **buffered here** instead of hitting the
160	/// PTY; on commit (or the commit deadline) the buffer is flushed to the PTY
161	/// **after** the injected bytes. So a stash/restore choreography
162	/// (`ctrl+s … ctrl+s`) can never be clobbered by an operator keystroke landing
163	/// mid-sequence. spt-core owning every PTY write is what makes this possible.
164	///
165	/// Pure state (no I/O) so it is unit-testable; the broker holds it behind a
166	/// `Mutex` and does the actual `write_input` off-lock (the W1 lesson: never block
167	/// a PTY write under a lock the hot input path needs).
168	#[derive(Debug, Default)]
169	pub struct InjectFloor {
170	    held: bool,
171	    buffer: Vec<u8>,
172	}
173
174	impl InjectFloor {
175	    /// Open the floor for a new injected sequence (clears any stale buffer — a
176	    /// prior sequence always drains to empty before release, so this is belt-and-
177	    /// braces). Subsequent operator input buffers until [`take_or_release`].
178	    ///
179	    /// [`take_or_release`]: InjectFloor::take_or_release
180	    pub fn open(&mut self) {
181	        self.held = true;
182	        self.buffer.clear();
183	    }
184
185	    /// Whether the floor is currently held (a sequence is mid-flight).
186	    pub fn is_held(&self) -> bool {
187	        self.held
188	    }
189
190	    /// Route one operator-input chunk: if the floor is held, buffer it and return
191	    /// `true` (the caller must NOT write to the PTY now — it is flushed after the
192	    /// sequence); if free, return `false` (the caller writes through its normal
193	    /// path).
194	    pub fn buffer_if_held(&mut self, bytes: &[u8]) -> bool {
195	        if self.held {
196	            self.buffer.extend_from_slice(bytes);
197	            true
198	        } else {
199	            false
200	        }
201	    }
202
203	    /// One flush step — call in a loop after `{commit}` / the deadline. If the
204	    /// buffer holds bytes, take them to write **off-lock** while the floor STAYS
205	    /// held (so input arriving mid-flush keeps buffering → strict order); when the
206	    /// buffer is empty, RELEASE the floor and return `None`. Looping until `None`
207	    /// drains everything and leaves the floor free.
208	    pub fn take_or_release(&mut self) -> Option<Vec<u8>> {
209	        if self.buffer.is_empty() {
210	            self.held = false;
211	            None
212	        } else {
213	            Some(std::mem::take(&mut self.buffer))
214	        }
215	    }
216	}
217
218	/// A live translation-binary child process: its piped stdin (for [`ToBinary`]
219	/// lines) plus a reader thread that parses [`KeyCmd`] lines off its stdout and
220	/// forwards them on the caller-supplied channel. The broker owns the receiving
221	/// end and the atomic PTY apply.
222	pub struct TranslationChild {
223	    child: Child,
224	    /// `None` after [`close_stdin`](Self::close_stdin) / drop has taken it.
225	    stdin: Mutex<Option<ChildStdin>>,
226	    _reader: JoinHandle<()>,
227	    pid: Option<u32>,
228	}
229
230	impl TranslationChild {
231	    /// Spawn `path` as a stdin→stdout filter, forwarding each parsed [`KeyCmd`] on
232	    /// `cmds`. stderr is inherited so an adapter author can see the binary's own
233	    /// diagnostics. Fails closed: a missing/unspawnable binary returns the OS
234	    /// error (the broker logs it and falls back to the degenerate raw inject).
235	    // [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
236	    pub fn spawn(path: &str, cmds: Sender<KeyCmd>) -> io::Result<Self> {
237	        let mut child = Command::new(path)
238	            .stdin(Stdio::piped())
239	            .stdout(Stdio::piped())
240	            .stderr(Stdio::inherit())
241	            .spawn()?;
242	        let pid = child.id().into();
243	        let stdout = child
244	            .stdout
245	            .take()
246	            .expect("stdout piped at spawn");
247	        let stdin = child.stdin.take().expect("stdin piped at spawn");
248	        let reader = thread::spawn(move || {
249	            let buf = BufReader::new(stdout);
250	            for line in buf.lines() {
251	                let Ok(line) = line else { break }; // child stdout closed
252	                let trimmed = line.trim();
253	                if trimmed.is_empty() {
254	                    continue;
255	                }
256	                match serde_json::from_str::<KeyCmd>(trimmed) {
257	                    Ok(cmd) => {
258	                        if cmds.send(cmd).is_err() {
259	                            break; // broker dropped the receiver — stop reading
260	                        }
261	                    }
262	                    Err(e) => {
263	                        eprintln!("TRANSLATION_BIN_BAD_LINE: {e}: {trimmed}");
264	                    }
265	                }
266	            }
267	        });
268	        Ok(TranslationChild {
269	            child,
270	            stdin: Mutex::new(Some(stdin)),
271	            _reader: reader,
272	            pid,
273	        })
274	    }
275
276	    /// The child's OS pid (for tests / introspection / the no-zombie assertion).
277	    pub fn pid(&self) -> Option<u32> {
278	        self.pid
279	    }
280
281	    /// Write one [`ToBinary`] line to the child's stdin (newline-terminated,
282	    /// flushed). A closed stdin (post-shutdown) is a no-op `Ok`.
283	    // [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
284	    pub fn send(&self, msg: &ToBinary) -> io::Result<()> {
285	        let line = serde_json::to_string(msg).map_err(io::Error::other)?;
286	        let mut guard = self.stdin.lock().unwrap();
287	        if let Some(w) = guard.as_mut() {
288	            w.write_all(line.as_bytes())?;
289	            w.write_all(b"\n")?;
290	            w.flush()?;
291	        }
292	        Ok(())
293	    }
294
295	    /// Drop the child's stdin handle, sending EOF — a well-behaved filter then
296	    /// exits on its own. Idempotent.
297	    fn close_stdin(&self) {
298	        let _ = self.stdin.lock().unwrap().take();
299	    }
300
301	    /// Bounded reap (doyle gate #2): close stdin (EOF), give the child
302	    /// [`REAP_GRACE`] to exit, then kill + `wait` it so no zombie outlives a
303	    /// session-down. Best-effort — every step swallows errors (a child that
304	    /// already exited is fine).
305	    fn reap(&mut self) {
306	        self.close_stdin();
307	        let deadline = Instant::now() + REAP_GRACE;
308	        loop {
309	            match self.child.try_wait() {
310	                Ok(Some(_)) => return, // exited on its own after EOF
311	                Ok(None) => {
312	                    if Instant::now() >= deadline {
313	                        break;
314	                    }
315	                    thread::sleep(REAP_POLL);
316	                }
317	                Err(_) => break,
318	            }
319	        }
320	        let _ = self.child.kill();
321	        let _ = self.child.wait();
322	    }
323	}
324
325	impl Drop for TranslationChild {
326	    fn drop(&mut self) {
327	        self.reap();
328	    }
329	}
330
331	#[cfg(test)]
332	mod tests {
333	    use super::*;
334
335	    // [unit->REQ-MSG-IDLE-TRANSLATION-BINARY] the stdin protocol serializes to the
336	    // exact ADR-0022 JSON-lines wire — and the `input` ping is CONTENT-FREE (doyle
337	    // gate #1: it carries `type` ONLY, no PTY bytes can ride it).
338	    #[test]
339	    fn to_binary_serializes_to_wire_and_input_is_content_free() {
340	        assert_eq!(
341	            serde_json::to_string(&ToBinary::Init {
342	                endpoint_id: "doyle".into(),
343	                node: "n1".into(),
344	            })
345	            .unwrap(),
346	            r#"{"type":"init","endpoint_id":"doyle","node":"n1"}"#
347	        );
348	        assert_eq!(
349	            serde_json::to_string(&ToBinary::Event {
350	                envelope: "<EVENT type=\"msg\"/>".into(),
351	            })
352	            .unwrap(),
353	            r#"{"type":"event","envelope":"<EVENT type=\"msg\"/>"}"#
354	        );
355	        let ping = serde_json::to_string(&ToBinary::Input).unwrap();
356	        assert_eq!(ping, r#"{"type":"input"}"#);
357	        // Privacy invariant, structurally: the only field is `type`.
358	        let v: serde_json::Value = serde_json::from_str(&ping).unwrap();
359	        assert_eq!(
360	            v.as_object().unwrap().keys().collect::<Vec<_>>(),
361	            vec!["type"],
362	            "the input ping must carry no field other than `type` — no PTY content"
363	        );
364	    }
365
366	    // [unit->REQ-MSG-IDLE-TRANSLATION-BINARY] each stdout shape parses to its
367	    // KeyCmd variant; an unknown extra field on a recognized shape is ignored
368	    // (forward-compat); a line matching no shape errors (the reader skips it).
369	    #[test]
370	    fn key_cmd_parses_each_wire_shape() {
371	        assert_eq!(
372	            serde_json::from_str::<KeyCmd>(r#"{"key":"ctrl+s"}"#).unwrap(),
373	            KeyCmd::Key { key: "ctrl+s".into() }
374	        );
375	        assert_eq!(
376	            serde_json::from_str::<KeyCmd>(r#"{"text":"hello"}"#).unwrap(),
377	            KeyCmd::Text { text: "hello".into() }
378	        );
379	        assert_eq!(
380	            serde_json::from_str::<KeyCmd>(r#"{"delay_ms":50}"#).unwrap(),
381	            KeyCmd::Delay { delay_ms: 50 }
382	        );
383	        assert_eq!(
384	            serde_json::from_str::<KeyCmd>(r#"{"commit":true}"#).unwrap(),
385	            KeyCmd::Commit { commit: true }
386	        );
387	        // forward-compat: an unknown field alongside a known shape still parses.
388	        assert_eq!(
389	            serde_json::from_str::<KeyCmd>(r#"{"key":"enter","repeat":3}"#).unwrap(),
390	            KeyCmd::Key { key: "enter".into() }
391	        );
392	        // a line matching no shape is an error.
393	        assert!(serde_json::from_str::<KeyCmd>(r#"{"bogus":1}"#).is_err());
394	    }
395
396	    // [unit->REQ-MSG-IDLE-TRANSLATION-BINARY] the inject floor: while held, operator
397	    // input buffers (not written); take_or_release drains in order then frees the
398	    // floor; input arriving mid-flush keeps buffering so order is preserved; a free
399	    // floor passes input straight through.
400	    #[test]
401	    fn inject_floor_buffers_while_held_then_drains_in_order() {
402	        let mut f = InjectFloor::default();
403	        // free floor → input passes through (caller writes), nothing buffered.
404	        assert!(!f.buffer_if_held(b"a"), "free floor does not buffer");
405	        assert!(!f.is_held());
406
407	        // open → operator input now buffers.
408	        f.open();
409	        assert!(f.is_held());
410	        assert!(f.buffer_if_held(b"hel"), "held floor buffers");
411	        assert!(f.buffer_if_held(b"lo"), "held floor keeps buffering");
412
413	        // flush step 1 takes the buffered bytes (floor STAYS held) ...
414	        assert_eq!(f.take_or_release(), Some(b"hello".to_vec()));
415	        assert!(f.is_held(), "floor stays held mid-flush so order is preserved");
416	        // ... input arriving mid-flush keeps buffering ...
417	        assert!(f.buffer_if_held(b"!"), "mid-flush input still buffers");
418	        assert_eq!(f.take_or_release(), Some(b"!".to_vec()));
419	        // ... and the empty step RELEASES the floor.
420	        assert_eq!(f.take_or_release(), None);
421	        assert!(!f.is_held(), "drained empty → floor released");
422	        assert!(!f.buffer_if_held(b"z"), "released floor passes through again");
423	    }
424
425	    // [unit->REQ-MSG-IDLE-TRANSLATION-BINARY] the send-keys byte map: named keys,
426	    // the CC choreography chord `ctrl+s` → 0x13, arrows as xterm CSI, a single
427	    // literal char passes through, and an unknown key is skipped (None, no panic).
428	    #[test]
429	    fn key_to_bytes_send_keys_map() {
430	        assert_eq!(key_to_bytes("enter"), Some(b"\r".to_vec()));
431	        assert_eq!(key_to_bytes("tab"), Some(b"\t".to_vec()));
432	        assert_eq!(key_to_bytes("esc"), Some(vec![0x1b]));
433	        assert_eq!(key_to_bytes("backspace"), Some(vec![0x7f]));
434	        // the CC stash/restore chord.
435	        assert_eq!(key_to_bytes("ctrl+s"), Some(vec![0x13]));
436	        assert_eq!(key_to_bytes("ctrl+a"), Some(vec![0x01]));
437	        assert_eq!(key_to_bytes("ctrl+b"), Some(vec![0x02])); // the rc detach byte
438	        // arrows as xterm CSI.
439	        assert_eq!(key_to_bytes("up"), Some(b"\x1b[A".to_vec()));
440	        assert_eq!(key_to_bytes("left"), Some(b"\x1b[D".to_vec()));
441	        // a single literal char passes through.
442	        assert_eq!(key_to_bytes("y"), Some(b"y".to_vec()));
443	        // case-insensitive named keys.
444	        assert_eq!(key_to_bytes("Enter"), Some(b"\r".to_vec()));
445	        // unknown multi-char key → skipped.
446	        assert_eq!(key_to_bytes("nope-not-a-key"), None);
447	    }
448	}
449

//! ADR-0022 idle-delivery **translation-binary** driver (v0.13.0 W2 Layer B).
//!
//! An adapter may declare a `[message-idle-translation-binary]` (see
//! [`spt_runtime::manifest::IdleTranslationBinary`]). It is a **pure
//! stdin→stdout JSON-lines filter** spt-core lifecycle-manages: spt-core feeds it
//! the inbound `<EVENT>` feed plus content-free user-activity pings, and reads
//! back keystroke-commands which spt-core (NOT the binary) applies to the
//! broker-held PTY. The binary never touches the PTY — that is what lets injection
//! coexist atomically with a live `spt rc` controller (the W1 coordination,
//! REQ-HAZARD-INJECT-CONTROL-COEXIST). This module owns only the **process + wire**
//! seam (spawn, the two JSON-lines protocols, the send-keys byte map, and a
//! bounded reap); the atomic PTY apply + controller-input buffering lives in the
//! broker (Layer C).
//!
//! **Privacy invariant (doyle gate #1):** the `input` ping is CONTENT-FREE — the
//! operator's PTY keystrokes are never duplicated into the binary's stdin. See
//! [`ToBinary::Input`].
//!
//! **No-zombie invariant (doyle gate #2):** dropping a [`TranslationChild`] closes
//! its stdin (EOF → a well-behaved filter exits), waits a bounded window, then
//! kills + reaps — a session-down never leaks a translation child.
//
// [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]

use std::io::{self, BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::mpsc::Sender;
use std::sync::Mutex;
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};

use serde::{Deserialize, Serialize};

/// Bounded window a dropped child gets to exit on its own (after stdin EOF)
/// before it is killed — the no-zombie reap bound (doyle gate #2). Kept short:
/// a translation filter has nothing to flush on EOF.
const REAP_GRACE: Duration = Duration::from_millis(500);
/// Poll interval while waiting out [`REAP_GRACE`].
const REAP_POLL: Duration = Duration::from_millis(20);

/// One stdin line, spt-core → binary (internally tagged on `type`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ToBinary {
    /// First line: identity. `{"type":"init","endpoint_id":…,"node":…}`.
    Init { endpoint_id: String, node: String },
    /// Per inbound message: the whole ADR-0020 `<EVENT>` envelope, verbatim.
    /// `{"type":"event","envelope":"<EVENT…>"}`.
    Event { envelope: String },
    /// A CONTENT-FREE ping each time the operator types, so the binary can track
    /// user-idle state for its own idle-gated buffering. `{"type":"input"}`.
    /// **The PTY input bytes are NEVER included** (doyle gate #1, privacy).
    Input,
}

/// One stdout line, binary → spt-core: a single keystroke-command. **Untagged** —
/// each line carries exactly one command shape (no discriminator field), matching
/// the ADR-0022 wire (`{key}` / `{text}` / `{delay_ms}` / `{commit}`). Unknown
/// extra fields on a recognized shape are ignored (forward-compat); a line
/// matching no shape is a parse error the reader logs and skips (one bad line
/// never kills the binary).
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(untagged)]
pub enum KeyCmd {
    /// A named key or `ctrl+<x>` chord → mapped to PTY bytes by [`key_to_bytes`].
    Key { key: String },
    /// Literal text typed verbatim into the PTY (the message payload).
    Text { text: String },
    /// Pause this many milliseconds between keystrokes (the choreography's stash
    /// settle, e.g. `{"delay_ms":50}`). Carried in the sequence, applied by the
    /// broker's atomic applier.
    Delay { delay_ms: u64 },
    /// Sequence terminator (doyle OPT-1 ruling, pending confirm): the binary
    /// emits `{"commit":true}` after the last keystroke of its response to an
    /// event, so the broker knows the atomic sequence is complete and can flush
    /// the buffered controller input race-free. The degenerate v0.11.0 raw-inject
    /// is `{text:payload}{key:"enter"}{commit:true}`.
    Commit { commit: bool },
}

/// Map a `{key}` command name to the raw PTY bytes (the "send-keys" API).
///
/// Returns `None` for an unknown key name — skipped, never a panic (the opaque
/// adapter-template stance: a bad key silences itself rather than wedging the
/// applier). `Delay`/`Commit` are not keys and are handled by the applier, not
/// here.
///
/// Vocabulary: the common control keys, the four arrows + Home/End/PageUp/
/// PageDown/Delete/Insert as xterm CSI sequences, `ctrl+<letter|char>` chords, and
/// a single literal character passed through as itself (so `{key:"y"}` works).
// [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
pub fn key_to_bytes(key: &str) -> Option<Vec<u8>> {
    let k = key.trim();
    let lower = k.to_ascii_lowercase();
    let named: &[u8] = match lower.as_str() {
        "enter" | "return" | "cr" => b"\r",
        "newline" | "lf" => b"\n",
        "tab" => b"\t",
        "esc" | "escape" => b"\x1b",
        "backspace" | "bs" => b"\x7f",
        "space" => b" ",
        "up" => b"\x1b[A",
        "down" => b"\x1b[B",
        "right" => b"\x1b[C",
        "left" => b"\x1b[D",
        "home" => b"\x1b[H",
        "end" => b"\x1b[F",
        "pageup" | "pgup" => b"\x1b[5~",
        "pagedown" | "pgdn" => b"\x1b[6~",
        "delete" | "del" => b"\x1b[3~",
        "insert" | "ins" => b"\x1b[2~",
        _ => {
            // ctrl+<x>: a control byte. `ctrl+s` → 0x13, `ctrl+space` → NUL,
            // `ctrl+[` → ESC, etc. Only single-char chords are mapped.
            if let Some(rest) = lower.strip_prefix("ctrl+").or_else(|| lower.strip_prefix("c-")) {
                return ctrl_byte(rest).map(|b| vec![b]);
            }
            // A single literal character → its own bytes (e.g. `{key:"y"}`).
            let mut chars = k.chars();
            if let (Some(c), None) = (chars.next(), chars.next()) {
                return Some(c.to_string().into_bytes());
            }
            return None;
        }
    };
    Some(named.to_vec())
}

/// The control byte for a `ctrl+<x>` chord. ASCII letters map to `0x01..=0x1a`;
/// the conventional symbol chords (`@ [ \ ] ^ _ space`) map to `0x00..=0x1f`.
fn ctrl_byte(rest: &str) -> Option<u8> {
    if rest == "space" {
        return Some(0x00);
    }
    let mut chars = rest.chars();
    let (c, None) = (chars.next()?, chars.next()) else {
        return None;
    };
    let b = c as u32;
    match c {
        'a'..='z' => Some((b as u8) & 0x1f),
        'A'..='Z' => Some((b as u8) & 0x1f),
        '@' => Some(0x00),
        '[' => Some(0x1b),
        '\\' => Some(0x1c),
        ']' => Some(0x1d),
        '^' => Some(0x1e),
        '_' => Some(0x1f),
        '?' => Some(0x7f),
        _ => None,
    }
}

/// The per-session controller-input buffer + inject-floor flag (W2 Layer C,
/// REQ-HAZARD-INJECT-CONTROL-COEXIST / the W1 coordination mechanism).
///
/// While the floor is **held** (an injected keystroke sequence is mid-flight,
/// between a `{type:event}` dispatch and its `{commit}`), the operator's
/// keystrokes from `dispatch_input` are **buffered here** instead of hitting the
/// PTY; on commit (or the commit deadline) the buffer is flushed to the PTY
/// **after** the injected bytes. So a stash/restore choreography
/// (`ctrl+s … ctrl+s`) can never be clobbered by an operator keystroke landing
/// mid-sequence. spt-core owning every PTY write is what makes this possible.
///
/// Pure state (no I/O) so it is unit-testable; the broker holds it behind a
/// `Mutex` and does the actual `write_input` off-lock (the W1 lesson: never block
/// a PTY write under a lock the hot input path needs).
#[derive(Debug, Default)]
pub struct InjectFloor {
    held: bool,
    buffer: Vec<u8>,
}

impl InjectFloor {
    /// Open the floor for a new injected sequence (clears any stale buffer — a
    /// prior sequence always drains to empty before release, so this is belt-and-
    /// braces). Subsequent operator input buffers until [`take_or_release`].
    ///
    /// [`take_or_release`]: InjectFloor::take_or_release
    pub fn open(&mut self) {
        self.held = true;
        self.buffer.clear();
    }

    /// Whether the floor is currently held (a sequence is mid-flight).
    pub fn is_held(&self) -> bool {
        self.held
    }

    /// Route one operator-input chunk: if the floor is held, buffer it and return
    /// `true` (the caller must NOT write to the PTY now — it is flushed after the
    /// sequence); if free, return `false` (the caller writes through its normal
    /// path).
    pub fn buffer_if_held(&mut self, bytes: &[u8]) -> bool {
        if self.held {
            self.buffer.extend_from_slice(bytes);
            true
        } else {
            false
        }
    }

    /// One flush step — call in a loop after `{commit}` / the deadline. If the
    /// buffer holds bytes, take them to write **off-lock** while the floor STAYS
    /// held (so input arriving mid-flush keeps buffering → strict order); when the
    /// buffer is empty, RELEASE the floor and return `None`. Looping until `None`
    /// drains everything and leaves the floor free.
    pub fn take_or_release(&mut self) -> Option<Vec<u8>> {
        if self.buffer.is_empty() {
            self.held = false;
            None
        } else {
            Some(std::mem::take(&mut self.buffer))
        }
    }
}

/// A live translation-binary child process: its piped stdin (for [`ToBinary`]
/// lines) plus a reader thread that parses [`KeyCmd`] lines off its stdout and
/// forwards them on the caller-supplied channel. The broker owns the receiving
/// end and the atomic PTY apply.
pub struct TranslationChild {
    child: Child,
    /// `None` after [`close_stdin`](Self::close_stdin) / drop has taken it.
    stdin: Mutex<Option<ChildStdin>>,
    _reader: JoinHandle<()>,
    pid: Option<u32>,
}

impl TranslationChild {
    /// Spawn `path` as a stdin→stdout filter, forwarding each parsed [`KeyCmd`] on
    /// `cmds`. stderr is inherited so an adapter author can see the binary's own
    /// diagnostics. Fails closed: a missing/unspawnable binary returns the OS
    /// error (the broker logs it and falls back to the degenerate raw inject).
    // [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
    pub fn spawn(path: &str, cmds: Sender<KeyCmd>) -> io::Result<Self> {
        let mut child = Command::new(path)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::inherit())
            .spawn()?;
        let pid = child.id().into();
        let stdout = child
            .stdout
            .take()
            .expect("stdout piped at spawn");
        let stdin = child.stdin.take().expect("stdin piped at spawn");
        let reader = thread::spawn(move || {
            let buf = BufReader::new(stdout);
            for line in buf.lines() {
                let Ok(line) = line else { break }; // child stdout closed
                let trimmed = line.trim();
                if trimmed.is_empty() {
                    continue;
                }
                match serde_json::from_str::<KeyCmd>(trimmed) {
                    Ok(cmd) => {
                        if cmds.send(cmd).is_err() {
                            break; // broker dropped the receiver — stop reading
                        }
                    }
                    Err(e) => {
                        eprintln!("TRANSLATION_BIN_BAD_LINE: {e}: {trimmed}");
                    }
                }
            }
        });
        Ok(TranslationChild {
            child,
            stdin: Mutex::new(Some(stdin)),
            _reader: reader,
            pid,
        })
    }

    /// The child's OS pid (for tests / introspection / the no-zombie assertion).
    pub fn pid(&self) -> Option<u32> {
        self.pid
    }

    /// Write one [`ToBinary`] line to the child's stdin (newline-terminated,
    /// flushed). A closed stdin (post-shutdown) is a no-op `Ok`.
    // [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
    pub fn send(&self, msg: &ToBinary) -> io::Result<()> {
        let line = serde_json::to_string(msg).map_err(io::Error::other)?;
        let mut guard = self.stdin.lock().unwrap();
        if let Some(w) = guard.as_mut() {
            w.write_all(line.as_bytes())?;
            w.write_all(b"\n")?;
            w.flush()?;
        }
        Ok(())
    }

    /// Drop the child's stdin handle, sending EOF — a well-behaved filter then
    /// exits on its own. Idempotent.
    fn close_stdin(&self) {
        let _ = self.stdin.lock().unwrap().take();
    }

    /// Bounded reap (doyle gate #2): close stdin (EOF), give the child
    /// [`REAP_GRACE`] to exit, then kill + `wait` it so no zombie outlives a
    /// session-down. Best-effort — every step swallows errors (a child that
    /// already exited is fine).
    fn reap(&mut self) {
        self.close_stdin();
        let deadline = Instant::now() + REAP_GRACE;
        loop {
            match self.child.try_wait() {
                Ok(Some(_)) => return, // exited on its own after EOF
                Ok(None) => {
                    if Instant::now() >= deadline {
                        break;
                    }
                    thread::sleep(REAP_POLL);
                }
                Err(_) => break,
            }
        }
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

impl Drop for TranslationChild {
    fn drop(&mut self) {
        self.reap();
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // [unit->REQ-MSG-IDLE-TRANSLATION-BINARY] the stdin protocol serializes to the
    // exact ADR-0022 JSON-lines wire — and the `input` ping is CONTENT-FREE (doyle
    // gate #1: it carries `type` ONLY, no PTY bytes can ride it).
    #[test]
    fn to_binary_serializes_to_wire_and_input_is_content_free() {
        assert_eq!(
            serde_json::to_string(&ToBinary::Init {
                endpoint_id: "doyle".into(),
                node: "n1".into(),
            })
            .unwrap(),
            r#"{"type":"init","endpoint_id":"doyle","node":"n1"}"#
        );
        assert_eq!(
            serde_json::to_string(&ToBinary::Event {
                envelope: "<EVENT type=\"msg\"/>".into(),
            })
            .unwrap(),
            r#"{"type":"event","envelope":"<EVENT type=\"msg\"/>"}"#
        );
        let ping = serde_json::to_string(&ToBinary::Input).unwrap();
        assert_eq!(ping, r#"{"type":"input"}"#);
        // Privacy invariant, structurally: the only field is `type`.
        let v: serde_json::Value = serde_json::from_str(&ping).unwrap();
        assert_eq!(
            v.as_object().unwrap().keys().collect::<Vec<_>>(),
            vec!["type"],
            "the input ping must carry no field other than `type` — no PTY content"
        );
    }

    // [unit->REQ-MSG-IDLE-TRANSLATION-BINARY] each stdout shape parses to its
    // KeyCmd variant; an unknown extra field on a recognized shape is ignored
    // (forward-compat); a line matching no shape errors (the reader skips it).
    #[test]
    fn key_cmd_parses_each_wire_shape() {
        assert_eq!(
            serde_json::from_str::<KeyCmd>(r#"{"key":"ctrl+s"}"#).unwrap(),
            KeyCmd::Key { key: "ctrl+s".into() }
        );
        assert_eq!(
            serde_json::from_str::<KeyCmd>(r#"{"text":"hello"}"#).unwrap(),
            KeyCmd::Text { text: "hello".into() }
        );
        assert_eq!(
            serde_json::from_str::<KeyCmd>(r#"{"delay_ms":50}"#).unwrap(),
            KeyCmd::Delay { delay_ms: 50 }
        );
        assert_eq!(
            serde_json::from_str::<KeyCmd>(r#"{"commit":true}"#).unwrap(),
            KeyCmd::Commit { commit: true }
        );
        // forward-compat: an unknown field alongside a known shape still parses.
        assert_eq!(
            serde_json::from_str::<KeyCmd>(r#"{"key":"enter","repeat":3}"#).unwrap(),
            KeyCmd::Key { key: "enter".into() }
        );
        // a line matching no shape is an error.
        assert!(serde_json::from_str::<KeyCmd>(r#"{"bogus":1}"#).is_err());
    }

    // [unit->REQ-MSG-IDLE-TRANSLATION-BINARY] the inject floor: while held, operator
    // input buffers (not written); take_or_release drains in order then frees the
    // floor; input arriving mid-flush keeps buffering so order is preserved; a free
    // floor passes input straight through.
    #[test]
    fn inject_floor_buffers_while_held_then_drains_in_order() {
        let mut f = InjectFloor::default();
        // free floor → input passes through (caller writes), nothing buffered.
        assert!(!f.buffer_if_held(b"a"), "free floor does not buffer");
        assert!(!f.is_held());

        // open → operator input now buffers.
        f.open();
        assert!(f.is_held());
        assert!(f.buffer_if_held(b"hel"), "held floor buffers");
        assert!(f.buffer_if_held(b"lo"), "held floor keeps buffering");

        // flush step 1 takes the buffered bytes (floor STAYS held) ...
        assert_eq!(f.take_or_release(), Some(b"hello".to_vec()));
        assert!(f.is_held(), "floor stays held mid-flush so order is preserved");
        // ... input arriving mid-flush keeps buffering ...
        assert!(f.buffer_if_held(b"!"), "mid-flush input still buffers");
        assert_eq!(f.take_or_release(), Some(b"!".to_vec()));
        // ... and the empty step RELEASES the floor.
        assert_eq!(f.take_or_release(), None);
        assert!(!f.is_held(), "drained empty → floor released");
        assert!(!f.buffer_if_held(b"z"), "released floor passes through again");
    }

    // [unit->REQ-MSG-IDLE-TRANSLATION-BINARY] the send-keys byte map: named keys,
    // the CC choreography chord `ctrl+s` → 0x13, arrows as xterm CSI, a single
    // literal char passes through, and an unknown key is skipped (None, no panic).
    #[test]
    fn key_to_bytes_send_keys_map() {
        assert_eq!(key_to_bytes("enter"), Some(b"\r".to_vec()));
        assert_eq!(key_to_bytes("tab"), Some(b"\t".to_vec()));
        assert_eq!(key_to_bytes("esc"), Some(vec![0x1b]));
        assert_eq!(key_to_bytes("backspace"), Some(vec![0x7f]));
        // the CC stash/restore chord.
        assert_eq!(key_to_bytes("ctrl+s"), Some(vec![0x13]));
        assert_eq!(key_to_bytes("ctrl+a"), Some(vec![0x01]));
        assert_eq!(key_to_bytes("ctrl+b"), Some(vec![0x02])); // the rc detach byte
        // arrows as xterm CSI.
        assert_eq!(key_to_bytes("up"), Some(b"\x1b[A".to_vec()));
        assert_eq!(key_to_bytes("left"), Some(b"\x1b[D".to_vec()));
        // a single literal char passes through.
        assert_eq!(key_to_bytes("y"), Some(b"y".to_vec()));
        // case-insensitive named keys.
        assert_eq!(key_to_bytes("Enter"), Some(b"\r".to_vec()));
        // unknown multi-char key → skipped.
        assert_eq!(key_to_bytes("nope-not-a-key"), None);
    }
}


Kernel pieces all banked + unit-tested. Now the broker integration. Let me map `dispatch_input`, the session struct, and where `translation_binary` currently lands in broker.rs.


cd crates/spt-daemon/src; echo "=== broker.rs size ==="; wc -l broker.rs; echo "=== dispatch_input + session struct + translation_binary in broker.rs ==="; grep -n "fn dispatch_input\|fn dispatch_endpoint_input\|struct .*Session\|translation_binary\|write_input\|InjectFloor\|inject_floor\|fn spawn\b\|TranslationChild\|send_effect\|op_id" broker.rs

Map broker.rs dispatch + session internals


=== broker.rs size ===
1892 broker.rs
=== dispatch_input + session struct + translation_binary in broker.rs ===
715:struct HostedSession {
738:    /// The exactly-once anchor for idempotent (`op_id`-carrying) effects.
1231:    /// With an `op_id` the write is routed through the broker-owned
1233:    /// `(session_id, op_id)` after a brain crash is deduped (the PTY is not
1236:    /// effect (B5 / KNOWN-HAZARDS 7.2). Without an `op_id` it is the legacy
1239:    fn dispatch_input(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
1252:        match req.op_id {
1259:                            .write_input(&bytes)
1262:                    .map_err(|e| format!("write_input failed: {e}"))?;
1271:                .write_input(&bytes)
1272:                .map_err(|e| format!("write_input failed: {e}")),
1294:    fn dispatch_endpoint_input(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
1319:                    .write_input(&bytes)
1320:                    .map_err(|e| format!("write_input failed: {e}"))?;
1411:    /// With an `op_id` the dial is routed through the broker-owned
1430:        match req.op_id {
1432:                // Direct (non-journaled) dial — no dedup, like a no-op_id input.
1452:                     (broker restarted); retry with a fresh op_id",
1556:        let build = |stream_id: u64, conn_id: u64, op_id: Option<u64>, applied_now: bool| {
1562:                    op_id,
1568:        match req.op_id {
1583:                let stream_id = host.stream_op_id(op).ok_or(
1585:                     (broker restarted); retry with a fresh op_id",
1594:    /// Write bytes to a held stream's send side (D4b). With an `op_id` the
1606:        match req.op_id {
1621:                        op_id: op,


715	struct HostedSession {
716	    /// The PTY + child (PTY master/writer + spawned child process).
717	    session: Arc<PtySession>,
718	    /// The output drain pump (kept so teardown can stop it).
719	    #[allow(dead_code)] // held for ownership/teardown; not read directly
720	    drain: Drain,
721	    /// The sequenced, buffered output + current subscriber.
722	    log: Arc<Mutex<OutputLog>>,
723	    /// The owning endpoint (the spawn's label; empty = unlabeled). The
724	    /// inbound dispatcher's gate subject for remote-drive (D9-1).
725	    endpoint: String,
726	}
727
728	/// The broker process kernel. Owns the listening socket, the table of hosted
729	/// sessions, and the broker-owned idempotency anchor (B5); holds no logic state
730	/// (ADR-0004 §B). The [`EffectJournal`] is a §B resource — the durable
731	/// recovery anchor a brain crash must not lose, so it lives in the broker, not
732	/// the brain (Spike #6 constraint 1).
733	pub struct Broker {
734	    listener: LocalSocketTransport,
735	    sessions: Arc<Mutex<HashMap<u64, HostedSession>>>,
736	    next_id: AtomicU64,
737	    log_cap_chunks: usize,
738	    /// The exactly-once anchor for idempotent (`op_id`-carrying) effects.
739	    journal: Arc<EffectJournal>,
740	    /// The broker-owned net endpoint + live QUIC connections (D4a, ADR-0004 §B).
741	    /// Empty when bound without a network host (net-disabled / most tests) OR
742	    /// when net is still being brought up (REQ-DAEMON-9 boot-race self-heal):
743	    /// a `OnceLock` so the background net-retry can ATTACH the host after bind
744	    /// without a relock on the hot effect-dispatch read path (`.get()` is
745	    /// lock-free). Settable exactly once — the first host wins.
746	    net: std::sync::OnceLock<NetHost>,
747	    /// The broker→supervisor planned-restart signal (ADR-0018 D3-3): a
748	    /// `KIND_BRAIN_RESTART` handler raises it so the brain supervisor cycles the
749	    /// child onto a freshly-swapped binary. Empty on a bare broker (a test broker
750	    /// with no supervisor) — the verb is then accepted but unhonored. Set once by
751	    /// `Daemon::run` after it spawns the supervisor.
752	    brain_restart: std::sync::OnceLock<Arc<crate::brainproc::BrainRestart>>,
753	}
754
755	impl Broker {
756	    /// Bind the broker's listening endpoint at the namespaced `name`, with the
757	    /// effect journal at the default path (`<spt_home>/daemon-effects.log`).
758	    pub fn bind(name: &str) -> io::Result<Arc<Self>> {
759	        Self::bind_in(name, default_journal_path())
760	    }
761
762	    /// Bind with an explicit effect-journal path — the testable seam (tests point
763	    /// the broker-owned anchor at a temp file rather than the shared home).
764	    pub fn bind_in(name: &str, journal_path: std::path::PathBuf) -> io::Result<Arc<Self>> {
765	        Self::bind_in_with_net(name, journal_path, None)
766	    }
767
768	    /// Bind with an already-started broker-owned network host (D4a). Taking the
769	    /// host (not its config) keeps a net bind failure separable from a listener
770	    /// bind failure — the daemon degrades to a net-less broker rather than
771	    /// losing PTY hosting (see `Daemon::run`).
772	    // [impl->REQ-DAEMON-2]
773	    pub fn bind_in_with_net(
774	        name: &str,
775	        journal_path: std::path::PathBuf,
776	        net: Option<NetHost>,
777	    ) -> io::Result<Arc<Self>> {
778	        Self::bind_in_with_net_cap(name, journal_path, net, DEFAULT_LOG_CHUNKS)
779	    }
780
781	    /// [`bind_in_with_net`](Self::bind_in_with_net) with an explicit output-log
782	    /// ring cap (the per-session eviction bound). Production binds at
783	    /// [`DEFAULT_LOG_CHUNKS`]; the restoration D4-2 resume harness binds a **tiny**
784	    /// cap to force ring eviction and exercise the post-eviction floor clamp (a
785	    /// `resume_seq` below the surviving ring floor must resume forward, losing only
786	    /// the evicted tail — never gap-erroring the live stream).
787	    pub fn bind_in_with_net_cap(
788	        name: &str,
789	        journal_path: std::path::PathBuf,
790	        net: Option<NetHost>,
791	        log_cap_chunks: usize,
792	    ) -> io::Result<Arc<Self>> {
793	        let net_cell = std::sync::OnceLock::new();
794	        if let Some(host) = net {
795	            let _ = net_cell.set(host);
796	        }
797	        Ok(Arc::new(Broker {
798	            listener: LocalSocketTransport::bind(name)?,
799	            sessions: Arc::new(Mutex::new(HashMap::new())),
800	            next_id: AtomicU64::new(1),
801	            log_cap_chunks,
802	            journal: Arc::new(EffectJournal::open(journal_path)?),
803	            net: net_cell,
804	            brain_restart: std::sync::OnceLock::new(),

struct HostedSession {
    /// The PTY + child (PTY master/writer + spawned child process).
    session: Arc<PtySession>,
    /// The output drain pump (kept so teardown can stop it).
    #[allow(dead_code)] // held for ownership/teardown; not read directly
    drain: Drain,
    /// The sequenced, buffered output + current subscriber.
    log: Arc<Mutex<OutputLog>>,
    /// The owning endpoint (the spawn's label; empty = unlabeled). The
    /// inbound dispatcher's gate subject for remote-drive (D9-1).
    endpoint: String,
}

/// The broker process kernel. Owns the listening socket, the table of hosted
/// sessions, and the broker-owned idempotency anchor (B5); holds no logic state
/// (ADR-0004 §B). The [`EffectJournal`] is a §B resource — the durable
/// recovery anchor a brain crash must not lose, so it lives in the broker, not
/// the brain (Spike #6 constraint 1).
pub struct Broker {
    listener: LocalSocketTransport,
    sessions: Arc<Mutex<HashMap<u64, HostedSession>>>,
    next_id: AtomicU64,
    log_cap_chunks: usize,
    /// The exactly-once anchor for idempotent (`op_id`-carrying) effects.
    journal: Arc<EffectJournal>,
    /// The broker-owned net endpoint + live QUIC connections (D4a, ADR-0004 §B).
    /// Empty when bound without a network host (net-disabled / most tests) OR
    /// when net is still being brought up (REQ-DAEMON-9 boot-race self-heal):
    /// a `OnceLock` so the background net-retry can ATTACH the host after bind
    /// without a relock on the hot effect-dispatch read path (`.get()` is
    /// lock-free). Settable exactly once — the first host wins.
    net: std::sync::OnceLock<NetHost>,
    /// The broker→supervisor planned-restart signal (ADR-0018 D3-3): a
    /// `KIND_BRAIN_RESTART` handler raises it so the brain supervisor cycles the
    /// child onto a freshly-swapped binary. Empty on a bare broker (a test broker
    /// with no supervisor) — the verb is then accepted but unhonored. Set once by
    /// `Daemon::run` after it spawns the supervisor.
    brain_restart: std::sync::OnceLock<Arc<crate::brainproc::BrainRestart>>,
}

impl Broker {
    /// Bind the broker's listening endpoint at the namespaced `name`, with the
    /// effect journal at the default path (`<spt_home>/daemon-effects.log`).
    pub fn bind(name: &str) -> io::Result<Arc<Self>> {
        Self::bind_in(name, default_journal_path())
    }

    /// Bind with an explicit effect-journal path — the testable seam (tests point
    /// the broker-owned anchor at a temp file rather than the shared home).
    pub fn bind_in(name: &str, journal_path: std::path::PathBuf) -> io::Result<Arc<Self>> {
        Self::bind_in_with_net(name, journal_path, None)
    }

    /// Bind with an already-started broker-owned network host (D4a). Taking the
    /// host (not its config) keeps a net bind failure separable from a listener
    /// bind failure — the daemon degrades to a net-less broker rather than
    /// losing PTY hosting (see `Daemon::run`).
    // [impl->REQ-DAEMON-2]
    pub fn bind_in_with_net(
        name: &str,
        journal_path: std::path::PathBuf,
        net: Option<NetHost>,
    ) -> io::Result<Arc<Self>> {
        Self::bind_in_with_net_cap(name, journal_path, net, DEFAULT_LOG_CHUNKS)
    }

    /// [`bind_in_with_net`](Self::bind_in_with_net) with an explicit output-log
    /// ring cap (the per-session eviction bound). Production binds at
    /// [`DEFAULT_LOG_CHUNKS`]; the restoration D4-2 resume harness binds a **tiny**
    /// cap to force ring eviction and exercise the post-eviction floor clamp (a
    /// `resume_seq` below the surviving ring floor must resume forward, losing only
    /// the evicted tail — never gap-erroring the live stream).
    pub fn bind_in_with_net_cap(
        name: &str,
        journal_path: std::path::PathBuf,
        net: Option<NetHost>,
        log_cap_chunks: usize,
    ) -> io::Result<Arc<Self>> {
        let net_cell = std::sync::OnceLock::new();
        if let Some(host) = net {
            let _ = net_cell.set(host);
        }
        Ok(Arc::new(Broker {
            listener: LocalSocketTransport::bind(name)?,
            sessions: Arc::new(Mutex::new(HashMap::new())),
            next_id: AtomicU64::new(1),
            log_cap_chunks,
            journal: Arc::new(EffectJournal::open(journal_path)?),
            net: net_cell,
            brain_restart: std::sync::OnceLock::new(),


1225	        log.lock().unwrap().detach_if(send);
1226	        Ok(())
1227	    }
1228
1229	    /// Inject input bytes into a session's PTY.
1230	    ///
1231	    /// With an `op_id` the write is routed through the broker-owned
1232	    /// [`EffectJournal`] for exactly-once delivery: a replay of the same
1233	    /// `(session_id, op_id)` after a brain crash is deduped (the PTY is not
1234	    /// written twice), and either outcome — fresh apply or dedup — is acked back
1235	    /// with an [`crate::msg::KIND_APPLIED`] frame so the brain can retire the
1236	    /// effect (B5 / KNOWN-HAZARDS 7.2). Without an `op_id` it is the legacy
1237	    /// direct write (no dedup, no ack).
1238	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
1239	    fn dispatch_input(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
1240	        let req: InputReq =
1241	            serde_json::from_value(env.payload).map_err(|e| format!("bad input payload: {e}"))?;
1242	        let bytes = decode_bytes(&req.data_b64).map_err(|e| format!("bad input base64: {e}"))?;
1243	        // Clone the session Arc out from under the sessions lock so the PTY write
1244	        // + fsync happen without holding the table lock (mirrors dispatch_subscribe).
1245	        let session = {
1246	            let sessions = self.sessions.lock().unwrap();
1247	            let h = sessions
1248	                .get(&req.session_id)
1249	                .ok_or_else(|| format!("no such session {}", req.session_id))?;
1250	            Arc::clone(&h.session)
1251	        };
1252	        match req.op_id {
1253	            Some(op) => {
1254	                let key = (req.session_id, op);
1255	                let outcome = self
1256	                    .journal
1257	                    .apply_once(key, EffectKind::PtyWrite, || {
1258	                        session
1259	                            .write_input(&bytes)
1260	                            .map_err(|e| io::Error::other(e.to_string()))
1261	                    })
1262	                    .map_err(|e| format!("write_input failed: {e}"))?;
1263	                // Ack exactly-once: both Applied and Deduped mean "durably landed".
1264	                send_frame(
1265	                    send,
1266	                    &applied_envelope(req.session_id, op, outcome == Outcome::Applied),
1267	                );
1268	                Ok(())
1269	            }
1270	            None => session
1271	                .write_input(&bytes)
1272	                .map_err(|e| format!("write_input failed: {e}")),
1273	        }
1274	    }
1275
1276	    /// Deliver an inbound message to an spt-hosted endpoint by ENDPOINT ID:
1277	    /// resolve the endpoint to its hosted session and inject the bytes straight
1278	    /// into that session's PTY (REQ-SEND-SPT-HOSTED). The endpoint-keyed
1279	    /// counterpart of [`dispatch_input`] — a CLI `spt send` to an spt-hosted
1280	    /// endpoint (broker holds its PTY, no `api listen` relay) lands here instead
1281	    /// of spooling silently. Resolution is atomic under the sessions lock (no
1282	    /// TOCTOU); the bytes are a pre-rendered `<EVENT>` envelope (the caller owns
1283	    /// the wire format — ADR-0020). Fire-and-forget: a direct write, no journal
1284	    /// (a user message is delivered like the relay's emit, not an exactly-once
1285	    /// effect). Replies [`KIND_ENDPOINT_INJECTED`] with `delivered` so the caller
1286	    /// knows whether to fall back to the spool.
1287	    ///
1288	    /// IDLE/DIRECT path only (REQ-SEND-SPT-HOSTED, MVP per CONTEXT:188): a write
1289	    /// arriving mid-agent-turn can interleave with the agent's active output —
1290	    /// activity-gated routing (idle→inject / active→spool-for-poll-drain) is a
1291	    /// deferred follow wave. The write is logged so an active-state inject is
1292	    /// never silent.
1293	    // [impl->REQ-SEND-SPT-HOSTED]
1294	    fn dispatch_endpoint_input(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
1295	        let req: EndpointInputReq = serde_json::from_value(env.payload)
1296	            .map_err(|e| format!("bad endpoint-input payload: {e}"))?;
1297	        let bytes =
1298	            decode_bytes(&req.data_b64).map_err(|e| format!("bad endpoint-input base64: {e}"))?;
1299	        // Resolve endpoint → session under the lock, cloning the Arc out so the
1300	        // PTY write happens without holding the table lock (dispatch_input rule).
1301	        let session = {
1302	            let sessions = self.sessions.lock().unwrap();
1303	            sessions
1304	                .values()
1305	                .find(|h| h.endpoint == req.endpoint)
1306	                .map(|h| Arc::clone(&h.session))
1307	        };
1308	        match session {
1309	            Some(session) => {
1310	                // IDLE/direct inject. Logged (REQ-SEND-SPT-HOSTED MVP): activity is
1311	                // not yet gated, so note the raw write — never a silent mid-turn
1312	                // interleave passed off as done.
1313	                eprintln!(
1314	                    "ENDPOINT_INJECT:{} ({} bytes, idle-direct — activity not yet gated)",
1315	                    req.endpoint,
1316	                    bytes.len()
1317	                );
1318	                session
1319	                    .write_input(&bytes)
1320	                    .map_err(|e| format!("write_input failed: {e}"))?;
1321	                send_frame(send, &endpoint_injected_envelope(&req.endpoint, true));
1322	                Ok(())
1323	            }
1324	            // No hosted session for this endpoint — tell the caller to spool.
1325	            None => {
1326	                send_frame(send, &endpoint_injected_envelope(&req.endpoint, false));
1327	                Ok(())
1328	            }
1329	        }
1330	    }
1331
1332	    /// Resize a session's PTY — CONTROLLER-EXCLUSIVE (REQ-RCVIEW-1). The broker
1333	    /// rejects a resize arriving on any stream that is not the session's current
1334	    /// controller (a viewer's resize is refused), then updates the stored size
1335	    /// and pushes a `size` frame to every viewer (the letterbox feed).
1336	    fn dispatch_resize(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
1337	        let req: ResizeReq =
1338	            serde_json::from_value(env.payload).map_err(|e| format!("bad resize payload: {e}"))?;
1339	        let (session, log) = {
1340	            let sessions = self.sessions.lock().unwrap();
1341	            let h = sessions
1342	                .get(&req.session_id)
1343	                .ok_or_else(|| format!("no such session {}", req.session_id))?;
1344	            (Arc::clone(&h.session), Arc::clone(&h.log))

        log.lock().unwrap().detach_if(send);
        Ok(())
    }

    /// Inject input bytes into a session's PTY.
    ///
    /// With an `op_id` the write is routed through the broker-owned
    /// [`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))


Architecture is clear. Now find the spawn handler that builds `HostedSession` + where `translation_binary` currently lands (D-plumbing threaded it — need to see if consumed yet).


cd crates/spt-daemon/src; echo "=== HostedSession construction + spawn handler ==="; grep -n "HostedSession {\|fn dispatch_spawn\|fn spawn_session\|fn handle_spawn\|SpawnReq\|PtySession::\|fn spawn_in\|translation" broker.rs; echo "=== translation_binary consumers across crate ==="; grep -rn "translation_binary" .

Find spawn handler + translation_binary usage


/usr/bin/bash: line 6: cd: crates/spt-daemon/src: No such file or directory
=== HostedSession construction + spawn handler ===
58:    NetStreamsReply, PairJoinReply, PairJoinReq, ResizeReq, SessionInfo, SessionsReply, SpawnReq,
715:struct HostedSession {
1063:    fn dispatch_spawn(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
1064:        let req: SpawnReq =
1070:        let session = PtySession::spawn_program_in_env(
1146:            HostedSession {
1183:        // (`PtySession::wait`, broker.rs exit-waiter), so it deadlocks every live
=== translation_binary consumers across crate ===
./applyhost.rs:577:            translation_binary: None,
./harnesshost.rs:121:    let translation_binary = manifest
./harnesshost.rs:122:        .message_idle_translation_binary
./harnesshost.rs:128:        translation_binary,
./harnesshost.rs:141:    pub translation_binary: Option<String>,
./harnesshost.rs:169:        translation_binary,
./harnesshost.rs:183:            translation_binary,
./harnesshost.rs:246:    // declares none yields `translation_binary = None` (the degenerate raw-inject /
./harnesshost.rs:249:    fn prepare_threads_translation_binary_path_when_declared() {
./harnesshost.rs:259:            prepared.translation_binary.as_deref(),
./harnesshost.rs:266:        assert!(prepared.translation_binary.is_none());
./livehost.rs:1108:                    translation_binary: None,
./msg.rs:214:    pub translation_binary: Option<String>,
./shellhost.rs:239:            translation_binary: None,


1063	    fn dispatch_spawn(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
1064	        let req: SpawnReq =
1065	            serde_json::from_value(env.payload).map_err(|e| format!("bad spawn payload: {e}"))?;
1066	        // REQ-HOST-RUN-2: spawn in the requested project cwd when carried;
1067	        // `None` inherits the broker's cwd (legacy/test + W1 bringup shortcut).
1068	        // Inject the manifest [env] vars (REQ-HAZARD-ENV-SUBST) on top of the
1069	        // inherited env — empty for a legacy/N-1 spawn (inherit only).
1070	        let session = PtySession::spawn_program_in_env(
1071	            &req.program,
1072	            &req.args,
1073	            SurfaceSize::new(req.rows, req.cols),
1074	            req.cwd.as_deref(),
1075	            req.env.iter().map(|(k, v)| (k.as_str(), v.as_str())),
1076	        )
1077	        .map_err(|e| format!("spawn failed: {e}"))?;
1078	        let session = Arc::new(session);
1079	        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
1080	        let pid = session.process_id();
1081
1082	        // The output log, with the spawning connection pre-attached as the
1083	        // controller (identity `None` = LOCAL, not a remote driver) so no chunk
1084	        // is missed before an explicit subscribe — and so a remote attach to a
1085	        // spawned-but-undriven session takes control silently (no loud displace
1086	        // of the local-spawner placeholder; REQ-RCVIEW-1).
1087	        let log = Arc::new(Mutex::new(OutputLog::new(
1088	            id,
1089	            self.log_cap_chunks,
1090	            req.endpoint.clone(),
1091	            (req.rows, req.cols),
1092	        )));
1093	        log.lock()
1094	            .unwrap()
1095	            .become_controller(Arc::clone(send), None, 0);
1096
1097	        // Output drain → append under the log lock (fast: ring + viewer fan-out),
1098	        // then deliver to the controller OFF the lock with a bounded deadline
1099	        // (W1 / REQ-HAZARD-INJECT-CONTROL-COEXIST). The controller write is no
1100	        // longer inline under the log lock, so a backed-up controller can never
1101	        // wedge the session; a fully-stalled controller is detached after the
1102	        // deadline (bounded wedge, never permanent).
1103	        let log_drain = Arc::clone(&log);
1104	        let drain = session
1105	            .drain(move |chunk| {
1106	                let job = log_drain.lock().unwrap().append(chunk);
1107	                if let Some(job) = job {
1108	                    if let Err(epoch) = job.deliver() {
1109	                        log_drain.lock().unwrap().mark_controller_gone(epoch);
1110	                    }
1111	                }
1112	            })
1113	            .map_err(|e| format!("drain failed: {e}"))?;
1114
1115	        // Exit waiter → one `exit` frame when the child is reaped, then the dead
1116	        // session is removed from the table. Runs on its own thread; `wait`
1117	        // holds the child lock but `kill` uses a split killer.
1118	        let sessions_exit = Arc::clone(&self.sessions);
1119	        let log_exit = Arc::clone(&log);
1120	        let waiter = Arc::clone(&session);
1121	        thread::spawn(move || {
1122	            if let Ok(status) = waiter.wait() {
1123	                let ev = ExitEvent {
1124	                    session_id: id,
1125	                    code: Some(status.exit_code() as i32),
1126	                };
1127	                let frame = Envelope::new(
1128	                    KIND_EXIT,
1129	                    serde_json::to_value(ev).expect("ExitEvent serializes"),
1130	                );
1131	                // Send to every attached sink — controller + all viewers (under
1132	                // the log lock).
1133	                let sinks = log_exit.lock().unwrap().all_sinks();
1134	                for sink in sinks {
1135	                    if let Ok(mut s) = sink.lock() {
1136	                        let _ = write_frame(&mut *s, &frame);
1137	                    }
1138	                }
1139	            }
1140	            // Reap the dead session from the table.
1141	            sessions_exit.lock().unwrap().remove(&id);
1142	        });
1143
1144	        self.sessions.lock().unwrap().insert(
1145	            id,
1146	            HostedSession {
1147	                session,
1148	                drain,
1149	                log,
1150	                endpoint: req.endpoint.clone(),
1151	            },
1152	        );
1153
1154	        let resp = Spawned {
1155	            session_id: id,
1156	            pid,
1157	        };
1158	        let frame = Envelope::new(
1159	            KIND_SPAWNED,
1160	            serde_json::to_value(resp).expect("Spawned serializes"),
1161	        );
1162	        send_frame(send, &frame);
1163	        Ok(id)
1164	    }
1165
1166	    /// Re-attach a brain to a session's output under the W2.5 controller/viewer
1167	    /// model (REQ-RCVIEW-1 / REQ-KICK-1), replaying from `from_seq` for gapless,
1168	    /// exactly-once resume. The `intent` + `by` select the role; the broker
1169	    /// resolves it (controller take / viewer add / busy-refuse / loud displace)
1170	    /// and replies the [`SubscribeOutcome`] so the serving brain can wake
1171	    /// (controller-only), stay wake-neutral (viewer), or refuse (busy). A
1172	    /// same-`by` re-subscribe silently re-takes the controller — the gate-#7
1173	    /// self-kick guard.
1174	    fn dispatch_subscribe(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
1175	        let req: SubscribeReq = serde_json::from_value(env.payload)
1176	            .map_err(|e| format!("bad subscribe payload: {e}"))?;
1177	        // Clone the log Arc out from under the sessions lock, then resolve under
1178	        // the log lock — never hold both locks at once.
1179	        //
1180	        // NOTE (REQ-HAZARD-RC-ATTACH-FAILFAST): a `session.try_wait()` dead-child
1181	        // fast-detect MUST NOT live here — `try_wait` locks the same child mutex
1182	        // the per-session exit-waiter holds for the WHOLE life of a live child

    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 → append under the log lock (fast: ring + viewer fan-out),
        // then deliver to the controller OFF the lock with a bounded deadline
        // (W1 / REQ-HAZARD-INJECT-CONTROL-COEXIST). The controller write is no
        // longer inline under the log lock, so a backed-up controller can never
        // wedge the session; a fully-stalled controller is detached after the
        // deadline (bounded wedge, never permanent).
        let log_drain = Arc::clone(&log);
        let drain = session
            .drain(move |chunk| {
                let job = log_drain.lock().unwrap().append(chunk);
                if let Some(job) = job {
                    if let Err(epoch) = job.deliver() {
                        log_drain.lock().unwrap().mark_controller_gone(epoch);
                    }
                }
            })
            .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(),
            },
        );

        let resp = Spawned {
            session_id: id,
            pid,
        };
        let frame = Envelope::new(
            KIND_SPAWNED,
            serde_json::to_value(resp).expect("Spawned serializes"),
        );
        send_frame(send, &frame);
        Ok(id)
    }

    /// Re-attach a brain to a session's output under the W2.5 controller/viewer
    /// model (REQ-RCVIEW-1 / REQ-KICK-1), replaying from `from_seq` for gapless,
    /// exactly-once resume. The `intent` + `by` select the role; the broker
    /// resolves it (controller take / viewer add / busy-refuse / loud displace)
    /// and replies the [`SubscribeOutcome`] so the serving brain can wake
    /// (controller-only), stay wake-neutral (viewer), or refuse (busy). A
    /// same-`by` re-subscribe silently re-takes the controller — the gate-#7
    /// self-kick guard.
    fn dispatch_subscribe(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
        let req: SubscribeReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad subscribe payload: {e}"))?;
        // Clone the log Arc out from under the sessions lock, then resolve under
        // the log lock — never hold both locks at once.
        //
        // NOTE (REQ-HAZARD-RC-ATTACH-FAILFAST): a `session.try_wait()` dead-child
        // fast-detect MUST NOT live here — `try_wait` locks the same child mutex
        // the per-session exit-waiter holds for the WHOLE life of a live child


Full data-plane mapped. Let me check the W1 deadline constant (to reuse its order), the node-label availability, and `write_input`'s signature/error type.


cd crates/spt-daemon/src && grep -rn "CONTROLLER_WRITE_DEADLINE\|_DEADLINE\b\|INJECT_COMMIT" *.rs | grep -i "const\|Duration" | head; echo "=== node label source ==="; grep -rn "node_label\|fn node_id\|fn label\b\|self.net" broker.rs | head; echo "=== write_input sig ==="; grep -rn "fn write_input" ../../spt-term/src/*.rs

Find deadline const, node label, write_input signature


<owl_messages>
<owl_message from="doyle" priority="high">doyle GATE PASS — W7-swap @95f742f + F1 @6034479. Verified myself: clippy --workspace -D warnings = 0; rc::tests::normalize_maps_backspace_but_leaves_detach_and_others = pass (the 0x7f→0x08 assertion is non-vacuous — fails on the old one-way code); traceable EXIT=0; F1 = doc-only (runner-contract note, root correctly nailed = shared process-global SPT_HOME set_var race under parallel-thread harness + contending brokers; doc-note over serial_test is the right call since CI is nextest-safe). REQ-HAZARD-RC-INPUT-KEY-ENCODING [impl,unit] satisfied. Rebuilding the operator binary @6034479 + deploying to PATH for the final HITL Ctrl+Backspace capture. Proceed with W2 Layer C; surface it + Layer G int when built. Good turn.</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.


/usr/bin/bash: line 6: cd: crates/spt-daemon/src: No such file or directory
=== node label source ===
825:        self.net.set(host).is_ok()
836:        self.net.get()
887:        self.net.get().map(|h| h.conn_count() as usize).unwrap_or(0)
1044:        if let Some(host) = self.net.get() {
1364:        let reply = match self.net.get() {
1555:        let host = self.net.get().ok_or("net disabled")?;
1605:        let host = self.net.get().ok_or("net disabled")?;
1635:        let host = self.net.get().ok_or("net disabled")?;
1657:        let host = self.net.get().ok_or("net disabled")?;
1674:        let host = self.net.get().ok_or("net disabled")?;
=== write_input sig ===
../../spt-term/src/pty.rs:236:    fn write_input(&self, bytes: &[u8]) -> Result<(), SurfaceError> {
../../spt-term/src/surface.rs:105:    fn write_input(&self, bytes: &[u8]) -> Result<(), SurfaceError>;


doyle GATE PASS on W7-swap + F1 — acknowledged. Proceeding with W2 Layer C as cleared.

No existing inject deadline const (I'll define `INJECT_COMMIT_DEADLINE`). Let me find W1's deadline for ordering + the node-label source + confirm `SurfaceError`.


grep -rn "DEADLINE\|Duration::from_secs\|Duration::from_millis" crates/spt-daemon/src/broker.rs crates/spt-daemon/src/attach.rs | grep -i "const\|: Duration\|= Duration" | head; echo "=== node label / machine id ==="; grep -rn "fn .*label\|node_label\|machine_label\|fn local_label" crates/spt-daemon/src/nethost.rs crates/spt-net/src/**/*.rs 2>/dev/null | head; echo "=== SurfaceError import in broker ==="; grep -n "SurfaceError\|use spt_term\|PtySession" crates/spt-daemon/src/broker.rs | head

Find W1 deadline, node label, surface imports


grep: crates/spt-daemon/src/broker.rs: No such file or directory
grep: crates/spt-daemon/src/attach.rs: No such file or directory
=== node label / machine id ===
=== SurfaceError import in broker ===
grep: crates/spt-daemon/src/broker.rs: No such file or directory


cd /c/Users/decid/Documents/projects/spt-core && pwd && grep -rn "DEADLINE\|= Duration::from" crates/spt-daemon/src/broker.rs | grep -i "const\|Duration" | head; echo "=== imports in broker ==="; grep -n "use spt_term\|SurfaceError\|use std::sync::mpsc\|use std::sync::atomic\|AtomicBool" crates/spt-daemon/src/broker.rs | head

Reset cwd + find deadline + imports


/c/Users/decid/Documents/projects/spt-core
134:const CONTROLLER_WRITE_DEADLINE: Duration = Duration::from_secs(5);
139:const CONTROLLER_SEND_POLL: Duration = Duration::from_millis(5);
1873:            start.elapsed() < CONTROLLER_WRITE_DEADLINE + Duration::from_secs(5),
=== imports in broker ===
36:use std::sync::atomic::{AtomicU64, Ordering};
37:use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError};
43:use spt_term::{Drain, PtySession, SessionSurface, SurfaceSize};


W1 deadline = 5s; I'll mirror with `INJECT_COMMIT_DEADLINE`. Now the node label for `ToBinary::Init`. Check what the broker can know about its local node label.


grep -rn "fn .*node_label\|node_label_display\|fn local_node\|fn machine_label\|hostname" crates/spt-daemon/src/machineid.rs crates/spt-store/src/*.rs 2>/dev/null | head; echo "=== how Init.node intended (ADR-0022/JIT) ==="; grep -rn "node" crates/spt-daemon/src/translation.rs | head

Find node-label source for Init


crates/spt-store/src/hostlabel.rs:1://! The node's human-readable label: the OS hostname (M7 D2, REQ-SUBNET-3 —
crates/spt-store/src/hostlabel.rs:6://! hostname at startup and stamps it onto every registry row it advertises
crates/spt-store/src/hostlabel.rs:7://! (a hostname change reaches peers as ordinary epoch-bumped updates) —
crates/spt-store/src/hostlabel.rs:11:/// This machine's OS hostname, if the OS will say. `None` (rather than a
crates/spt-store/src/hostlabel.rs:14:pub fn os_hostname() -> Option<String> {
crates/spt-store/src/hostlabel.rs:29:    // `gethostname(2)` — no env dependence (HOSTNAME is shell-managed and
crates/spt-store/src/hostlabel.rs:32:    let rc = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) };
crates/spt-store/src/hostlabel.rs:50:    // whitespace-trimmed label (the actual value is the runner's hostname —
crates/spt-store/src/hostlabel.rs:53:    fn hostname_is_nonempty_and_trimmed_on_supported_platforms() {
crates/spt-store/src/hostlabel.rs:55:            let label = os_hostname().expect("supported OS yields a hostname");
=== how Init.node intended (ADR-0022/JIT) ===
45:    /// First line: identity. `{"type":"init","endpoint_id":…,"node":…}`.
46:    Init { endpoint_id: String, node: String },
343:                node: "n1".into(),
346:            r#"{"type":"init","endpoint_id":"doyle","node":"n1"}"#


Everything resolved: node label via `spt_store::hostlabel::os_hostname()`, deadline mirrors W1's 5s, `SessionSurface` trait gives `write_input`.

First, refactor `TranslationChild` so `terminate` is callable via `&self`/`Arc` (the FAULT path needs to kill from the worker holding an Arc):


Edit

pub struct TranslationChild {
    child: Child,
    /// `None` after [`close_stdin`](Self::close_stdin) / drop has taken it.
    stdin: Mutex<Option<ChildStdin>>,
    _reader: JoinHandle<()>,
    pid: Option<u32>,
}

pub struct TranslationChild {
    /// `None` once reaped — behind a `Mutex` so [`terminate`](Self::terminate) is
    /// callable through a shared `Arc` (the broker shares the child between the
    /// inject worker, which kills it on FAULT, and `dispatch_input`, which fires
    /// content-free pings — Layer C).
    child: Mutex<Option<Child>>,
    /// `None` after [`close_stdin`](Self::close_stdin) / reap has taken it.
    stdin: Mutex<Option<ChildStdin>>,
    _reader: JoinHandle<()>,
    pid: Option<u32>,
}