1	//! The versioned broker↔brain IPC frame schema and connection handshake.
2	//!
3	//! This is the single most update-frequency-sensitive contract in spt-core
4	//! (KNOWN-HAZARDS 2.3): self-update (ADR-0004) swaps the brain routinely while
5	//! the broker persists, so **a newer brain must talk to an older broker**. Two
6	//! mechanisms make that hold by construction:
7	//!
8	//! 1. **Forward-compatible envelopes.** Every frame is an [`Envelope`] with an
9	//!    explicit `protocol_version`, a *string* `kind` discriminator, and an
10	//!    opaque `payload` ([`serde_json::Value`]). A string kind means an older
11	//!    broker decoding a frame whose `kind` it does not recognize gets a clean
12	//!    `Envelope` — an unknown *kind* is a logic-layer concern, never a decode
13	//!    error. serde's default (no `deny_unknown_fields`) means unknown *trailing
14	//!    fields* a newer brain adds are silently ignored on the older side.
15	//! 2. **A version handshake.** On connect, the brain sends a [`Hello`]; the
16	//!    broker accepts iff the brain's `protocol_version >= MIN_COMPATIBLE_VERSION`.
17	//!    A newer brain (higher version) is accepted by an older broker; a brain
18	//!    too old to satisfy the broker's floor is rejected with a typed error
19	//!    rather than a silent misparse.
20	// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
21
22	use serde::{Deserialize, Serialize};
23
24	/// The IPC protocol version this build speaks. Bump **only** on a breaking
25	/// frame-schema change (a field whose absence an older peer cannot tolerate).
26	/// Additive changes — new `kind`s, new optional fields — do **not** bump this.
27	pub const IPC_PROTOCOL_VERSION: u32 = 1;
28
29	/// The oldest peer `protocol_version` this build will still handshake with.
30	/// Held at `1` for v1; raised only when an old version is genuinely retired.
31	pub const MIN_COMPATIBLE_VERSION: u32 = 1;
32
33	/// Which side of the IPC a connecting peer is. The broker is the stable kernel;
34	/// the brain is the restartable logic layer (ADR-0004).
35	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36	#[serde(rename_all = "snake_case")]
37	pub enum Role {
38	    /// The stable kernel that holds the un-transferable resources.
39	    Broker,
40	    /// The restartable logic layer that attaches over the IPC.
41	    Brain,
42	}
43
44	/// The first frame a connecting peer sends. Intentionally minimal and
45	/// append-only: future fields must be added with `#[serde(default)]` so an
46	/// older peer decoding a newer `Hello` still succeeds (2.3).
47	#[derive(Debug, Clone, Serialize, Deserialize)]
48	pub struct Hello {
49	    /// The protocol version the sender speaks ([`IPC_PROTOCOL_VERSION`]).
50	    pub protocol_version: u32,
51	    /// Which side the sender is.
52	    pub role: Role,
53	}
54
55	impl Hello {
56	    /// A `Hello` for this build, declaring [`IPC_PROTOCOL_VERSION`].
57	    pub fn current(role: Role) -> Self {
58	        Hello { protocol_version: IPC_PROTOCOL_VERSION, role }
59	    }
60	}
61
62	/// Why a handshake was refused — surfaced typed rather than as a silent
63	/// misparse so an incompatible peer fails loudly (the 2.3 failure mode is a
64	/// peer dying *unlogged* on an arity/version mismatch).
65	#[derive(Debug, Clone, PartialEq, Eq)]
66	pub enum HandshakeError {
67	    /// The peer's `protocol_version` is below this build's compatibility floor.
68	    IncompatibleVersion { peer: u32, min_supported: u32 },
69	    /// The peer announced a role we did not expect on this connection.
70	    UnexpectedRole { expected: Role, got: Role },
71	}
72
73	impl std::fmt::Display for HandshakeError {
74	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75	        match self {
76	            HandshakeError::IncompatibleVersion { peer, min_supported } => write!(
77	                f,
78	                "peer protocol version {peer} below minimum supported {min_supported}"
79	            ),
80	            HandshakeError::UnexpectedRole { expected, got } => {
81	                write!(f, "expected peer role {expected:?}, got {got:?}")
82	            }
83	        }
84	    }
85	}
86
87	impl std::error::Error for HandshakeError {}
88
89	/// Decide whether to accept a peer's [`Hello`] on a connection expecting
90	/// `expected_peer`. Accepts any `protocol_version >= MIN_COMPATIBLE_VERSION`
91	/// (a *newer* brain is fine against an *older* broker) and rejects below the
92	/// floor or on a role mismatch. This is the whole of the version-tolerance
93	/// policy — kept in one place so the rule is auditable.
94	// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
95	pub fn accept_hello(hello: &Hello, expected_peer: Role) -> Result<(), HandshakeError> {
96	    if hello.protocol_version < MIN_COMPATIBLE_VERSION {
97	        return Err(HandshakeError::IncompatibleVersion {
98	            peer: hello.protocol_version,
99	            min_supported: MIN_COMPATIBLE_VERSION,
100	        });
101	    }
102	    if hello.role != expected_peer {
103	        return Err(HandshakeError::UnexpectedRole { expected: expected_peer, got: hello.role });
104	    }
105	    Ok(())
106	}
107
108	/// One IPC message. The forward-compatible envelope: `protocol_version` lets a
109	/// reader scope its interpretation, `kind` is a *string* so an unknown message
110	/// type decodes cleanly (the logic layer decides what to do with an unknown
111	/// kind), and `payload` is opaque JSON so each kind owns its own shape without
112	/// the envelope needing to know it. Unknown trailing fields are ignored on
113	/// decode (serde default), so a newer brain may extend a frame and an older
114	/// broker still parses it.
115	#[derive(Debug, Clone, Serialize, Deserialize)]
116	pub struct Envelope {
117	    /// The protocol version of the sender (for scoped interpretation).
118	    pub protocol_version: u32,
119	    /// The message-kind discriminator. A string, not an enum, so an unrecognized
120	    /// kind is never a decode failure on an older peer.
121	    pub kind: String,
122	    /// The kind-specific body. Absent bodies default to JSON `null`.
123	    #[serde(default)]
124	    pub payload: serde_json::Value,
125	}
126
127	impl Envelope {
128	    /// Build an envelope for this build's protocol version with a JSON payload.
129	    pub fn new(kind: impl Into<String>, payload: serde_json::Value) -> Self {
130	        Envelope { protocol_version: IPC_PROTOCOL_VERSION, kind: kind.into(), payload }
131	    }
132	}
133
134	#[cfg(test)]
135	mod tests {
136	    use super::*;
137	    use serde_json::json;
138
139	    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
140	    // The load-bearing forward-compat property: a frame a *newer* brain emits —
141	    // carrying an unknown trailing field — must decode cleanly on an *older*
142	    // broker that has never heard of that field. We simulate the newer wire form
143	    // with raw JSON (a superset of today's Envelope) and decode into the current
144	    // struct; the unknown field is dropped, not rejected.
145	    #[test]
146	    fn envelope_with_unknown_trailing_field_decodes_on_older_peer() {
147	        let newer_wire = json!({
148	            "protocol_version": 2,
149	            "kind": "attach",
150	            "payload": { "session": "abc" },
151	            "future_field_an_older_broker_never_heard_of": { "nested": [1, 2, 3] }
152	        })
153	        .to_string();
154
155	        let decoded: Envelope =
156	            serde_json::from_str(&newer_wire).expect("older peer must tolerate unknown fields");
157	        assert_eq!(decoded.protocol_version, 2);
158	        assert_eq!(decoded.kind, "attach");
159	        assert_eq!(decoded.payload, json!({ "session": "abc" }));
160	    }
161
162	    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
163	    // An unknown *kind* is not a decode error — the envelope parses and the
164	    // logic layer is what decides it cannot handle the kind.
165	    #[test]
166	    fn unknown_kind_is_not_a_decode_error() {
167	        let wire = json!({ "protocol_version": 1, "kind": "a_kind_from_the_future" }).to_string();
168	        let decoded: Envelope = serde_json::from_str(&wire).unwrap();
169	        assert_eq!(decoded.kind, "a_kind_from_the_future");
170	        assert!(decoded.payload.is_null()); // payload defaulted
171	    }
172
173	    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
174	    // A newer brain (higher protocol version, extra Hello fields) is accepted by
175	    // an older broker — the exact "newer brain ↔ older broker" direction 2.3
176	    // demands.
177	    #[test]
178	    fn handshake_accepts_newer_brain_on_older_broker() {
179	        let newer_hello_wire = json!({
180	            "protocol_version": IPC_PROTOCOL_VERSION + 5,
181	            "role": "brain",
182	            "added_in_a_future_release": true
183	        })
184	        .to_string();
185	        let hello: Hello = serde_json::from_str(&newer_hello_wire).unwrap();
186	        assert_eq!(accept_hello(&hello, Role::Brain), Ok(()));
187	    }
188
189	    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
190	    // A peer below the compatibility floor is rejected loudly, not misparsed.
191	    #[test]
192	    fn handshake_rejects_below_floor() {
193	        let hello = Hello { protocol_version: MIN_COMPATIBLE_VERSION - 1, role: Role::Brain };
194	        assert!(matches!(
195	            accept_hello(&hello, Role::Brain),
196	            Err(HandshakeError::IncompatibleVersion { .. })
197	        ));
198	    }
199
200	    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT] role mismatch is a typed refusal.
201	    #[test]
202	    fn handshake_rejects_role_mismatch() {
203	        let hello = Hello::current(Role::Broker);
204	        assert!(matches!(
205	            accept_hello(&hello, Role::Brain),
206	            Err(HandshakeError::UnexpectedRole { .. })
207	        ));
208	    }
209	}
210

//! The versioned broker↔brain IPC frame schema and connection handshake.
//!
//! This is the single most update-frequency-sensitive contract in spt-core
//! (KNOWN-HAZARDS 2.3): self-update (ADR-0004) swaps the brain routinely while
//! the broker persists, so **a newer brain must talk to an older broker**. Two
//! mechanisms make that hold by construction:
//!
//! 1. **Forward-compatible envelopes.** Every frame is an [`Envelope`] with an
//!    explicit `protocol_version`, a *string* `kind` discriminator, and an
//!    opaque `payload` ([`serde_json::Value`]). A string kind means an older
//!    broker decoding a frame whose `kind` it does not recognize gets a clean
//!    `Envelope` — an unknown *kind* is a logic-layer concern, never a decode
//!    error. serde's default (no `deny_unknown_fields`) means unknown *trailing
//!    fields* a newer brain adds are silently ignored on the older side.
//! 2. **A version handshake.** On connect, the brain sends a [`Hello`]; the
//!    broker accepts iff the brain's `protocol_version >= MIN_COMPATIBLE_VERSION`.
//!    A newer brain (higher version) is accepted by an older broker; a brain
//!    too old to satisfy the broker's floor is rejected with a typed error
//!    rather than a silent misparse.
// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]

use serde::{Deserialize, Serialize};

/// The IPC protocol version this build speaks. Bump **only** on a breaking
/// frame-schema change (a field whose absence an older peer cannot tolerate).
/// Additive changes — new `kind`s, new optional fields — do **not** bump this.
pub const IPC_PROTOCOL_VERSION: u32 = 1;

/// The oldest peer `protocol_version` this build will still handshake with.
/// Held at `1` for v1; raised only when an old version is genuinely retired.
pub const MIN_COMPATIBLE_VERSION: u32 = 1;

/// Which side of the IPC a connecting peer is. The broker is the stable kernel;
/// the brain is the restartable logic layer (ADR-0004).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Role {
    /// The stable kernel that holds the un-transferable resources.
    Broker,
    /// The restartable logic layer that attaches over the IPC.
    Brain,
}

/// The first frame a connecting peer sends. Intentionally minimal and
/// append-only: future fields must be added with `#[serde(default)]` so an
/// older peer decoding a newer `Hello` still succeeds (2.3).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hello {
    /// The protocol version the sender speaks ([`IPC_PROTOCOL_VERSION`]).
    pub protocol_version: u32,
    /// Which side the sender is.
    pub role: Role,
}

impl Hello {
    /// A `Hello` for this build, declaring [`IPC_PROTOCOL_VERSION`].
    pub fn current(role: Role) -> Self {
        Hello { protocol_version: IPC_PROTOCOL_VERSION, role }
    }
}

/// Why a handshake was refused — surfaced typed rather than as a silent
/// misparse so an incompatible peer fails loudly (the 2.3 failure mode is a
/// peer dying *unlogged* on an arity/version mismatch).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HandshakeError {
    /// The peer's `protocol_version` is below this build's compatibility floor.
    IncompatibleVersion { peer: u32, min_supported: u32 },
    /// The peer announced a role we did not expect on this connection.
    UnexpectedRole { expected: Role, got: Role },
}

impl std::fmt::Display for HandshakeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            HandshakeError::IncompatibleVersion { peer, min_supported } => write!(
                f,
                "peer protocol version {peer} below minimum supported {min_supported}"
            ),
            HandshakeError::UnexpectedRole { expected, got } => {
                write!(f, "expected peer role {expected:?}, got {got:?}")
            }
        }
    }
}

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

/// Decide whether to accept a peer's [`Hello`] on a connection expecting
/// `expected_peer`. Accepts any `protocol_version >= MIN_COMPATIBLE_VERSION`
/// (a *newer* brain is fine against an *older* broker) and rejects below the
/// floor or on a role mismatch. This is the whole of the version-tolerance
/// policy — kept in one place so the rule is auditable.
// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
pub fn accept_hello(hello: &Hello, expected_peer: Role) -> Result<(), HandshakeError> {
    if hello.protocol_version < MIN_COMPATIBLE_VERSION {
        return Err(HandshakeError::IncompatibleVersion {
            peer: hello.protocol_version,
            min_supported: MIN_COMPATIBLE_VERSION,
        });
    }
    if hello.role != expected_peer {
        return Err(HandshakeError::UnexpectedRole { expected: expected_peer, got: hello.role });
    }
    Ok(())
}

/// One IPC message. The forward-compatible envelope: `protocol_version` lets a
/// reader scope its interpretation, `kind` is a *string* so an unknown message
/// type decodes cleanly (the logic layer decides what to do with an unknown
/// kind), and `payload` is opaque JSON so each kind owns its own shape without
/// the envelope needing to know it. Unknown trailing fields are ignored on
/// decode (serde default), so a newer brain may extend a frame and an older
/// broker still parses it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Envelope {
    /// The protocol version of the sender (for scoped interpretation).
    pub protocol_version: u32,
    /// The message-kind discriminator. A string, not an enum, so an unrecognized
    /// kind is never a decode failure on an older peer.
    pub kind: String,
    /// The kind-specific body. Absent bodies default to JSON `null`.
    #[serde(default)]
    pub payload: serde_json::Value,
}

impl Envelope {
    /// Build an envelope for this build's protocol version with a JSON payload.
    pub fn new(kind: impl Into<String>, payload: serde_json::Value) -> Self {
        Envelope { protocol_version: IPC_PROTOCOL_VERSION, kind: kind.into(), payload }
    }
}

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

    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
    // The load-bearing forward-compat property: a frame a *newer* brain emits —
    // carrying an unknown trailing field — must decode cleanly on an *older*
    // broker that has never heard of that field. We simulate the newer wire form
    // with raw JSON (a superset of today's Envelope) and decode into the current
    // struct; the unknown field is dropped, not rejected.
    #[test]
    fn envelope_with_unknown_trailing_field_decodes_on_older_peer() {
        let newer_wire = json!({
            "protocol_version": 2,
            "kind": "attach",
            "payload": { "session": "abc" },
            "future_field_an_older_broker_never_heard_of": { "nested": [1, 2, 3] }
        })
        .to_string();

        let decoded: Envelope =
            serde_json::from_str(&newer_wire).expect("older peer must tolerate unknown fields");
        assert_eq!(decoded.protocol_version, 2);
        assert_eq!(decoded.kind, "attach");
        assert_eq!(decoded.payload, json!({ "session": "abc" }));
    }

    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
    // An unknown *kind* is not a decode error — the envelope parses and the
    // logic layer is what decides it cannot handle the kind.
    #[test]
    fn unknown_kind_is_not_a_decode_error() {
        let wire = json!({ "protocol_version": 1, "kind": "a_kind_from_the_future" }).to_string();
        let decoded: Envelope = serde_json::from_str(&wire).unwrap();
        assert_eq!(decoded.kind, "a_kind_from_the_future");
        assert!(decoded.payload.is_null()); // payload defaulted
    }

    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
    // A newer brain (higher protocol version, extra Hello fields) is accepted by
    // an older broker — the exact "newer brain ↔ older broker" direction 2.3
    // demands.
    #[test]
    fn handshake_accepts_newer_brain_on_older_broker() {
        let newer_hello_wire = json!({
            "protocol_version": IPC_PROTOCOL_VERSION + 5,
            "role": "brain",
            "added_in_a_future_release": true
        })
        .to_string();
        let hello: Hello = serde_json::from_str(&newer_hello_wire).unwrap();
        assert_eq!(accept_hello(&hello, Role::Brain), Ok(()));
    }

    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
    // A peer below the compatibility floor is rejected loudly, not misparsed.
    #[test]
    fn handshake_rejects_below_floor() {
        let hello = Hello { protocol_version: MIN_COMPATIBLE_VERSION - 1, role: Role::Brain };
        assert!(matches!(
            accept_hello(&hello, Role::Brain),
            Err(HandshakeError::IncompatibleVersion { .. })
        ));
    }

    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT] role mismatch is a typed refusal.
    #[test]
    fn handshake_rejects_role_mismatch() {
        let hello = Hello::current(Role::Broker);
        assert!(matches!(
            accept_hello(&hello, Role::Brain),
            Err(HandshakeError::UnexpectedRole { .. })
        ));
    }
}


NOTE fROM THE USER: proceed with next steps.


Now PtySession spawn API + transport API + the existing contract_e2e harness.


1	//! The OS-split local-IPC carrier: named pipe (Windows) / Unix-domain socket
2	//! (unix), behind one [`DaemonTransport`] trait.
3	//!
4	//! This mirrors how `spt-term`'s `SessionSurface` abstracts ConPTY vs forkpty:
5	//! the broker and brain talk through one trait, and the platform split lives in
6	//! a single implementation ([`LocalSocketTransport`], over the `interprocess`
7	//! crate's local sockets — namespaced names work on both Windows and Linux).
8	//! The byte framing ([`crate::codec`]) and the version handshake
9	//! ([`crate::frame`]) ride on top, transport-agnostic.
10	// [impl->REQ-DAEMON-2]
11
12	use std::io::{self, Read, Write};
13
14	use interprocess::local_socket::{
15	    prelude::*, GenericNamespaced, ListenerOptions, Stream,
16	};
17
18	use crate::codec::{read_frame, write_frame};
19	use crate::frame::{accept_hello, Envelope, HandshakeError, Hello, Role};
20
21	/// A bidirectional IPC connection between the broker and a brain. Anything that
22	/// is both [`Read`] and [`Write`] qualifies, so tests can substitute an
23	/// in-memory or socket-pair duplex for the real local socket.
24	pub trait IpcConn: Read + Write {}
25	impl<T: Read + Write> IpcConn for T {}
26
27	/// A listening IPC endpoint plus its dialer. The broker `bind`s one and
28	/// `accept`s brains; a brain `connect`s. Abstracted as a trait so B1/B2 can
29	/// drive the broker over a stub transport in tests without a real socket.
30	pub trait DaemonTransport {
31	    /// The accepted/connected connection type.
32	    type Conn: IpcConn;
33
34	    /// Accept the next inbound connection (broker side). Blocks.
35	    fn accept(&self) -> io::Result<Self::Conn>;
36	}
37
38	/// The production transport: `interprocess` local sockets. A namespaced name
39	/// resolves to a Windows named pipe or a Linux abstract-namespace UDS — the one
40	/// OS split, contained here.
41	pub struct LocalSocketTransport {
42	    listener: interprocess::local_socket::Listener,
43	}
44
45	impl LocalSocketTransport {
46	    /// Bind a listening endpoint under the namespaced `name` (broker side).
47	    pub fn bind(name: &str) -> io::Result<Self> {
48	        let ns = name.to_ns_name::<GenericNamespaced>()?;
49	        let listener = ListenerOptions::new().name(ns).create_sync()?;
50	        Ok(LocalSocketTransport { listener })
51	    }
52
53	    /// Connect to an existing endpoint under `name` (brain side).
54	    pub fn connect(name: &str) -> io::Result<Stream> {
55	        let ns = name.to_ns_name::<GenericNamespaced>()?;
56	        Stream::connect(ns)
57	    }
58	}
59
60	impl DaemonTransport for LocalSocketTransport {
61	    type Conn = Stream;
62
63	    fn accept(&self) -> io::Result<Self::Conn> {
64	        self.listener.accept()
65	    }
66	}
67
68	/// Brain side of the connect handshake: announce ourselves as `role` by sending
69	/// a `hello` frame. The broker answers with [`recv_hello`] + [`accept_hello`].
70	// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
71	pub fn send_hello<W: Write>(w: &mut W, role: Role) -> io::Result<()> {
72	    let hello = Hello::current(role);
73	    let payload =
74	        serde_json::to_value(hello).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
75	    write_frame(w, &Envelope::new("hello", payload))
76	}
77
78	/// Broker side: read the peer's first frame, require it to be a `hello`, and
79	/// run the version-tolerance policy ([`accept_hello`]) against `expected_peer`.
80	/// An incompatible peer surfaces a typed [`HandshakeError`] (never a silent
81	/// misparse — the 2.3 failure mode is a peer dying unlogged on a mismatch).
82	// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
83	pub fn recv_hello<R: Read>(r: &mut R, expected_peer: Role) -> io::Result<Hello> {
84	    let env = read_frame(r)?;
85	    if env.kind != "hello" {
86	        return Err(io::Error::new(
87	            io::ErrorKind::InvalidData,
88	            format!("expected hello frame, got kind {:?}", env.kind),
89	        ));
90	    }
91	    let hello: Hello = serde_json::from_value(env.payload)
92	        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
93	    accept_hello(&hello, expected_peer)
94	        .map_err(|e: HandshakeError| io::Error::new(io::ErrorKind::InvalidData, e))?;
95	    Ok(hello)
96	}
97
98	#[cfg(test)]
99	mod tests {
100	    use super::*;
101	    use serde_json::json;
102	    use std::sync::atomic::{AtomicU32, Ordering};
103
104	    // Unique namespaced socket name per test, so parallel test threads on one
105	    // machine never collide on the same pipe/UDS name.
106	    static SEQ: AtomicU32 = AtomicU32::new(0);
107	    fn unique_name() -> String {
108	        let n = SEQ.fetch_add(1, Ordering::Relaxed);
109	        format!("spt-daemon-test-{}-{}.sock", std::process::id(), n)
110	    }
111
112	    // [unit->REQ-DAEMON-2] a real local socket carries a hello handshake then a
113	    // frame, end to end, on whatever OS the test runs on (named pipe / UDS).
114	    #[test]
115	    fn local_socket_handshake_and_frame_round_trip() {
116	        let name = unique_name();
117	        let broker = LocalSocketTransport::bind(&name).unwrap();
118
119	        let client_name = name.clone();
120	        let client = std::thread::spawn(move || {
121	            let mut conn = LocalSocketTransport::connect(&client_name).unwrap();
122	            send_hello(&mut conn, Role::Brain).unwrap();
123	            write_frame(&mut conn, &Envelope::new("ping", json!({ "seq": 1 }))).unwrap();
124	            // Read the broker's reply.
125	            read_frame(&mut conn).unwrap()
126	        });
127
128	        let mut server = broker.accept().unwrap();
129	        let hello = recv_hello(&mut server, Role::Brain).unwrap();
130	        assert_eq!(hello.role, Role::Brain);
131	        let ping = read_frame(&mut server).unwrap();
132	        assert_eq!(ping.kind, "ping");
133	        write_frame(&mut server, &Envelope::new("pong", json!({ "seq": 1 }))).unwrap();
134
135	        let reply = client.join().unwrap();
136	        assert_eq!(reply.kind, "pong");
137	    }
138
139	    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT] a brain announcing the wrong role
140	    // is refused at the handshake, over the real socket.
141	    #[test]
142	    fn local_socket_rejects_wrong_role() {
143	        let name = unique_name();
144	        let broker = LocalSocketTransport::bind(&name).unwrap();
145
146	        let client_name = name.clone();
147	        let client = std::thread::spawn(move || {
148	            let mut conn = LocalSocketTransport::connect(&client_name).unwrap();
149	            // Announce as Broker on a connection where the broker expects a Brain.
150	            send_hello(&mut conn, Role::Broker).unwrap();
151	        });
152
153	        let mut server = broker.accept().unwrap();
154	        let err = recv_hello(&mut server, Role::Brain).unwrap_err();
155	        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
156	        client.join().unwrap();
157	    }
158	}
159

//! The OS-split local-IPC carrier: named pipe (Windows) / Unix-domain socket
//! (unix), behind one [`DaemonTransport`] trait.
//!
//! This mirrors how `spt-term`'s `SessionSurface` abstracts ConPTY vs forkpty:
//! the broker and brain talk through one trait, and the platform split lives in
//! a single implementation ([`LocalSocketTransport`], over the `interprocess`
//! crate's local sockets — namespaced names work on both Windows and Linux).
//! The byte framing ([`crate::codec`]) and the version handshake
//! ([`crate::frame`]) ride on top, transport-agnostic.
// [impl->REQ-DAEMON-2]

use std::io::{self, Read, Write};

use interprocess::local_socket::{
    prelude::*, GenericNamespaced, ListenerOptions, Stream,
};

use crate::codec::{read_frame, write_frame};
use crate::frame::{accept_hello, Envelope, HandshakeError, Hello, Role};

/// A bidirectional IPC connection between the broker and a brain. Anything that
/// is both [`Read`] and [`Write`] qualifies, so tests can substitute an
/// in-memory or socket-pair duplex for the real local socket.
pub trait IpcConn: Read + Write {}
impl<T: Read + Write> IpcConn for T {}

/// A listening IPC endpoint plus its dialer. The broker `bind`s one and
/// `accept`s brains; a brain `connect`s. Abstracted as a trait so B1/B2 can
/// drive the broker over a stub transport in tests without a real socket.
pub trait DaemonTransport {
    /// The accepted/connected connection type.
    type Conn: IpcConn;

    /// Accept the next inbound connection (broker side). Blocks.
    fn accept(&self) -> io::Result<Self::Conn>;
}

/// The production transport: `interprocess` local sockets. A namespaced name
/// resolves to a Windows named pipe or a Linux abstract-namespace UDS — the one
/// OS split, contained here.
pub struct LocalSocketTransport {
    listener: interprocess::local_socket::Listener,
}

impl LocalSocketTransport {
    /// Bind a listening endpoint under the namespaced `name` (broker side).
    pub fn bind(name: &str) -> io::Result<Self> {
        let ns = name.to_ns_name::<GenericNamespaced>()?;
        let listener = ListenerOptions::new().name(ns).create_sync()?;
        Ok(LocalSocketTransport { listener })
    }

    /// Connect to an existing endpoint under `name` (brain side).
    pub fn connect(name: &str) -> io::Result<Stream> {
        let ns = name.to_ns_name::<GenericNamespaced>()?;
        Stream::connect(ns)
    }
}

impl DaemonTransport for LocalSocketTransport {
    type Conn = Stream;

    fn accept(&self) -> io::Result<Self::Conn> {
        self.listener.accept()
    }
}

/// Brain side of the connect handshake: announce ourselves as `role` by sending
/// a `hello` frame. The broker answers with [`recv_hello`] + [`accept_hello`].
// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
pub fn send_hello<W: Write>(w: &mut W, role: Role) -> io::Result<()> {
    let hello = Hello::current(role);
    let payload =
        serde_json::to_value(hello).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    write_frame(w, &Envelope::new("hello", payload))
}

/// Broker side: read the peer's first frame, require it to be a `hello`, and
/// run the version-tolerance policy ([`accept_hello`]) against `expected_peer`.
/// An incompatible peer surfaces a typed [`HandshakeError`] (never a silent
/// misparse — the 2.3 failure mode is a peer dying unlogged on a mismatch).
// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
pub fn recv_hello<R: Read>(r: &mut R, expected_peer: Role) -> io::Result<Hello> {
    let env = read_frame(r)?;
    if env.kind != "hello" {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            format!("expected hello frame, got kind {:?}", env.kind),
        ));
    }
    let hello: Hello = serde_json::from_value(env.payload)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    accept_hello(&hello, expected_peer)
        .map_err(|e: HandshakeError| io::Error::new(io::ErrorKind::InvalidData, e))?;
    Ok(hello)
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use std::sync::atomic::{AtomicU32, Ordering};

    // Unique namespaced socket name per test, so parallel test threads on one
    // machine never collide on the same pipe/UDS name.
    static SEQ: AtomicU32 = AtomicU32::new(0);
    fn unique_name() -> String {
        let n = SEQ.fetch_add(1, Ordering::Relaxed);
        format!("spt-daemon-test-{}-{}.sock", std::process::id(), n)
    }

    // [unit->REQ-DAEMON-2] a real local socket carries a hello handshake then a
    // frame, end to end, on whatever OS the test runs on (named pipe / UDS).
    #[test]
    fn local_socket_handshake_and_frame_round_trip() {
        let name = unique_name();
        let broker = LocalSocketTransport::bind(&name).unwrap();

        let client_name = name.clone();
        let client = std::thread::spawn(move || {
            let mut conn = LocalSocketTransport::connect(&client_name).unwrap();
            send_hello(&mut conn, Role::Brain).unwrap();
            write_frame(&mut conn, &Envelope::new("ping", json!({ "seq": 1 }))).unwrap();
            // Read the broker's reply.
            read_frame(&mut conn).unwrap()
        });

        let mut server = broker.accept().unwrap();
        let hello = recv_hello(&mut server, Role::Brain).unwrap();
        assert_eq!(hello.role, Role::Brain);
        let ping = read_frame(&mut server).unwrap();
        assert_eq!(ping.kind, "ping");
        write_frame(&mut server, &Envelope::new("pong", json!({ "seq": 1 }))).unwrap();

        let reply = client.join().unwrap();
        assert_eq!(reply.kind, "pong");
    }

    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT] a brain announcing the wrong role
    // is refused at the handshake, over the real socket.
    #[test]
    fn local_socket_rejects_wrong_role() {
        let name = unique_name();
        let broker = LocalSocketTransport::bind(&name).unwrap();

        let client_name = name.clone();
        let client = std::thread::spawn(move || {
            let mut conn = LocalSocketTransport::connect(&client_name).unwrap();
            // Announce as Broker on a connection where the broker expects a Brain.
            send_hello(&mut conn, Role::Broker).unwrap();
        });

        let mut server = broker.accept().unwrap();
        let err = recv_hello(&mut server, Role::Brain).unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
        client.join().unwrap();
    }
}


Bash

grep -n "pub fn spawn_program\|pub fn spawn\|pub fn write_input\|pub fn process_id\|pub fn drain\b" crates/spt-term/src/pty.rs

PtySession public API signatures


62:    pub fn spawn(cmd: CommandBuilder, size: SurfaceSize) -> Result<Self, SurfaceError> {
97:    pub fn spawn_program<P, A, S>(
164:    pub fn process_id(&self) -> Option<u32> {


crates\spt-daemon\tests\handoff.rs
crates\spt-daemon\tests\broker.rs
crates\spt-daemon\tests\idempotent.rs


1	//! B1 integration: a [`Broker`] hosts one real PTY child and relays its output
2	//! and injected input over the versioned local IPC, on both OSes.
3	//!
4	//! The child is an echo program (`cat` on Unix, `findstr "."` on Windows — the
5	//! same cross-OS shape spt-term's injection tests use): bytes the brain injects
6	//! round-trip through the real PTY and come back as `output` events. We then
7	//! kill it and observe the `exit` event. This exercises the whole B1 contract —
8	//! spawn under a broker PTY, stream output, accept input, report exit — through
9	//! the actual named-pipe / UDS transport.
10	//!
11	//! The brain is driven **single-threaded**: send a command, then read frames
12	//! until the expected reply/event arrives (accumulating any interleaved output).
13	//! interprocess `Stream::split()` shares the underlying fd, so a split client
14	//! would only signal EOF once *both* halves drop; keeping one `Stream` means
15	//! dropping it closes the fd, the broker's command loop hits EOF, and it tears
16	//! the session down deterministically — which we then assert.
17	// [int->REQ-DAEMON-2]
18
19	use std::sync::atomic::{AtomicU32, Ordering};
20	use std::sync::Arc;
21	use std::thread;
22	use std::time::Duration;
23
24	use interprocess::local_socket::Stream;
25	use spt_daemon::codec::{read_frame, write_frame};
26	use spt_daemon::frame::{Envelope, Role};
27	use spt_daemon::msg::{
28	    decode_bytes, encode_bytes, ExitEvent, InputReq, KillReq, OutputEvent, SpawnReq, Spawned,
29	    KIND_EXIT, KIND_INPUT, KIND_KILL, KIND_OUTPUT, KIND_SPAWN, KIND_SPAWNED,
30	};
31	use spt_daemon::transport::{send_hello, LocalSocketTransport};
32	use spt_daemon::Broker;
33
34	static SEQ: AtomicU32 = AtomicU32::new(0);
35	fn unique_name() -> String {
36	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
37	    format!("spt-daemon-b1-{}-{}.sock", std::process::id(), n)
38	}
39
40	/// The echo child: reads stdin, writes each line to stdout.
41	fn echo_spawn_req() -> SpawnReq {
42	    #[cfg(unix)]
43	    let (program, args) = ("cat".to_string(), Vec::<String>::new());
44	    #[cfg(windows)]
45	    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
46	    SpawnReq { program, args, rows: 24, cols: 80 }
47	}
48
49	fn send(conn: &mut Stream, kind: &str, payload: serde_json::Value) {
50	    write_frame(conn, &Envelope::new(kind, payload)).expect("send frame");
51	}
52
53	fn main_test() {
54	    let name = unique_name();
55	    let broker = Broker::bind(&name).expect("bind broker");
56
57	    // The broker accepts one brain and serves it on a handler thread.
58	    let server_broker = Arc::clone(&broker);
59	    let server = thread::spawn(move || {
60	        let handle = server_broker.serve_once().expect("accept brain");
61	        handle.join().expect("handler thread");
62	    });
63
64	    // Brain side: connect (retry briefly in case accept lags the bind).
65	    let mut conn = None;
66	    for _ in 0..200 {
67	        match LocalSocketTransport::connect(&name) {
68	            Ok(c) => {
69	                conn = Some(c);
70	                break;
71	            }
72	            Err(_) => thread::sleep(Duration::from_millis(10)),
73	        }
74	    }
75	    let mut conn = conn.expect("connect to broker");
76	    // (No per-read timeout: Windows named pipes reject I/O timeouts. The broker
77	    // always pushes `spawned`/`output`/`exit` frames, so the blocking reads
78	    // below return; a genuine stall is caught by the test runner's own timeout.)
79
80	    send_hello(&mut conn, Role::Brain).expect("send hello");
81
82	    // Spawn the echo child under a broker PTY; read until the `spawned` reply,
83	    // accumulating any output that interleaves.
84	    send(&mut conn, KIND_SPAWN, serde_json::to_value(echo_spawn_req()).unwrap());
85	    let mut out = Vec::<u8>::new();
86	    let sid = loop {
87	        let f = read_frame(&mut conn).expect("frame before spawned");
88	        match f.kind.as_str() {
89	            KIND_SPAWNED => {
90	                break serde_json::from_value::<Spawned>(f.payload).unwrap().session_id;
91	            }
92	            KIND_OUTPUT => accumulate_output(&f, &mut out),
93	            _ => {}
94	        }
95	    };
96
97	    // Inject a line; it must round-trip through the real PTY back as output.
98	    send(
99	        &mut conn,
100	        KIND_INPUT,
101	        serde_json::to_value(InputReq {
102	            session_id: sid,
103	            data_b64: encode_bytes(b"INJECT-ALPHA\r"),
104	            op_id: None,
105	        })
106	        .unwrap(),
107	    );
108	    let echoed = loop {
109	        if String::from_utf8_lossy(&out).contains("INJECT-ALPHA") {
110	            break true;
111	        }
112	        match read_frame(&mut conn) {
113	            Ok(f) if f.kind == KIND_OUTPUT => accumulate_output(&f, &mut out),
114	            Ok(_) => {}
115	            Err(_) => break false, // recv timeout → echo never arrived
116	        }
117	    };
118
119	    // Kill the child and read until the `exit` event.
120	    send(&mut conn, KIND_KILL, serde_json::to_value(KillReq { session_id: sid }).unwrap());
121	    let mut exit: Option<ExitEvent> = None;
122	    loop {
123	        match read_frame(&mut conn) {
124	            Ok(f) if f.kind == KIND_EXIT => {
125	                exit = Some(serde_json::from_value(f.payload).unwrap());
126	                break;
127	            }
128	            Ok(f) if f.kind == KIND_OUTPUT => accumulate_output(&f, &mut out),
129	            Ok(_) => {}
130	            Err(_) => break, // timeout
131	        }
132	    }
133
134	    // Disconnect: dropping the single Stream closes the fd, so the broker's
135	    // command loop hits EOF and tears the session down. Joining the handler
136	    // (via the server thread) lets us assert the teardown happened.
137	    drop(conn);
138	    server.join().expect("server thread");
139
140	    assert!(echoed, "injected input must round-trip through the PTY: {:?}", String::from_utf8_lossy(&out));
141	    let exit = exit.expect("exit event after kill");
142	    assert_eq!(exit.session_id, sid);
143	    assert_eq!(broker.session_count(), 0, "session torn down on disconnect");
144	}
145
146	fn accumulate_output(frame: &Envelope, out: &mut Vec<u8>) {
147	    let ev: OutputEvent = serde_json::from_value(frame.payload.clone()).unwrap();
148	    out.extend_from_slice(&decode_bytes(&ev.data_b64).unwrap());
149	}
150
151	#[test]
152	fn broker_hosts_pty_child_streams_output_and_accepts_input() {
153	    main_test();
154	}
155

//! B1 integration: a [`Broker`] hosts one real PTY child and relays its output
//! and injected input over the versioned local IPC, on both OSes.
//!
//! The child is an echo program (`cat` on Unix, `findstr "."` on Windows — the
//! same cross-OS shape spt-term's injection tests use): bytes the brain injects
//! round-trip through the real PTY and come back as `output` events. We then
//! kill it and observe the `exit` event. This exercises the whole B1 contract —
//! spawn under a broker PTY, stream output, accept input, report exit — through
//! the actual named-pipe / UDS transport.
//!
//! The brain is driven **single-threaded**: send a command, then read frames
//! until the expected reply/event arrives (accumulating any interleaved output).
//! interprocess `Stream::split()` shares the underlying fd, so a split client
//! would only signal EOF once *both* halves drop; keeping one `Stream` means
//! dropping it closes the fd, the broker's command loop hits EOF, and it tears
//! the session down deterministically — which we then assert.
// [int->REQ-DAEMON-2]

use std::sync::atomic::{AtomicU32, Ordering};
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::{
    decode_bytes, encode_bytes, ExitEvent, InputReq, KillReq, OutputEvent, SpawnReq, Spawned,
    KIND_EXIT, KIND_INPUT, KIND_KILL, KIND_OUTPUT, KIND_SPAWN, KIND_SPAWNED,
};
use spt_daemon::transport::{send_hello, LocalSocketTransport};
use spt_daemon::Broker;

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-b1-{}-{}.sock", std::process::id(), n)
}

/// The echo child: reads stdin, writes each line to stdout.
fn echo_spawn_req() -> SpawnReq {
    #[cfg(unix)]
    let (program, args) = ("cat".to_string(), Vec::<String>::new());
    #[cfg(windows)]
    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
    SpawnReq { program, args, rows: 24, cols: 80 }
}

fn send(conn: &mut Stream, kind: &str, payload: serde_json::Value) {
    write_frame(conn, &Envelope::new(kind, payload)).expect("send frame");
}

fn main_test() {
    let name = unique_name();
    let broker = Broker::bind(&name).expect("bind broker");

    // The broker accepts one brain and serves it on a handler thread.
    let server_broker = Arc::clone(&broker);
    let server = thread::spawn(move || {
        let handle = server_broker.serve_once().expect("accept brain");
        handle.join().expect("handler thread");
    });

    // Brain side: connect (retry briefly in case accept lags the bind).
    let mut conn = None;
    for _ in 0..200 {
        match LocalSocketTransport::connect(&name) {
            Ok(c) => {
                conn = Some(c);
                break;
            }
            Err(_) => thread::sleep(Duration::from_millis(10)),
        }
    }
    let mut conn = conn.expect("connect to broker");
    // (No per-read timeout: Windows named pipes reject I/O timeouts. The broker
    // always pushes `spawned`/`output`/`exit` frames, so the blocking reads
    // below return; a genuine stall is caught by the test runner's own timeout.)

    send_hello(&mut conn, Role::Brain).expect("send hello");

    // Spawn the echo child under a broker PTY; read until the `spawned` reply,
    // accumulating any output that interleaves.
    send(&mut conn, KIND_SPAWN, serde_json::to_value(echo_spawn_req()).unwrap());
    let mut out = Vec::<u8>::new();
    let sid = loop {
        let f = read_frame(&mut conn).expect("frame before spawned");
        match f.kind.as_str() {
            KIND_SPAWNED => {
                break serde_json::from_value::<Spawned>(f.payload).unwrap().session_id;
            }
            KIND_OUTPUT => accumulate_output(&f, &mut out),
            _ => {}
        }
    };

    // Inject a line; it must round-trip through the real PTY back as output.
    send(
        &mut conn,
        KIND_INPUT,
        serde_json::to_value(InputReq {
            session_id: sid,
            data_b64: encode_bytes(b"INJECT-ALPHA\r"),
            op_id: None,
        })
        .unwrap(),
    );
    let echoed = loop {
        if String::from_utf8_lossy(&out).contains("INJECT-ALPHA") {
            break true;
        }
        match read_frame(&mut conn) {
            Ok(f) if f.kind == KIND_OUTPUT => accumulate_output(&f, &mut out),
            Ok(_) => {}
            Err(_) => break false, // recv timeout → echo never arrived
        }
    };

    // Kill the child and read until the `exit` event.
    send(&mut conn, KIND_KILL, serde_json::to_value(KillReq { session_id: sid }).unwrap());
    let mut exit: Option<ExitEvent> = None;
    loop {
        match read_frame(&mut conn) {
            Ok(f) if f.kind == KIND_EXIT => {
                exit = Some(serde_json::from_value(f.payload).unwrap());
                break;
            }
            Ok(f) if f.kind == KIND_OUTPUT => accumulate_output(&f, &mut out),
            Ok(_) => {}
            Err(_) => break, // timeout
        }
    }

    // Disconnect: dropping the single Stream closes the fd, so the broker's
    // command loop hits EOF and tears the session down. Joining the handler
    // (via the server thread) lets us assert the teardown happened.
    drop(conn);
    server.join().expect("server thread");

    assert!(echoed, "injected input must round-trip through the PTY: {:?}", String::from_utf8_lossy(&out));
    let exit = exit.expect("exit event after kill");
    assert_eq!(exit.session_id, sid);
    assert_eq!(broker.session_count(), 0, "session torn down on disconnect");
}

fn accumulate_output(frame: &Envelope, out: &mut Vec<u8>) {
    let ev: OutputEvent = serde_json::from_value(frame.payload.clone()).unwrap();
    out.extend_from_slice(&decode_bytes(&ev.data_b64).unwrap());
}

#[test]
fn broker_hosts_pty_child_streams_output_and_accepts_input() {
    main_test();
}


95
96	    /// Convenience: spawn `program` with `args` under a PTY sized to `size`.
97	    pub fn spawn_program<P, A, S>(
98	        program: P,
99	        args: A,
100	        size: SurfaceSize,
101	    ) -> Result<Self, SurfaceError>
102	    where
103	        P: AsRef<std::ffi::OsStr>,
104	        A: IntoIterator<Item = S>,
105	        S: AsRef<std::ffi::OsStr>,
106	    {
107	        let mut cmd = CommandBuilder::new(program);
108	        for a in args {
109	            cmd.arg(a);
110	        }
111	        Self::spawn(cmd, size)
112	    }
113
114	    /// Take the master reader. Returns [`SurfaceError::ReaderTaken`] on the
115	    /// second call — output is drained by exactly one consumer (the A2 drain
116	    /// thread in the hosted path; a test consumer otherwise).
117	    pub fn take_reader(&self) -> Result<Box<dyn Read + Send>, SurfaceError> {
118	        self.reader
119	            .lock()
120	            .unwrap()
121	            .take()
122	            .ok_or(SurfaceError::ReaderTaken)
123	    }
124
125	    /// The shared writer handle — cloned to the A2 DSR drain thread so its
126	    /// auto-answer goes through the same serialized writer the caller injects
127	    /// input through (no torn writes).
128	    pub(crate) fn shared_writer(&self) -> SharedWriter {
129	        Arc::clone(&self.writer)
130	    }
131
132	    /// Poll for child exit without blocking.
133	    pub fn try_wait(&self) -> Result<Option<ExitStatus>, SurfaceError> {
134	        self.child.lock().unwrap().try_wait().map_err(SurfaceError::Io)
135	    }
136
137	    /// Block until the child exits, yielding its status.
138	    pub fn wait(&self) -> Result<ExitStatus, SurfaceError> {
139	        self.child.lock().unwrap().wait().map_err(SurfaceError::Io)
140	    }
141
142	    /// Terminate the child. Safe to call from another thread while `wait`
143	    /// blocks (uses the split `ChildKiller`). Confirm the reap via
144	    /// [`PtySession::wait`] / [`PtySession::try_wait`].
145	    pub fn kill(&self) -> Result<(), SurfaceError> {
146	        let res = self.killer.lock().unwrap().kill();
147	        #[cfg(windows)]
148	        {
149	            // portable-pty 0.8's Windows `ChildKiller` returns the spurious
150	            // `last_os_error` even when `TerminateProcess` *succeeds* (the
151	            // upstream success check is inverted; `WinChild::kill` swallows it,
152	            // the cloned killer does not). Its Result is therefore untrustworthy
153	            // — kill is best-effort; confirm death by reaping.
154	            let _ = res;
155	            Ok(())
156	        }
157	        #[cfg(unix)]
158	        {
159	            res.map_err(SurfaceError::Io)
160	        }
161	    }
162
163	    /// The child's OS process id, if the backend exposes one.
164	    pub fn process_id(&self) -> Option<u32> {
165	        self.pid
166	    }
167	}
168
169	// [impl->REQ-TERM-1]
170	impl SessionSurface for PtySession {
171	    fn write_input(&self, bytes: &[u8]) -> Result<(), SurfaceError> {
172	        let mut w = self.writer.lock().unwrap();
173	        w.write_all(bytes).map_err(SurfaceError::Io)?;
174	        w.flush().map_err(SurfaceError::Io)
175	    }
176
177	    fn resize(&self, size: SurfaceSize) -> Result<(), SurfaceError> {
178	        // Hold the master lock so resize never races a concurrent reader/resize
179	        // (Spike #5: resize races the reader on Windows).
180	        self.master
181	            .lock()
182	            .unwrap()
183	            .resize(size.into())
184	            .map_err(|e| SurfaceError::Io(io_other(e)))

    /// Convenience: spawn `program` with `args` under a PTY sized to `size`.
    pub fn spawn_program<P, A, S>(
        program: P,
        args: A,
        size: SurfaceSize,
    ) -> Result<Self, SurfaceError>
    where
        P: AsRef<std::ffi::OsStr>,
        A: IntoIterator<Item = S>,
        S: AsRef<std::ffi::OsStr>,
    {
        let mut cmd = CommandBuilder::new(program);
        for a in args {
            cmd.arg(a);
        }
        Self::spawn(cmd, size)
    }

    /// Take the master reader. Returns [`SurfaceError::ReaderTaken`] on the
    /// second call — output is drained by exactly one consumer (the A2 drain
    /// thread in the hosted path; a test consumer otherwise).
    pub fn take_reader(&self) -> Result<Box<dyn Read + Send>, SurfaceError> {
        self.reader
            .lock()
            .unwrap()
            .take()
            .ok_or(SurfaceError::ReaderTaken)
    }

    /// The shared writer handle — cloned to the A2 DSR drain thread so its
    /// auto-answer goes through the same serialized writer the caller injects
    /// input through (no torn writes).
    pub(crate) fn shared_writer(&self) -> SharedWriter {
        Arc::clone(&self.writer)
    }

    /// Poll for child exit without blocking.
    pub fn try_wait(&self) -> Result<Option<ExitStatus>, SurfaceError> {
        self.child.lock().unwrap().try_wait().map_err(SurfaceError::Io)
    }

    /// Block until the child exits, yielding its status.
    pub fn wait(&self) -> Result<ExitStatus, SurfaceError> {
        self.child.lock().unwrap().wait().map_err(SurfaceError::Io)
    }

    /// Terminate the child. Safe to call from another thread while `wait`
    /// blocks (uses the split `ChildKiller`). Confirm the reap via
    /// [`PtySession::wait`] / [`PtySession::try_wait`].
    pub fn kill(&self) -> Result<(), SurfaceError> {
        let res = self.killer.lock().unwrap().kill();
        #[cfg(windows)]
        {
            // portable-pty 0.8's Windows `ChildKiller` returns the spurious
            // `last_os_error` even when `TerminateProcess` *succeeds* (the
            // upstream success check is inverted; `WinChild::kill` swallows it,
            // the cloned killer does not). Its Result is therefore untrustworthy
            // — kill is best-effort; confirm death by reaping.
            let _ = res;
            Ok(())
        }
        #[cfg(unix)]
        {
            res.map_err(SurfaceError::Io)
        }
    }

    /// The child's OS process id, if the backend exposes one.
    pub fn process_id(&self) -> Option<u32> {
        self.pid
    }
}

// [impl->REQ-TERM-1]
impl SessionSurface for PtySession {
    fn write_input(&self, bytes: &[u8]) -> Result<(), SurfaceError> {
        let mut w = self.writer.lock().unwrap();
        w.write_all(bytes).map_err(SurfaceError::Io)?;
        w.flush().map_err(SurfaceError::Io)
    }

    fn resize(&self, size: SurfaceSize) -> Result<(), SurfaceError> {
        // Hold the master lock so resize never races a concurrent reader/resize
        // (Spike #5: resize races the reader on Windows).
        self.master
            .lock()
            .unwrap()
            .resize(size.into())
            .map_err(|e| SurfaceError::Io(io_other(e)))


Full picture acquired. Architecture decision for B8:

**DigestParser = logic → runs brain-side, not in broker** (M3b-PLAN risk forbids parse-in-broker; ADR-0008 "broker-pushed" wording predates the strict split). Digest is a brain-side projection over the broker's R-TERM-3 output stream, published to a daemon-held hub that the `spt digest` control channel serves.

Plan:
1. **Manifest `[pty_digest]` seam** — spt-runtime (declarative strings only; sibling of spt-term so no DigestParser dep) + MANIFEST.md doc.
2. **`DigestEngine`** (spt-daemon) — wraps spt-term `DigestParser`, fed from `BrokerEvent::Output`; snapshot + delta updates + opt-in Path-B persist.
3. **Digest hub + control IPC** (mirror seedmap) — daemon holds live per-endpoint digests; snapshot + subscribe(delta-stream) ops.
4. **`Brain::run_digest_feed`** — reusable feed: broker subscription → engine → hub publish.
5. **`spt digest <id> [--follow] [--json]`** CLI; local addressing only.
6. **Integration test** — real daemon+broker PTY+brain feed → control-channel snapshot/delta/persist (the `int` evidence).
7. **traceable-reqs** — REQ-TERM-4 → `["impl","unit","int"]` + tags.

Let me verify deps + grep for any existing digest wiring in daemon/CLI before writing.


cat crates/spt-daemon/Cargo.toml; echo "=== runtime deps ==="; grep -A15 "\[dependencies\]" crates/spt-runtime/Cargo.toml

Check spt-daemon + spt-runtime dependencies


[package]
name = "spt-daemon"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core broker/brain daemon (M3b): the per-machine supervisor process. A stable broker kernel holds the un-transferable resources (PTY masters from spt-term, harness children, sockets) while a restartable brain holds all logic; a versioned local IPC lets a newer brain talk to an older broker so a self-update swaps the brain with zero endpoint interruption (ADR-0004). Internal supervisor (NOT public SDK, R-ARCH-2) — the `spt` binary is a thin CLI over it. B0 lands the crate + the versioned local-IPC transport."

[dependencies]
# Layer-below set (R-ARCH-1 acyclic): …→spt-live→spt-daemon→spt. The broker
# re-hosts spt-term PTYs + the spt-live lifecycle seams; these edges fix the
# crate's position in the workspace layering. Tasks B1–B8 consume them.
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-term = { path = "../spt-term" }
spt-live = { path = "../spt-live" }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
# PTY output is arbitrary bytes; the forward-compat JSON envelope carries it as
# a base64 string in the payload rather than forking the codec for binary.
base64 = "0.22"
# Cross-platform local IPC: named pipe (Windows) / Unix-domain socket (unix)
# behind one API — the OS-split substrate the DaemonTransport trait wraps,
# mirroring how spt-term abstracts ConPTY vs forkpty behind SessionSurface.
interprocess = "2"

[dev-dependencies]
tempfile = "3"
=== runtime deps ===
[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
serde = { version = "1", features = ["derive"] }
toml = "0.8"

[dev-dependencies]
tempfile = "3"


grep -rni "digest" crates/spt-daemon/src crates/spt/src crates/spt-runtime/src 2>/dev/null

Confirm no existing digest wiring in daemon/cli/runtime


(Bash completed with no output)


Confirmed: spt-daemon sees both spt-runtime + spt-term; no existing digest wiring. Building bottom-up.

**Step 1 — Manifest `[pty_digest]` seam (spt-runtime).**


Edit

    /// `[shell]` body — present iff `adapter.kind = "shell"` (validated).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub shell: Option<Shell>,
}

    /// `[shell]` body — present iff `adapter.kind = "shell"` (validated).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub shell: Option<Shell>,
    /// `[pty_digest]` — the live-activity-buffer pattern seam (ADR-0008). The
    /// adapter supplies the boundary + tool patterns; spt-core only *runs* them
    /// over the broker PTY byte stream (the no-built-in-parser rule — identical to
    /// opaque command templates). Absent ⇒ the digest is unavailable for this
    /// adapter.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pty_digest: Option<PtyDigest>,
}


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

    /// `[shell]` body — present iff `adapter.kind = "shell"` (validated).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub shell: Option<Shell>,
}

    /// `[shell]` body — present iff `adapter.kind = "shell"` (validated).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub shell: Option<Shell>,
    /// `[pty_digest]` — the live-activity-buffer pattern seam (ADR-0008). The
    /// adapter supplies the boundary + tool patterns; spt-core only *runs* them
    /// over the broker PTY byte stream (the no-built-in-parser rule — identical to
    /// opaque command templates). Absent ⇒ the digest is unavailable for this
    /// adapter.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pty_digest: Option<PtyDigest>,
}

+    /// `[pty_digest]` — the live-activity-buffer pattern seam (ADR-0008). The

+    /// adapter supplies the boundary + tool patterns; spt-core only *runs* them

+    /// over the broker PTY byte stream (the no-built-in-parser rule — identical to

+    /// opaque command templates). Absent ⇒ the digest is unavailable for this

+    #[serde(default, skip_serializing_if = "Option::is_none")]

/// `[adapter]` — the manifest header, readable before any update (compat gate).


impl OverCap {
    /// Whether this is the default (`reject`) — for `skip_serializing_if`.
    pub fn is_default(&self) -> bool {
        matches!(self, OverCap::Reject)
    }
}

impl OverCap {
    /// Whether this is the default (`reject`) — for `skip_serializing_if`.
    pub fn is_default(&self) -> bool {
        matches!(self, OverCap::Reject)
    }
}

/// `[pty_digest]` — the adapter-supplied patterns for the live activity buffer
/// (ADR-0008). `input_pattern`/`agent_pattern` are the turn-boundary markers;
/// `tool_patterns` is one regex per known tool and `catchall_pattern` an optional
/// final catchall for user-installed tools (each naming `name`/`arg` capture
/// groups). spt-core compiles + runs these over the broker PTY stream — it never
/// parses harness transcripts (the history-subsystem no-built-in-parser rule the
/// digest deliberately honors). The patterns are **opaque to spt-core**, exactly
/// like the `[session.*]` command templates.
///
/// `window_turns` is the only presentation override exposed here; the remaining
/// presentation knobs (arg truncation, ANSI stripping, scan-buffer cap) stay
/// spt-core-owned defaults. `persist` opts this endpoint into the coarse Path-B
/// activity log (ADR-0008 "Option (C)") — off by default.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PtyDigest {
    /// User-input boundary marker (regex). Opens a span until the next match.
    pub input_pattern: String,
    /// Agent-output boundary marker (regex). Opens a span until the next match.
    pub agent_pattern: String,
    /// One regex per known tool; each should name an `arg` (and may name a
    /// `name`) capture group.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tool_patterns: Vec<String>,
    /// Optional final catchall for unknowable user-installed tools.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub catchall_pattern: Option<String>,
    /// Window depth override (last N user turns). `None` ⇒ spt-core default (~3).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub window_turns: Option<usize>,
    /// Opt-in Path-B persistence (ADR-0008 "Option (C)"): append completed turns
    /// to the perch's native history log as a coarse activity record.
    #[serde(default, skip_serializing_if = "is_false")]
    pub persist: bool,
}

/// `skip_serializing_if` helper for a `bool` defaulting to `false`.
fn is_false(b: &bool) -> bool {
    !*b
}


impl OverCap {
    /// Whether this is the default (`reject`) — for `skip_serializing_if`.
    pub fn is_default(&self) -> bool {
        matches!(self, OverCap::Reject)
    }
}

/// `[pty_digest]` — the adapter-supplied patterns for the live activity buffer
/// (ADR-0008). `input_pattern`/`agent_pattern` are the turn-boundary markers;
/// `tool_patterns` is one regex per known tool and `catchall_pattern` an optional
/// final catchall for user-installed tools (each naming `name`/`arg` capture
/// groups). spt-core compiles + runs these over the broker PTY stream — it never
/// parses harness transcripts (the history-subsystem no-built-in-parser rule the
/// digest deliberately honors). The patterns are **opaque to spt-core**, exactly
/// like the `[session.*]` command templates.
///
/// `window_turns` is the only presentation override exposed here; the remaining
/// presentation knobs (arg truncation, ANSI stripping, scan-buffer cap) stay
/// spt-core-owned defaults. `persist` opts this endpoint into the coarse Path-B
/// activity log (ADR-0008 "Option (C)") — off by default.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PtyDigest {
    /// User-input boundary marker (regex). Opens a span until the next match.
    pub input_pattern: String,
    /// Agent-output boundary marker (regex). Opens a span until the next match.
    pub agent_pattern: String,
    /// One regex per known tool; each should name an `arg` (and may name a
    /// `name`) capture group.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tool_patterns: Vec<String>,
    /// Optional final catchall for unknowable user-installed tools.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub catchall_pattern: Option<String>,
    /// Window depth override (last N user turns). `None` ⇒ spt-core default (~3).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub window_turns: Option<usize>,
    /// Opt-in Path-B persistence (ADR-0008 "Option (C)"): append completed turns
    /// to the perch's native history log as a coarse activity record.
    #[serde(default, skip_serializing_if = "is_false")]
    pub persist: bool,
}

/// `skip_serializing_if` helper for a `bool` defaulting to `false`.
fn is_false(b: &bool) -> bool {
    !*b
}

+/// `[pty_digest]` — the adapter-supplied patterns for the live activity buffer

+/// (ADR-0008). `input_pattern`/`agent_pattern` are the turn-boundary markers;

+/// `tool_patterns` is one regex per known tool and `catchall_pattern` an optional

+/// final catchall for user-installed tools (each naming `name`/`arg` capture

+/// groups). spt-core compiles + runs these over the broker PTY stream — it never

+/// parses harness transcripts (the history-subsystem no-built-in-parser rule the

+/// digest deliberately honors). The patterns are **opaque to spt-core**, exactly

+/// like the `[session.*]` command templates.

+/// `window_turns` is the only presentation override exposed here; the remaining

+/// presentation knobs (arg truncation, ANSI stripping, scan-buffer cap) stay

+/// spt-core-owned defaults. `persist` opts this endpoint into the coarse Path-B

+/// activity log (ADR-0008 "Option (C)") — off by default.

+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

+    /// User-input boundary marker (regex). Opens a span until the next match.

+    /// Agent-output boundary marker (regex). Opens a span until the next match.

+    /// One regex per known tool; each should name an `arg` (and may name a

+    #[serde(default, skip_serializing_if = "Vec::is_empty")]

+    /// Optional final catchall for unknowable user-installed tools.

+    #[serde(default, skip_serializing_if = "Option::is_none")]

+    pub catchall_pattern: Option<String>,

+    /// Window depth override (last N user turns). `None` ⇒ spt-core default (~3).

+    /// Opt-in Path-B persistence (ADR-0008 "Option (C)"): append completed turns

+    /// to the perch's native history log as a coarse activity record.

+    #[serde(default, skip_serializing_if = "is_false")]

+/// `skip_serializing_if` helper for a `bool` defaulting to `false`.

/// Parse/validation failure — carries a human-readable message for the adapter


Now validation + the impl tag. Add to `validate()`:


        // update avenue ↔ required fields.
        if let Some(u) = &self.update {

        // pty_digest: the two boundary markers are load-bearing — a digest with
        // no input/agent boundary degenerates to one undifferentiated span, so
        // require them non-empty when the section is declared. The tool patterns
        // + catchall are optional (an adapter may advertise no tools).
        // [impl->REQ-TERM-4]
        if let Some(d) = &self.pty_digest {
            if d.input_pattern.trim().is_empty() || d.agent_pattern.trim().is_empty() {
                return err(
                    "[pty_digest] requires non-empty `input_pattern` and `agent_pattern`".into(),
                );
            }
        }

        // update avenue ↔ required fields.
        if let Some(u) = &self.update {


        // pty_digest: the two boundary markers are load-bearing — a digest with
        // no input/agent boundary degenerates to one undifferentiated span, so
        // require them non-empty when the section is declared. The tool patterns
        // + catchall are optional (an adapter may advertise no tools).
        // [impl->REQ-TERM-4]
        if let Some(d) = &self.pty_digest {
            if d.input_pattern.trim().is_empty() || d.agent_pattern.trim().is_empty() {
                return err(
                    "[pty_digest] requires non-empty `input_pattern` and `agent_pattern`".into(),
                );
            }
        }

        // update avenue ↔ required fields.
        if let Some(u) = &self.update {

+        // pty_digest: the two boundary markers are load-bearing — a digest with

+        // no input/agent boundary degenerates to one undifferentiated span, so

+        // require them non-empty when the section is declared. The tool patterns

+        // + catchall are optional (an adapter may advertise no tools).

+        if let Some(d) = &self.pty_digest {

+            if d.input_pattern.trim().is_empty() || d.agent_pattern.trim().is_empty() {

+                    "[pty_digest] requires non-empty `input_pattern` and `agent_pattern`".into(),


Now add manifest unit tests for the seam. Append to the test fixture + add tests:


[update]
avenue = "delegated"
command = "claude plugin update spt"
version_check = true
"#;

[update]
avenue = "delegated"
command = "claude plugin update spt"
version_check = true

[pty_digest]
input_pattern = "\\nUSER> "
agent_pattern = "\\nAGENT> "
tool_patterns = ["(?P<name>Write)\\((?P<arg>[^)]*)\\)"]
catchall_pattern = "(?P<name>[A-Z][a-zA-Z]+)\\((?P<arg>[^)]*)\\)"
window_turns = 5
persist = true
"#;


[update]
avenue = "delegated"
command = "claude plugin update spt"
version_check = true
"#;

[update]
avenue = "delegated"
command = "claude plugin update spt"
version_check = true

[pty_digest]
input_pattern = "\\nUSER> "
agent_pattern = "\\nAGENT> "
tool_patterns = ["(?P<name>Write)\\((?P<arg>[^)]*)\\)"]
catchall_pattern = "(?P<name>[A-Z][a-zA-Z]+)\\((?P<arg>[^)]*)\\)"
window_turns = 5
persist = true
"#;


        assert_eq!(m.identity.unwrap().session_id_source, SessionIdSource::PostSpawn);
        assert_eq!(m.update.unwrap().avenue, UpdateAvenue::Delegated);
    }

        assert_eq!(m.identity.unwrap().session_id_source, SessionIdSource::PostSpawn);
        assert_eq!(m.update.unwrap().avenue, UpdateAvenue::Delegated);
        // pty_digest seam (ADR-0008)
        let d = m.pty_digest.unwrap();
        assert_eq!(d.input_pattern, "\nUSER> ");
        assert_eq!(d.tool_patterns.len(), 1);
        assert!(d.catchall_pattern.is_some());
        assert_eq!(d.window_turns, Some(5));
        assert!(d.persist);
    }

    #[test] // [unit->REQ-TERM-4] the pty_digest seam round-trips through TOML.
    fn pty_digest_round_trips() {
        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
        let reparsed = Manifest::from_toml_str(&m.to_toml_string().unwrap()).expect("re-parse");
        assert_eq!(m.pty_digest, reparsed.pty_digest);
    }

    #[test] // [unit->REQ-TERM-4] pty_digest defaults: tools empty, catchall none,
    // window default, persist off — a minimal digest declares only the boundaries.
    fn pty_digest_minimal_defaults() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"A> \"\n",
        )
        .expect("parse");
        let d = m.pty_digest.unwrap();
        assert!(d.tool_patterns.is_empty());
        assert!(d.catchall_pattern.is_none());
        assert_eq!(d.window_turns, None);
        assert!(!d.persist, "persist is opt-in (off by default)");
    }

    #[test] // [unit->REQ-TERM-4] a pty_digest missing a boundary marker is rejected
    // (an empty boundary degenerates the parse to one undifferentiated span).
    fn pty_digest_requires_both_boundaries() {
        let bad = "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
                   [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"\"\n";
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
        assert!(e.to_string().contains("pty_digest"));
    }


        assert_eq!(m.identity.unwrap().session_id_source, SessionIdSource::PostSpawn);
        assert_eq!(m.update.unwrap().avenue, UpdateAvenue::Delegated);
    }

        assert_eq!(m.identity.unwrap().session_id_source, SessionIdSource::PostSpawn);
        assert_eq!(m.update.unwrap().avenue, UpdateAvenue::Delegated);
        // pty_digest seam (ADR-0008)
        let d = m.pty_digest.unwrap();
        assert_eq!(d.input_pattern, "\nUSER> ");
        assert_eq!(d.tool_patterns.len(), 1);
        assert!(d.catchall_pattern.is_some());
        assert_eq!(d.window_turns, Some(5));
        assert!(d.persist);
    }

    #[test] // [unit->REQ-TERM-4] the pty_digest seam round-trips through TOML.
    fn pty_digest_round_trips() {
        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
        let reparsed = Manifest::from_toml_str(&m.to_toml_string().unwrap()).expect("re-parse");
        assert_eq!(m.pty_digest, reparsed.pty_digest);
    }

    #[test] // [unit->REQ-TERM-4] pty_digest defaults: tools empty, catchall none,
    // window default, persist off — a minimal digest declares only the boundaries.
    fn pty_digest_minimal_defaults() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"A> \"\n",
        )
        .expect("parse");
        let d = m.pty_digest.unwrap();
        assert!(d.tool_patterns.is_empty());
        assert!(d.catchall_pattern.is_none());
        assert_eq!(d.window_turns, None);
        assert!(!d.persist, "persist is opt-in (off by default)");
    }

    #[test] // [unit->REQ-TERM-4] a pty_digest missing a boundary marker is rejected
    // (an empty boundary degenerates the parse to one undifferentiated span).
    fn pty_digest_requires_both_boundaries() {
        let bad = "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
                   [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"\"\n";
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
        assert!(e.to_string().contains("pty_digest"));
    }

+        assert_eq!(d.input_pattern, "\nUSER> ");

+        assert_eq!(d.tool_patterns.len(), 1);

+        assert!(d.catchall_pattern.is_some());

+        assert_eq!(d.window_turns, Some(5));

+    #[test] // [unit->REQ-TERM-4] the pty_digest seam round-trips through TOML.

+        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");

+        let reparsed = Manifest::from_toml_str(&m.to_toml_string().unwrap()).expect("re-parse");

+        assert_eq!(m.pty_digest, reparsed.pty_digest);

+    #[test] // [unit->REQ-TERM-4] pty_digest defaults: tools empty, catchall none,

+    // window default, persist off — a minimal digest declares only the boundaries.

+        let m = Manifest::from_toml_str(

+            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\

+             [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"A> \"\n",

+        assert!(d.tool_patterns.is_empty());

+        assert!(d.catchall_pattern.is_none());

+        assert_eq!(d.window_turns, None);

+        assert!(!d.persist, "persist is opt-in (off by default)");

+    #[test] // [unit->REQ-TERM-4] a pty_digest missing a boundary marker is rejected

+    // (an empty boundary degenerates the parse to one undifferentiated span).

+    fn pty_digest_requires_both_boundaries() {

+        let bad = "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\

+                   [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"\"\n";

+        let e = Manifest::from_toml_str(bad).unwrap_err();

+        assert!(matches!(e, ManifestError::Validation(_)));

+        assert!(e.to_string().contains("pty_digest"));

let m = Manifest::from_toml_str(SHELL_FIXTURE).expect("parse");


Now the MANIFEST.md doc section:


### `[pty_digest]` — live activity buffer patterns (ADR-0008)

<!-- [doc->REQ-TERM-4] The adapter supplies the patterns; spt-core only runs them
over the broker PTY byte stream (Path A's "adapter owns the parser" rule, identical
to opaque command templates). This is NOT a built-in per-harness parser — it never
locates or parses the harness transcript. Absent section ⇒ the digest is simply
unavailable for the adapter. -->

```toml
[pty_digest]
input_pattern = "\\nUSER> "          # user-input boundary (opens a span until the next match)
agent_pattern = "\\nAGENT> "         # agent-output boundary (opens a span until the next match)
tool_patterns = [                     # one regex per known tool; name `arg` (+ optional `name`) groups
  "(?P<name>Write)\\((?P<arg>[^)]*)\\)",
  "(?P<name>Bash)\\((?P<arg>[^)]*)\\)",
]
catchall_pattern = "(?P<name>[A-Z][a-zA-Z]+)\\((?P<arg>[^)]*)\\)"   # optional: unknowable user tools
window_turns = 3                      # optional presentation override (last N user turns); default ~3
persist = false                       # opt-in Path-B "Option (C)" coarse activity log; off by default
```

The two boundary markers are **required** when the section is present (an empty
boundary degenerates the stream to one undifferentiated span); `tool_patterns`,
`catchall_pattern`, and `window_turns` are optional. The patterns are **opaque
regex strings** spt-core compiles and runs — model/tool semantics live in the
adapter, never spt-core. Presentation knobs other than `window_turns` (tool-arg
truncation ~25 chars, ANSI stripping, the rolling scan-buffer cap) are spt-core-owned
defaults: the template controls *extraction*, spt-core controls *presentation*.

Two access modes ride the one R-TERM-3 session surface (raw bytes vs parsed digest
= two channels): a **snapshot pull** (`spt digest <id>`) and a **delta-stream**
(`spt digest <id> --follow` — only changed turns are pushed). Local addressing only;
the qualified `[subnet:]id@node` cross-node form is **M4**.

### `[update]` — adapter self-update


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

### `[pty_digest]` — live activity buffer patterns (ADR-0008)

<!-- [doc->REQ-TERM-4] The adapter supplies the patterns; spt-core only runs them
over the broker PTY byte stream (Path A's "adapter owns the parser" rule, identical
to opaque command templates). This is NOT a built-in per-harness parser — it never
locates or parses the harness transcript. Absent section ⇒ the digest is simply
unavailable for the adapter. -->

```toml
[pty_digest]
input_pattern = "\\nUSER> "          # user-input boundary (opens a span until the next match)
agent_pattern = "\\nAGENT> "         # agent-output boundary (opens a span until the next match)
tool_patterns = [                     # one regex per known tool; name `arg` (+ optional `name`) groups
  "(?P<name>Write)\\((?P<arg>[^)]*)\\)",
  "(?P<name>Bash)\\((?P<arg>[^)]*)\\)",
]
catchall_pattern = "(?P<name>[A-Z][a-zA-Z]+)\\((?P<arg>[^)]*)\\)"   # optional: unknowable user tools
window_turns = 3                      # optional presentation override (last N user turns); default ~3
persist = false                       # opt-in Path-B "Option (C)" coarse activity log; off by default
```

The two boundary markers are **required** when the section is present (an empty
boundary degenerates the stream to one undifferentiated span); `tool_patterns`,
`catchall_pattern`, and `window_turns` are optional. The patterns are **opaque
regex strings** spt-core compiles and runs — model/tool semantics live in the
adapter, never spt-core. Presentation knobs other than `window_turns` (tool-arg
truncation ~25 chars, ANSI stripping, the rolling scan-buffer cap) are spt-core-owned
defaults: the template controls *extraction*, spt-core controls *presentation*.

Two access modes ride the one R-TERM-3 session surface (raw bytes vs parsed digest
= two channels): a **snapshot pull** (`spt digest <id>`) and a **delta-stream**
(`spt digest <id> --follow` — only changed turns are pushed). Local addressing only;
the qualified `[subnet:]id@node` cross-node form is **M4**.

### `[update]` — adapter self-update

parent_ancestor_name = "claude"    # process-tree anchor when session_id absent

+### `[pty_digest]` — live activity buffer patterns (ADR-0008)

+<!-- [doc->REQ-TERM-4] The adapter supplies the patterns; spt-core only runs them

+over the broker PTY byte stream (Path A's "adapter owns the parser" rule, identical

+to opaque command templates). This is NOT a built-in per-harness parser — it never

+locates or parses the harness transcript. Absent section ⇒ the digest is simply

+input_pattern = "\\nUSER> "          # user-input boundary (opens a span until the next match)

+agent_pattern = "\\nAGENT> "         # agent-output boundary (opens a span until the next match)

+tool_patterns = [                     # one regex per known tool; name `arg` (+ optional `name`) groups

+catchall_pattern = "(?P<name>[A-Z][a-zA-Z]+)\\((?P<arg>[^)]*)\\)"   # optional: unknowable user tools

+window_turns = 3                      # optional presentation override (last N user turns); default ~3

+persist = false                       # opt-in Path-B "Option (C)" coarse activity log; off by default

+The two boundary markers are **required** when the section is present (an empty

+boundary degenerates the stream to one undifferentiated span); `tool_patterns`,

+`catchall_pattern`, and `window_turns` are optional. The patterns are **opaque

+regex strings** spt-core compiles and runs — model/tool semantics live in the

+adapter, never spt-core. Presentation knobs other than `window_turns` (tool-arg

+truncation ~25 chars, ANSI stripping, the rolling scan-buffer cap) are spt-core-owned

+defaults: the template controls *extraction*, spt-core controls *presentation*.

+Two access modes ride the one R-TERM-3 session surface (raw bytes vs parsed digest

+= two channels): a **snapshot pull** (`spt digest <id>`) and a **delta-stream**

+(`spt digest <id> --follow` — only changed turns are pushed). Local addressing only;

+the qualified `[subnet:]id@node` cross-node form is **M4**.


Verify spt-runtime compiles + tests pass:


Bash

cargo test -p spt-runtime --lib 2>&1 | tail -20

Build + test spt-runtime


test runtime::tests::bounded_run_captures_success ... ok
test runtime::tests::bounded_run_kills_on_timeout ... ok

failures:

---- manifest::tests::harness_fixture_parses_all_sections stdout ----

thread 'manifest::tests::harness_fixture_parses_all_sections' (41976) panicked at crates\spt-runtime\src\manifest.rs:700:9:
assertion `left == right` failed
  left: "\\nUSER> "
 right: "\nUSER> "
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures:
    manifest::tests::harness_fixture_parses_all_sections

test result: FAILED. 19 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s

error: test failed, to rerun pass `-p spt-runtime --lib`


TOML `\\n` → literal backslash-n (regex source), correct. Fix the assert:


Edit

        let d = m.pty_digest.unwrap();
        assert_eq!(d.input_pattern, "\nUSER> ");

        let d = m.pty_digest.unwrap();
        assert_eq!(d.input_pattern, "\\nUSER> ", "regex source: literal backslash-n, not a newline");


        let d = m.pty_digest.unwrap();
        assert_eq!(d.input_pattern, "\nUSER> ");

        let d = m.pty_digest.unwrap();
        assert_eq!(d.input_pattern, "\\nUSER> ", "regex source: literal backslash-n, not a newline");

-        assert_eq!(d.input_pattern, "\nUSER> ");

+        assert_eq!(d.input_pattern, "\\nUSER> ", "regex source: literal backslash-n, not a newline");


Bash

cargo test -p spt-runtime --lib 2>&1 | tail -6

Re-test spt-runtime


test runtime::tests::spawn_session_detached_returns_pid ... ok
test runtime::tests::bounded_run_captures_success ... ok
test runtime::tests::bounded_run_kills_on_timeout ... ok

test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s


spt-runtime seam ✓. **Step 2 — `DigestEngine` in spt-daemon.** First check spt-term's serde availability (digest types must serialize for IPC):


grep -A12 "\[dependencies\]" crates/spt-term/Cargo.toml

spt-term dependencies


[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
portable-pty = "0.8"
regex = "1"

[dev-dependencies]
tempfile = "3"


Add serde to spt-term + derive on digest data types (they cross the IPC):


Edit

portable-pty = "0.8"
regex = "1"
# The digest data types (Digest/Turn/DigestEntry/ToolUse) cross the broker↔brain
# IPC + the `spt digest` control channel (M3b B8), so they are serde-serializable.
serde = { version = "1", features = ["derive"] }


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


1	[package]
2	name = "spt-term"
3	version = "0.0.0"
4	edition = "2021"
5	publish = false
6	description = "spt-core session-surface mechanism (M3a): the multiplatform PTY layer every hosted agent session runs inside. ConPTY (Windows) / forkpty (Unix) behind one SessionSurface trait, byte-stream output with bounded backpressure, and the live activity buffer (PTY digest) parser primitive. Internal broker machinery (NOT public SDK, R-ARCH-2) — the M3b daemon hosts many of these."
7
8	[dependencies]
9	spt-proto = { path = "../spt-proto" }
10	spt-store = { path = "../spt-store" }
11	spt-msg = { path = "../spt-msg" }
12	portable-pty = "0.8"
13	regex = "1"
14
15	[dev-dependencies]
16	tempfile = "3"
17

[package]
name = "spt-term"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core session-surface mechanism (M3a): the multiplatform PTY layer every hosted agent session runs inside. ConPTY (Windows) / forkpty (Unix) behind one SessionSurface trait, byte-stream output with bounded backpressure, and the live activity buffer (PTY digest) parser primitive. Internal broker machinery (NOT public SDK, R-ARCH-2) — the M3b daemon hosts many of these."

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
portable-pty = "0.8"
regex = "1"

[dev-dependencies]
tempfile = "3"


portable-pty = "0.8"
regex = "1"

[dev-dependencies]

portable-pty = "0.8"
regex = "1"
# The digest data types (Digest/Turn/DigestEntry/ToolUse) cross the broker↔brain
# IPC + the `spt digest` control channel (M3b B8), so they are serde-serializable.
serde = { version = "1", features = ["derive"] }

[dev-dependencies]


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

portable-pty = "0.8"
regex = "1"
# The digest data types (Digest/Turn/DigestEntry/ToolUse) cross the broker↔brain
# IPC + the `spt digest` control channel (M3b B8), so they are serde-serializable.
serde = { version = "1", features = ["derive"] }

[dev-dependencies]

[package]
name = "spt-term"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core session-surface mechanism (M3a): the multiplatform PTY layer every hosted agent session runs inside. ConPTY (Windows) / forkpty (Unix) behind one SessionSurface trait, byte-stream output with bounded backpressure, and the live activity buffer (PTY digest) parser primitive. Internal broker machinery (NOT public SDK, R-ARCH-2) — the M3b daemon hosts many of these."

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
portable-pty = "0.8"
regex = "1"

[dev-dependencies]
tempfile = "3"

+# The digest data types (Digest/Turn/DigestEntry/ToolUse) cross the broker↔brain

+# IPC + the `spt digest` control channel (M3b B8), so they are serde-serializable.

+serde = { version = "1", features = ["derive"] }


Now derive serde on the digest data types:


use regex::Regex;
use serde::{Deserialize, Serialize};


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

use regex::Regex;
use serde::{Deserialize, Serialize};

//! clean text on both the forkpty raw-pipe and ConPTY screen-buffer backends.

/// A failure compiling an adapter-supplied pattern.


/// A single collapsed tool invocation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolUse {

/// A single collapsed tool invocation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolUse {


/// A single collapsed tool invocation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolUse {

/// A single collapsed tool invocation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolUse {

+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]


/// One source-tagged entry within a turn.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DigestEntry {

/// One source-tagged entry within a turn.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DigestEntry {


/// One source-tagged entry within a turn.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DigestEntry {

/// One source-tagged entry within a turn.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DigestEntry {

/// Agent output text (between boundaries).


/// One user turn: the user input that opened it (None for any pre-input
/// preamble) plus the agent output / tool sprints that followed, in order.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Turn {

/// One user turn: the user input that opened it (None for any pre-input
/// preamble) plus the agent output / tool sprints that followed, in order.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Turn {


/// One user turn: the user input that opened it (None for any pre-input
/// preamble) plus the agent output / tool sprints that followed, in order.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Turn {

/// One user turn: the user input that opened it (None for any pre-input
/// preamble) plus the agent output / tool sprints that followed, in order.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Turn {

-#[derive(Debug, Clone, PartialEq, Eq, Default)]

+#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]

/// The user input text that opened this turn.


/// The structured rolling digest — the last `N` turns.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Digest {

/// The structured rolling digest — the last `N` turns.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Digest {


/// The structured rolling digest — the last `N` turns.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Digest {

/// The structured rolling digest — the last `N` turns.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Digest {

/// Turns oldest-to-newest, at most `window_turns` of them.


Bash

Build spt-term with serde derives


   Compiling spt-term v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.16s


spt-term serde ✓. Path-B append currently lives in the `spt` crate (`reporting::append_history`) — but spt-daemon is *below* spt. The history-log append is a store op; I'll add `spt-store::history::append` as its proper home and have both call it. Check spt-store lib:


1	//! `spt-store` — the persistence commodity layer for spt-core.
2	//!
3	//! Sits directly above `spt-proto` (R-ARCH-1 layering): it serializes proto
4	//! types to disk (spool, registry, info.json) through a single atomic-write
5	//! primitive. Stable on-disk formats are copied verbatim from the sister
6	//! project (ADR-0001); the EBUSY-retry hardening on the write path is the
7	//! clean-room addition demanded by KNOWN-HAZARDS 5.2.
8	//!
9	// Second layer of the acyclically-layered crate stack (R-ARCH-1): spt-store
10	// depends only on spt-proto (one layer down), never the reverse — the
11	// dependency edge is one-way, so the workspace graph stays acyclic.
12	// [impl->REQ-ARCH-1]
13
14	pub mod atomic;
15	mod db;
16	pub mod info;
17	pub mod liveness;
18	pub mod perch;
19	pub mod proc;
20	pub mod registry;
21	pub mod seed;
22	pub mod spool;
23

//! `spt-store` — the persistence commodity layer for spt-core.
//!
//! Sits directly above `spt-proto` (R-ARCH-1 layering): it serializes proto
//! types to disk (spool, registry, info.json) through a single atomic-write
//! primitive. Stable on-disk formats are copied verbatim from the sister
//! project (ADR-0001); the EBUSY-retry hardening on the write path is the
//! clean-room addition demanded by KNOWN-HAZARDS 5.2.
//!
// Second layer of the acyclically-layered crate stack (R-ARCH-1): spt-store
// depends only on spt-proto (one layer down), never the reverse — the
// dependency edge is one-way, so the workspace graph stays acyclic.
// [impl->REQ-ARCH-1]

pub mod atomic;
mod db;
pub mod info;
pub mod liveness;
pub mod perch;
pub mod proc;
pub mod registry;
pub mod seed;
pub mod spool;


105	        eprintln!("PRESENCE_FAIL:{id}: {e}");
106	        return EXIT_REFUSED;
107	    }
108	    eprintln!("PRESENCE:{id}");
109	    0
110	}
111
112	/// Append a normalized record to the perch's Path-B native history store,
113	/// returning bytes written. Separated for unit-testability.
114	pub fn append_history(id: &str, record: &str) -> std::io::Result<usize> {
115	    use std::io::Write;
116	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
117	    std::fs::create_dir_all(&perch_path)?;
118	    let mut f = std::fs::OpenOptions::new()
119	        .create(true)
120	        .append(true)
121	        .open(perch_path.join("history.log"))?;
122	    let line = if record.ends_with('\n') {
123	        record.to_string()
124	    } else {
125	        format!("{record}\n")
126	    };
127	    f.write_all(line.as_bytes())?;
128	    Ok(line.len())
129	}
130
131	/// `api history-log <id>` — Path-B ingest: read a normalized record from stdin
132	/// and append it to the native store.
133	// [impl->REQ-API-2]
134	pub fn cmd_history_log(id: &str) -> i32 {
135	    use std::io::Read;
136	    let mut record = String::new();
137	    let _ = std::io::stdin().read_to_string(&mut record);
138	    if record.trim().is_empty() {
139	        eprintln!("EMPTY_RECORD: history-log read nothing on stdin");
140	        return EXIT_REFUSED;
141	    }
142	    match append_history(id, record.trim_end()) {
143	        Ok(n) => {
144	            eprintln!("HISTORY_LOGGED:{id}:{n}");
145	            0
146	        }
147	        Err(e) => {
148	            eprintln!("HISTORY_FAIL:{id}: {e}");
149	            EXIT_REFUSED
150	        }
151	    }
152	}
153
154	/// `api emit --type <t> <id>` — a Shell pushes a sensory payload to its owner.
155	/// Stubbed in M2a (concrete Shells + the sensory channel are M5); declared so
156	/// the surface is complete and adapters can bind it.
157	// [impl->REQ-API-2]
158	pub fn cmd_emit(id: &str, sensory_type: &str) -> i32 {
159	    eprintln!("EMIT_STUB:{id} type={sensory_type} (Shell sensory channel lands in M5)");

        eprintln!("PRESENCE_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    eprintln!("PRESENCE:{id}");
    0
}

/// Append a normalized record to the perch's Path-B native history store,
/// returning bytes written. Separated for unit-testability.
pub fn append_history(id: &str, record: &str) -> std::io::Result<usize> {
    use std::io::Write;
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    std::fs::create_dir_all(&perch_path)?;
    let mut f = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(perch_path.join("history.log"))?;
    let line = if record.ends_with('\n') {
        record.to_string()
    } else {
        format!("{record}\n")
    };
    f.write_all(line.as_bytes())?;
    Ok(line.len())
}

/// `api history-log <id>` — Path-B ingest: read a normalized record from stdin
/// and append it to the native store.
// [impl->REQ-API-2]
pub fn cmd_history_log(id: &str) -> i32 {
    use std::io::Read;
    let mut record = String::new();
    let _ = std::io::stdin().read_to_string(&mut record);
    if record.trim().is_empty() {
        eprintln!("EMPTY_RECORD: history-log read nothing on stdin");
        return EXIT_REFUSED;
    }
    match append_history(id, record.trim_end()) {
        Ok(n) => {
            eprintln!("HISTORY_LOGGED:{id}:{n}");
            0
        }
        Err(e) => {
            eprintln!("HISTORY_FAIL:{id}: {e}");
            EXIT_REFUSED
        }
    }
}

/// `api emit --type <t> <id>` — a Shell pushes a sensory payload to its owner.
/// Stubbed in M2a (concrete Shells + the sensory channel are M5); declared so
/// the surface is complete and adapters can bind it.
// [impl->REQ-API-2]
pub fn cmd_emit(id: &str, sensory_type: &str) -> i32 {
    eprintln!("EMIT_STUB:{id} type={sensory_type} (Shell sensory channel lands in M5)");


Create `spt-store::history` as the proper home for Path-B append:


Write

//! Path-B native history store — the append-only `history.log` under a perch.
//!
//! Path B (CONTEXT §history subsystem) is the *native* transcript strategy: the
//! adapter (or spt-core itself) pushes already-normalized records and spt-core
//! stores them, rather than locating + parsing a version-fragile harness
//! transcript. The store is deliberately trivial — one newline-delimited record
//! per line, append-only — so it is a commodity store operation, not logic.
//!
//! Two callers append here today: `api history-log` (an adapter pushing a
//! normalized conversation record, REQ-API-2) and the M3b PTY-digest's opt-in
//! "Option (C)" persistence (a coarse activity-log record per completed turn,
//! ADR-0008 / REQ-TERM-4). Both want the same primitive, and it must live below
//! the `spt` binary (the daemon, which is below `spt`, persists digests) — so it
//! sits here in the store layer, the lowest common home.
// [impl->REQ-API-2]

use std::io::Write;
use std::path::Path;

/// Append a single normalized `record` to `<perch_path>/history.log`, creating
/// the perch dir + file if absent. A trailing newline is ensured (the store is
/// one record per line). Returns the number of bytes written (record + newline).
pub fn append_record(perch_path: &Path, record: &str) -> std::io::Result<usize> {
    std::fs::create_dir_all(perch_path)?;
    let mut f = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(perch_path.join("history.log"))?;
    let line = if record.ends_with('\n') {
        record.to_string()
    } else {
        format!("{record}\n")
    };
    f.write_all(line.as_bytes())?;
    Ok(line.len())
}

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

    // [unit->REQ-API-2] records append one-per-line, file auto-created.
    #[test]
    fn appends_one_record_per_line() {
        let dir = tempfile::tempdir().unwrap();
        let perch = dir.path().join("perch");
        append_record(&perch, "{\"role\":\"user\"}").unwrap();
        append_record(&perch, "{\"role\":\"assistant\"}\n").unwrap();
        let log = std::fs::read_to_string(perch.join("history.log")).unwrap();
        assert_eq!(log.lines().count(), 2, "two records, two lines");
        assert!(log.lines().all(|l| !l.is_empty()));
    }
}