50
51	/// A spawn/template failure. Carries a message for the operator; never panics.
52	#[derive(Debug)]
53	pub enum RuntimeError {
54	    /// A `{placeholder}` in a command had no provided value.
55	    MissingKey { key: String, command: String },
56	    /// The filled command was empty (no program token).
57	    EmptyCommand,
58	    /// The named role isn't declared in the manifest.
59	    UnknownRole(String),
60	    /// The OS refused to spawn the process.
61	    Spawn(std::io::Error),
62	    /// A bounded invocation exceeded its timeout and was killed.
63	    Timeout { command: String, after: Duration },
64	}
65
66	impl std::fmt::Display for RuntimeError {
67	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68	        match self {
69	            RuntimeError::MissingKey { key, command } => {
70	                write!(f, "no value for substitution key {{{key}}} in command: {command}")
71	            }
72	            RuntimeError::EmptyCommand => write!(f, "command template produced an empty command"),
73	            RuntimeError::UnknownRole(r) => write!(f, "manifest declares no [session.{r}] role"),
74	            RuntimeError::Spawn(e) => write!(f, "failed to spawn session: {e}"),
75	            RuntimeError::Timeout { command, after } => {
76	                write!(f, "bounded invocation timed out after {after:?} (killed): {command}")
77	            }
78	        }
79	    }
80	}
81
82	impl std::error::Error for RuntimeError {}
83
84	/// Fill `{key}` placeholders in an opaque template from `keys`. Unknown
85	/// placeholders error (listing the missing key); literal text passes through.
86	/// `{{`/`}}` are not interpreted — command templates use bare `{key}` only.
87	pub fn fill_template(
88	    template: &str,
89	    keys: &BTreeMap<String, String>,
90	) -> Result<String, RuntimeError> {
91	    let mut out = String::with_capacity(template.len());
92	    let mut rest = template;
93	    while let Some(open) = rest.find('{') {
94	        out.push_str(&rest[..open]);
95	        let after = &rest[open + 1..];
96	        let close = after.find('}').ok_or_else(|| RuntimeError::MissingKey {
97	            key: after.chars().take(16).collect(),
98	            command: template.to_string(),
99	        })?;
100	        let key = &after[..close];
101	        let value = keys.get(key).ok_or_else(|| RuntimeError::MissingKey {
102	            key: key.to_string(),
103	            command: template.to_string(),
104	        })?;
105	        out.push_str(value);
106	        rest = &after[close + 1..];
107	    }
108	    out.push_str(rest);
109	    Ok(out)
110	}
111
112	/// Split a filled command line into program + args, honoring double quotes so a
113	/// quoted argument with spaces stays one token. Clean-room (no shell dependency,
114	/// portable). Single quotes are literal — adapters quote with `"`.
115	pub fn tokenize(command: &str) -> Vec<String> {
116	    let mut tokens = Vec::new();
117	    let mut cur = String::new();
118	    let mut in_quote = false;
119	    let mut has_token = false;
120	    for ch in command.chars() {
121	        match ch {
122	            '"' => {
123	                in_quote = !in_quote;
124	                has_token = true;
125	            }
126	            c if c.is_whitespace() && !in_quote => {
127	                if has_token {
128	                    tokens.push(std::mem::take(&mut cur));
129	                    has_token = false;
130	                }
131	            }
132	            c => {
133	                cur.push(c);
134	                has_token = true;
135	            }
136	        }
137	    }
138	    if has_token {
139	        tokens.push(cur);
140	    }
141	    tokens
142	}
143
144	/// A spawned detached session — its OS pid. The handle is intentionally dropped

/// A spawn/template failure. Carries a message for the operator; never panics.
#[derive(Debug)]
pub enum RuntimeError {
    /// A `{placeholder}` in a command had no provided value.
    MissingKey { key: String, command: String },
    /// The filled command was empty (no program token).
    EmptyCommand,
    /// The named role isn't declared in the manifest.
    UnknownRole(String),
    /// The OS refused to spawn the process.
    Spawn(std::io::Error),
    /// A bounded invocation exceeded its timeout and was killed.
    Timeout { command: String, after: Duration },
}

impl std::fmt::Display for RuntimeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RuntimeError::MissingKey { key, command } => {
                write!(f, "no value for substitution key {{{key}}} in command: {command}")
            }
            RuntimeError::EmptyCommand => write!(f, "command template produced an empty command"),
            RuntimeError::UnknownRole(r) => write!(f, "manifest declares no [session.{r}] role"),
            RuntimeError::Spawn(e) => write!(f, "failed to spawn session: {e}"),
            RuntimeError::Timeout { command, after } => {
                write!(f, "bounded invocation timed out after {after:?} (killed): {command}")
            }
        }
    }
}

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

/// Fill `{key}` placeholders in an opaque template from `keys`. Unknown
/// placeholders error (listing the missing key); literal text passes through.
/// `{{`/`}}` are not interpreted — command templates use bare `{key}` only.
pub fn fill_template(
    template: &str,
    keys: &BTreeMap<String, String>,
) -> Result<String, RuntimeError> {
    let mut out = String::with_capacity(template.len());
    let mut rest = template;
    while let Some(open) = rest.find('{') {
        out.push_str(&rest[..open]);
        let after = &rest[open + 1..];
        let close = after.find('}').ok_or_else(|| RuntimeError::MissingKey {
            key: after.chars().take(16).collect(),
            command: template.to_string(),
        })?;
        let key = &after[..close];
        let value = keys.get(key).ok_or_else(|| RuntimeError::MissingKey {
            key: key.to_string(),
            command: template.to_string(),
        })?;
        out.push_str(value);
        rest = &after[close + 1..];
    }
    out.push_str(rest);
    Ok(out)
}

/// Split a filled command line into program + args, honoring double quotes so a
/// quoted argument with spaces stays one token. Clean-room (no shell dependency,
/// portable). Single quotes are literal — adapters quote with `"`.
pub fn tokenize(command: &str) -> Vec<String> {
    let mut tokens = Vec::new();
    let mut cur = String::new();
    let mut in_quote = false;
    let mut has_token = false;
    for ch in command.chars() {
        match ch {
            '"' => {
                in_quote = !in_quote;
                has_token = true;
            }
            c if c.is_whitespace() && !in_quote => {
                if has_token {
                    tokens.push(std::mem::take(&mut cur));
                    has_token = false;
                }
            }
            c => {
                cur.push(c);
                has_token = true;
            }
        }
    }
    if has_token {
        tokens.push(cur);
    }
    tokens
}

/// A spawned detached session — its OS pid. The handle is intentionally dropped


63	pub struct PtySize {
64	    /// The number of lines of text
65	    pub rows: u16,
66	    /// The number of columns of text
67	    pub cols: u16,
68	    /// The width of a cell in pixels.  Note that some systems never
69	    /// fill this value and ignore it.
70	    pub pixel_width: u16,
71	    /// The height of a cell in pixels.  Note that some systems never
72	    /// fill this value and ignore it.
73	    pub pixel_height: u16,
74	}
75
76	impl Default for PtySize {
77	    fn default() -> Self {
78	        PtySize {
79	            rows: 24,
80	            cols: 80,
81	            pixel_width: 0,
82	            pixel_height: 0,
83	        }
84	    }
85	}
86
87	/// Represents the master/control end of the pty

pub struct PtySize {
    /// The number of lines of text
    pub rows: u16,
    /// The number of columns of text
    pub cols: u16,
    /// The width of a cell in pixels.  Note that some systems never
    /// fill this value and ignore it.
    pub pixel_width: u16,
    /// The height of a cell in pixels.  Note that some systems never
    /// fill this value and ignore it.
    pub pixel_height: u16,
}

impl Default for PtySize {
    fn default() -> Self {
        PtySize {
            rows: 24,
            cols: 80,
            pixel_width: 0,
            pixel_height: 0,
        }
    }
}

/// Represents the master/control end of the pty


Writing A1: `surface.rs` (trait + types) and `pty.rs` (backend).


Write

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

use portable_pty::PtySize;

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

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

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

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

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

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

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

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

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


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-term\src\surface.rs (file state is current in your context — no need to Read it back)

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

use portable_pty::PtySize;

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

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

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

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

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

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

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

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

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


Edit

    /// Write raw bytes to the child's input. Escape sequences pass through
    /// verbatim (the cooked/raw distinction is the caller's — see
    /// [`crate::pty::PtySession::send_line`] / `send_keys`). Flushed on return.

    /// Write raw bytes to the child's input. Escape sequences pass through
    /// verbatim (the cooked/raw `send_line` / `send_keys` distinction lands on
    /// the concrete surface in A3). Flushed on return.


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

    /// Write raw bytes to the child's input. Escape sequences pass through
    /// verbatim (the cooked/raw `send_line` / `send_keys` distinction lands on
    /// the concrete surface in A3). Flushed on return.

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

use portable_pty::PtySize;

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

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

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

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

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

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

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

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

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

-    /// verbatim (the cooked/raw distinction is the caller's — see

-    /// [`crate::pty::PtySession::send_line`] / `send_keys`). Flushed on return.

+    /// verbatim (the cooked/raw `send_line` / `send_keys` distinction lands on

+    /// the concrete surface in A3). Flushed on return.


Write

//! Native-PTY session backend — ConPTY on Windows 10+, `forkpty(3)` on Unix,
//! both via `portable-pty` (the exact crate + handoff shape Spikes #1 / #4
//! proved).
//!
//! [`PtySession`] spawns a child under a real PTY, owns the master reader and
//! writer, and serves the [`SessionSurface`] contract. It is **single-surface
//! per handle** — one child's PTY, not the multiplexed fleet (that supervisor is
//! the M3b broker).
//!
//! ## Concurrency discipline (Spike #5, binding)
//!
//! `resize()` races the reader thread on Windows; the master is therefore held
//! behind a `Mutex` and every `resize` takes that lock. The writer is a shared
//! `Arc<Mutex<…>>` so the A2 DSR drain thread can answer ConPTY's startup
//! cursor query through the same serialized writer the caller injects input
//! through — no torn writes.

use std::io::{Read, Write};
use std::sync::{Arc, Mutex};

use portable_pty::{
    native_pty_system, Child, ChildKiller, CommandBuilder, ExitStatus, MasterPty,
};

use crate::surface::{SessionSurface, SurfaceError, SurfaceSize};

/// Shared, serialized handle to the PTY master writer. Cloned to the A2 drain
/// thread so the DSR auto-answer and caller input never interleave mid-write.
pub(crate) type SharedWriter = Arc<Mutex<Box<dyn Write + Send>>>;

/// A child process running under a native PTY, exposed as a [`SessionSurface`].
///
/// Build one with [`PtySession::spawn`]; drive input via [`SessionSurface::write_input`]
/// (or the A3 `send_keys`/`send_line` helpers), drain output via
/// [`PtySession::take_reader`], and observe lifecycle via [`PtySession::try_wait`] /
/// [`PtySession::wait`] / [`PtySession::kill`].
pub struct PtySession {
    /// The master end, behind a lock so `resize` never races the reader (Spike #5).
    master: Mutex<Box<dyn MasterPty + Send>>,
    /// The single PTY writer, shared so the A2 drain thread can answer DSR.
    writer: SharedWriter,
    /// The spawned child; behind a lock for `wait`/`try_wait`.
    child: Mutex<Box<dyn Child + Send + Sync>>,
    /// A kill handle split from the child so we can terminate from another
    /// thread even while `wait` blocks (`portable-pty` `ChildKiller`).
    killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
    /// The master reader, taken at most once (`take_reader`).
    reader: Mutex<Option<Box<dyn Read + Send>>>,
    /// The child OS pid, captured at spawn.
    pid: Option<u32>,
}

impl PtySession {
    /// Spawn `cmd` under a fresh native PTY sized to `size`.
    ///
    /// The slave end is dropped immediately after the child is spawned: the
    /// child holds the only remaining slave handle, which lets EOF propagate on
    /// the Unix raw-pipe backend once the child exits. (On Windows the no-EOF
    /// behaviour is governed by the writer, handled by the A2 drain discipline.)
    //
    // [impl->REQ-TERM-1]
    pub fn spawn(cmd: CommandBuilder, size: SurfaceSize) -> Result<Self, SurfaceError> {
        let pty_system = native_pty_system();
        let pair = pty_system
            .openpty(size.into())
            .map_err(|e| SurfaceError::OpenPty(e.to_string()))?;

        let child = pair
            .slave
            .spawn_command(cmd)
            .map_err(|e| SurfaceError::Spawn(e.to_string()))?;
        // Drop the slave so the child owns the only slave-side handle.
        drop(pair.slave);

        let reader = pair
            .master
            .try_clone_reader()
            .map_err(|e| SurfaceError::Io(io_other(e)))?;
        let writer = pair
            .master
            .take_writer()
            .map_err(|e| SurfaceError::Io(io_other(e)))?;
        let killer = child.clone_killer();
        let pid = child.process_id();

        Ok(Self {
            master: Mutex::new(pair.master),
            writer: Arc::new(Mutex::new(writer)),
            child: Mutex::new(child),
            killer: Mutex::new(killer),
            reader: Mutex::new(Some(reader)),
            pid,
        })
    }

    /// 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 — used by the A2 drain thread to answer DSR
    /// through the same serialized writer the caller injects input through.
    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`).
    pub fn kill(&self) -> Result<(), SurfaceError> {
        self.killer.lock().unwrap().kill().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)))
    }
}

/// Fold an `anyhow::Error` from `portable-pty` into a `std::io::Error` so the
/// crate's public surface never leaks the `anyhow` dependency.
fn io_other(e: portable_pty::Error) -> std::io::Error {
    std::io::Error::new(std::io::ErrorKind::Other, e.to_string())
}


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-term\src\pty.rs (file state is current in your context — no need to Read it back)

//! Native-PTY session backend — ConPTY on Windows 10+, `forkpty(3)` on Unix,
//! both via `portable-pty` (the exact crate + handoff shape Spikes #1 / #4
//! proved).
//!
//! [`PtySession`] spawns a child under a real PTY, owns the master reader and
//! writer, and serves the [`SessionSurface`] contract. It is **single-surface
//! per handle** — one child's PTY, not the multiplexed fleet (that supervisor is
//! the M3b broker).
//!
//! ## Concurrency discipline (Spike #5, binding)
//!
//! `resize()` races the reader thread on Windows; the master is therefore held
//! behind a `Mutex` and every `resize` takes that lock. The writer is a shared
//! `Arc<Mutex<…>>` so the A2 DSR drain thread can answer ConPTY's startup
//! cursor query through the same serialized writer the caller injects input
//! through — no torn writes.

use std::io::{Read, Write};
use std::sync::{Arc, Mutex};

use portable_pty::{
    native_pty_system, Child, ChildKiller, CommandBuilder, ExitStatus, MasterPty,
};

use crate::surface::{SessionSurface, SurfaceError, SurfaceSize};

/// Shared, serialized handle to the PTY master writer. Cloned to the A2 drain
/// thread so the DSR auto-answer and caller input never interleave mid-write.
pub(crate) type SharedWriter = Arc<Mutex<Box<dyn Write + Send>>>;

/// A child process running under a native PTY, exposed as a [`SessionSurface`].
///
/// Build one with [`PtySession::spawn`]; drive input via [`SessionSurface::write_input`]
/// (or the A3 `send_keys`/`send_line` helpers), drain output via
/// [`PtySession::take_reader`], and observe lifecycle via [`PtySession::try_wait`] /
/// [`PtySession::wait`] / [`PtySession::kill`].
pub struct PtySession {
    /// The master end, behind a lock so `resize` never races the reader (Spike #5).
    master: Mutex<Box<dyn MasterPty + Send>>,
    /// The single PTY writer, shared so the A2 drain thread can answer DSR.
    writer: SharedWriter,
    /// The spawned child; behind a lock for `wait`/`try_wait`.
    child: Mutex<Box<dyn Child + Send + Sync>>,
    /// A kill handle split from the child so we can terminate from another
    /// thread even while `wait` blocks (`portable-pty` `ChildKiller`).
    killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
    /// The master reader, taken at most once (`take_reader`).
    reader: Mutex<Option<Box<dyn Read + Send>>>,
    /// The child OS pid, captured at spawn.
    pid: Option<u32>,
}

impl PtySession {
    /// Spawn `cmd` under a fresh native PTY sized to `size`.
    ///
    /// The slave end is dropped immediately after the child is spawned: the
    /// child holds the only remaining slave handle, which lets EOF propagate on
    /// the Unix raw-pipe backend once the child exits. (On Windows the no-EOF
    /// behaviour is governed by the writer, handled by the A2 drain discipline.)
    //
    // [impl->REQ-TERM-1]
    pub fn spawn(cmd: CommandBuilder, size: SurfaceSize) -> Result<Self, SurfaceError> {
        let pty_system = native_pty_system();
        let pair = pty_system
            .openpty(size.into())
            .map_err(|e| SurfaceError::OpenPty(e.to_string()))?;

        let child = pair
            .slave
            .spawn_command(cmd)
            .map_err(|e| SurfaceError::Spawn(e.to_string()))?;
        // Drop the slave so the child owns the only slave-side handle.
        drop(pair.slave);

        let reader = pair
            .master
            .try_clone_reader()
            .map_err(|e| SurfaceError::Io(io_other(e)))?;
        let writer = pair
            .master
            .take_writer()
            .map_err(|e| SurfaceError::Io(io_other(e)))?;
        let killer = child.clone_killer();
        let pid = child.process_id();

        Ok(Self {
            master: Mutex::new(pair.master),
            writer: Arc::new(Mutex::new(writer)),
            child: Mutex::new(child),
            killer: Mutex::new(killer),
            reader: Mutex::new(Some(reader)),
            pid,
        })
    }

    /// 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 — used by the A2 drain thread to answer DSR
    /// through the same serialized writer the caller injects input through.
    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`).
    pub fn kill(&self) -> Result<(), SurfaceError> {
        self.killer.lock().unwrap().kill().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)))
    }
}

/// Fold an `anyhow::Error` from `portable-pty` into a `std::io::Error` so the
/// crate's public surface never leaks the `anyhow` dependency.
fn io_other(e: portable_pty::Error) -> std::io::Error {
    std::io::Error::new(std::io::ErrorKind::Other, e.to_string())
}


Now wire modules into lib.rs.


Edit

// Surfaces land across M3a (see M3a-PLAN.md task table):
//   A1  SessionSurface trait + ConPTY/forkpty backends     (REQ-TERM-1)
//   A2  ConPTY-DSR auto-answer drain thread                 (REQ-HAZARD-CONPTY-DSR)
//   A3  send-keys / send-line injection                     (REQ-TERM-2)
//   A4  byte-stream local streaming (bounded backpressure)  (REQ-TERM-3)
//   A5  live activity buffer (PTY digest) parser primitive  (REQ-TERM-4)

// Surfaces land across M3a (see M3a-PLAN.md task table):
//   A1  SessionSurface trait + ConPTY/forkpty backends     (REQ-TERM-1)       ← landed
//   A2  ConPTY-DSR auto-answer drain thread                 (REQ-HAZARD-CONPTY-DSR)
//   A3  send-keys / send-line injection                     (REQ-TERM-2)
//   A4  byte-stream local streaming (bounded backpressure)  (REQ-TERM-3)
//   A5  live activity buffer (PTY digest) parser primitive  (REQ-TERM-4)

pub mod pty;
pub mod surface;

pub use portable_pty::CommandBuilder;
pub use pty::PtySession;
pub use surface::{SessionSurface, SurfaceError, SurfaceSize};


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

// Surfaces land across M3a (see M3a-PLAN.md task table):
//   A1  SessionSurface trait + ConPTY/forkpty backends     (REQ-TERM-1)       ← landed
//   A2  ConPTY-DSR auto-answer drain thread                 (REQ-HAZARD-CONPTY-DSR)
//   A3  send-keys / send-line injection                     (REQ-TERM-2)
//   A4  byte-stream local streaming (bounded backpressure)  (REQ-TERM-3)
//   A5  live activity buffer (PTY digest) parser primitive  (REQ-TERM-4)

pub mod pty;
pub mod surface;

pub use portable_pty::CommandBuilder;
pub use pty::PtySession;
pub use surface::{SessionSurface, SurfaceError, SurfaceSize};

//! spt-core session-surface mechanism — the broker's PTY half (M3a).
//!
//! `spt-term` is the **multiplatform session-surface mechanism** that every hosted
//! agent session runs inside: a real child under a native PTY — ConPTY on Windows
//! 10+, `forkpty(3)` on Unix — behind one [`SessionSurface`] trait, with input
//! injection, byte-stream output, and the live activity buffer (PTY digest) parser
//! primitive layered on top. It is the sixth crate in the acyclic stack
//! (`spt-proto -> spt-store -> spt-msg -> spt-term`), a sibling of `spt-runtime`.
//!
//! M3a delivers the *library mechanism*; the **M3b** `spt-daemon` broker delivers
//! the *process* that hosts many of these behind versioned IPC. `spt-term` is
//! deliberately **single-surface-per-handle** — it owns one child's PTY, not the
//! multiplexed name-addressable fleet (that supervisor is M3b). See `M3a-PLAN.md`.
//!
//! ## OS stream-contract neutrality (Spike #4 / #5, binding)
//!
//! The single biggest design risk this crate carries: `forkpty` is a **raw ordered
//! pipe**; ConPTY is a **screen buffer** (repaint-on-resize reorders + duplicates
//! the stream; a mid-session attach sees the viewport, not scrollback). The
//! [`SessionSurface`] trait and the byte-stream / digest contracts therefore must
//! **not** bake in either OS's semantics — the surface is modeled as a byte stream
//! with explicit, OS-independent attach/replay semantics.
//
// spt-term is internal broker machinery, NOT part of the public semver SDK
// surface (R-ARCH-2 stays spt-proto/spt-store/spt-msg/spt-runtime). The M3b
// daemon programs against it; external harness authors do not.

// Surfaces land across M3a (see M3a-PLAN.md task table):
//   A1  SessionSurface trait + ConPTY/forkpty backends     (REQ-TERM-1)
//   A2  ConPTY-DSR auto-answer drain thread                 (REQ-HAZARD-CONPTY-DSR)
//   A3  send-keys / send-line injection                     (REQ-TERM-2)
//   A4  byte-stream local streaming (bounded backpressure)  (REQ-TERM-3)
//   A5  live activity buffer (PTY digest) parser primitive  (REQ-TERM-4)

-//   A1  SessionSurface trait + ConPTY/forkpty backends     (REQ-TERM-1)

+//   A1  SessionSurface trait + ConPTY/forkpty backends     (REQ-TERM-1)       ← landed

+pub use surface::{SessionSurface, SurfaceError, SurfaceSize};


Bash

Build A1


   Compiling spt-term v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
error[E0603]: struct `Error` is private
   --> crates\spt-term\src\pty.rs:174:30
    |
174 | fn io_other(e: portable_pty::Error) -> std::io::Error {
    |                              ^^^^^ private struct
    |
note: the struct `Error` is defined here
   --> C:\Users\decid\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\portable-pty-0.8.1\src\lib.rs:40:5
    |
 40 | use anyhow::Error;
    |     ^^^^^^^^^^^^^
help: import `Error` directly
    |
174 - fn io_other(e: portable_pty::Error) -> std::io::Error {
174 + fn io_other(e: anyhow::Error) -> std::io::Error {
    |

For more information about this error, try `rustc --explain E0603`.
error: could not compile `spt-term` (lib) due to 1 previous error


Edit

/// Fold an `anyhow::Error` from `portable-pty` into a `std::io::Error` so the
/// crate's public surface never leaks the `anyhow` dependency.
fn io_other(e: portable_pty::Error) -> std::io::Error {
    std::io::Error::new(std::io::ErrorKind::Other, e.to_string())
}

/// Fold a `portable-pty` error (a private `anyhow::Error` alias) into a
/// `std::io::Error` so the crate's public surface never leaks `anyhow`. Generic
/// over `Display` to avoid naming the un-exported error type.
fn io_other<E: std::fmt::Display>(e: E) -> std::io::Error {
    std::io::Error::new(std::io::ErrorKind::Other, e.to_string())
}


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

/// Fold a `portable-pty` error (a private `anyhow::Error` alias) into a
/// `std::io::Error` so the crate's public surface never leaks `anyhow`. Generic
/// over `Display` to avoid naming the un-exported error type.
fn io_other<E: std::fmt::Display>(e: E) -> std::io::Error {
    std::io::Error::new(std::io::ErrorKind::Other, e.to_string())
}

//! Native-PTY session backend — ConPTY on Windows 10+, `forkpty(3)` on Unix,
//! both via `portable-pty` (the exact crate + handoff shape Spikes #1 / #4
//! proved).
//!
//! [`PtySession`] spawns a child under a real PTY, owns the master reader and
//! writer, and serves the [`SessionSurface`] contract. It is **single-surface
//! per handle** — one child's PTY, not the multiplexed fleet (that supervisor is
//! the M3b broker).
//!
//! ## Concurrency discipline (Spike #5, binding)
//!
//! `resize()` races the reader thread on Windows; the master is therefore held
//! behind a `Mutex` and every `resize` takes that lock. The writer is a shared
//! `Arc<Mutex<…>>` so the A2 DSR drain thread can answer ConPTY's startup
//! cursor query through the same serialized writer the caller injects input
//! through — no torn writes.

use std::io::{Read, Write};
use std::sync::{Arc, Mutex};

use portable_pty::{
    native_pty_system, Child, ChildKiller, CommandBuilder, ExitStatus, MasterPty,
};

use crate::surface::{SessionSurface, SurfaceError, SurfaceSize};

/// Shared, serialized handle to the PTY master writer. Cloned to the A2 drain
/// thread so the DSR auto-answer and caller input never interleave mid-write.
pub(crate) type SharedWriter = Arc<Mutex<Box<dyn Write + Send>>>;

/// A child process running under a native PTY, exposed as a [`SessionSurface`].
///
/// Build one with [`PtySession::spawn`]; drive input via [`SessionSurface::write_input`]
/// (or the A3 `send_keys`/`send_line` helpers), drain output via
/// [`PtySession::take_reader`], and observe lifecycle via [`PtySession::try_wait`] /
/// [`PtySession::wait`] / [`PtySession::kill`].
pub struct PtySession {
    /// The master end, behind a lock so `resize` never races the reader (Spike #5).
    master: Mutex<Box<dyn MasterPty + Send>>,
    /// The single PTY writer, shared so the A2 drain thread can answer DSR.
    writer: SharedWriter,
    /// The spawned child; behind a lock for `wait`/`try_wait`.
    child: Mutex<Box<dyn Child + Send + Sync>>,
    /// A kill handle split from the child so we can terminate from another
    /// thread even while `wait` blocks (`portable-pty` `ChildKiller`).
    killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
    /// The master reader, taken at most once (`take_reader`).
    reader: Mutex<Option<Box<dyn Read + Send>>>,
    /// The child OS pid, captured at spawn.
    pid: Option<u32>,
}

impl PtySession {
    /// Spawn `cmd` under a fresh native PTY sized to `size`.
    ///
    /// The slave end is dropped immediately after the child is spawned: the
    /// child holds the only remaining slave handle, which lets EOF propagate on
    /// the Unix raw-pipe backend once the child exits. (On Windows the no-EOF
    /// behaviour is governed by the writer, handled by the A2 drain discipline.)
    //
    // [impl->REQ-TERM-1]
    pub fn spawn(cmd: CommandBuilder, size: SurfaceSize) -> Result<Self, SurfaceError> {
        let pty_system = native_pty_system();
        let pair = pty_system
            .openpty(size.into())
            .map_err(|e| SurfaceError::OpenPty(e.to_string()))?;

        let child = pair
            .slave
            .spawn_command(cmd)
            .map_err(|e| SurfaceError::Spawn(e.to_string()))?;
        // Drop the slave so the child owns the only slave-side handle.
        drop(pair.slave);

        let reader = pair
            .master
            .try_clone_reader()
            .map_err(|e| SurfaceError::Io(io_other(e)))?;
        let writer = pair
            .master
            .take_writer()
            .map_err(|e| SurfaceError::Io(io_other(e)))?;
        let killer = child.clone_killer();
        let pid = child.process_id();

        Ok(Self {
            master: Mutex::new(pair.master),
            writer: Arc::new(Mutex::new(writer)),
            child: Mutex::new(child),
            killer: Mutex::new(killer),
            reader: Mutex::new(Some(reader)),
            pid,
        })
    }

    /// 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 — used by the A2 drain thread to answer DSR
    /// through the same serialized writer the caller injects input through.
    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`).
    pub fn kill(&self) -> Result<(), SurfaceError> {
        self.killer.lock().unwrap().kill().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)))
    }
}

/// Fold an `anyhow::Error` from `portable-pty` into a `std::io::Error` so the
/// crate's public surface never leaks the `anyhow` dependency.
fn io_other(e: portable_pty::Error) -> std::io::Error {
    std::io::Error::new(std::io::ErrorKind::Other, e.to_string())
}

-/// Fold an `anyhow::Error` from `portable-pty` into a `std::io::Error` so the

-/// crate's public surface never leaks the `anyhow` dependency.

-fn io_other(e: portable_pty::Error) -> std::io::Error {

+/// Fold a `portable-pty` error (a private `anyhow::Error` alias) into a

+/// `std::io::Error` so the crate's public surface never leaks `anyhow`. Generic

+/// over `Display` to avoid naming the un-exported error type.

+fn io_other<E: std::fmt::Display>(e: E) -> std::io::Error {


Bash

Rebuild A1


   Compiling spt-term v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
warning: method `shared_writer` is never used
   --> crates\spt-term\src\pty.rs:127:19
    |
 53 | impl PtySession {
    | --------------- method in this implementation
...
127 |     pub(crate) fn shared_writer(&self) -> SharedWriter {
    |                   ^^^^^^^^^^^^^
    |
    = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default

warning: `spt-term` (lib) generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s


A1 builds. Drop the A2-only `shared_writer` accessor to keep A1 warning-clean — it lands with A2.


Edit

    /// The shared writer handle — used by the A2 drain thread to answer DSR
    /// through the same serialized writer the caller injects input through.
    pub(crate) fn shared_writer(&self) -> SharedWriter {
        Arc::clone(&self.writer)
    }

    /// Poll for child exit without blocking.

/// Poll for child exit without blocking.


//! Native-PTY session backend — ConPTY on Windows 10+, `forkpty(3)` on Unix,
//! both via `portable-pty` (the exact crate + handoff shape Spikes #1 / #4
//! proved).
//!
//! [`PtySession`] spawns a child under a real PTY, owns the master reader and
//! writer, and serves the [`SessionSurface`] contract. It is **single-surface
//! per handle** — one child's PTY, not the multiplexed fleet (that supervisor is
//! the M3b broker).
//!
//! ## Concurrency discipline (Spike #5, binding)
//!
//! `resize()` races the reader thread on Windows; the master is therefore held
//! behind a `Mutex` and every `resize` takes that lock. The writer is a shared
//! `Arc<Mutex<…>>` so the A2 DSR drain thread can answer ConPTY's startup
//! cursor query through the same serialized writer the caller injects input
//! through — no torn writes.

use std::io::{Read, Write};
use std::sync::{Arc, Mutex};

use portable_pty::{
    native_pty_system, Child, ChildKiller, CommandBuilder, ExitStatus, MasterPty,
};

use crate::surface::{SessionSurface, SurfaceError, SurfaceSize};

/// Shared, serialized handle to the PTY master writer. Cloned to the A2 drain
/// thread so the DSR auto-answer and caller input never interleave mid-write.
pub(crate) type SharedWriter = Arc<Mutex<Box<dyn Write + Send>>>;

/// A child process running under a native PTY, exposed as a [`SessionSurface`].
///
/// Build one with [`PtySession::spawn`]; drive input via [`SessionSurface::write_input`]
/// (or the A3 `send_keys`/`send_line` helpers), drain output via
/// [`PtySession::take_reader`], and observe lifecycle via [`PtySession::try_wait`] /
/// [`PtySession::wait`] / [`PtySession::kill`].
pub struct PtySession {
    /// The master end, behind a lock so `resize` never races the reader (Spike #5).
    master: Mutex<Box<dyn MasterPty + Send>>,
    /// The single PTY writer, shared so the A2 drain thread can answer DSR.
    writer: SharedWriter,
    /// The spawned child; behind a lock for `wait`/`try_wait`.
    child: Mutex<Box<dyn Child + Send + Sync>>,
    /// A kill handle split from the child so we can terminate from another
    /// thread even while `wait` blocks (`portable-pty` `ChildKiller`).
    killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
    /// The master reader, taken at most once (`take_reader`).
    reader: Mutex<Option<Box<dyn Read + Send>>>,
    /// The child OS pid, captured at spawn.
    pid: Option<u32>,
}

impl PtySession {
    /// Spawn `cmd` under a fresh native PTY sized to `size`.
    ///
    /// The slave end is dropped immediately after the child is spawned: the
    /// child holds the only remaining slave handle, which lets EOF propagate on
    /// the Unix raw-pipe backend once the child exits. (On Windows the no-EOF
    /// behaviour is governed by the writer, handled by the A2 drain discipline.)
    //
    // [impl->REQ-TERM-1]
    pub fn spawn(cmd: CommandBuilder, size: SurfaceSize) -> Result<Self, SurfaceError> {
        let pty_system = native_pty_system();
        let pair = pty_system
            .openpty(size.into())
            .map_err(|e| SurfaceError::OpenPty(e.to_string()))?;

        let child = pair
            .slave
            .spawn_command(cmd)
            .map_err(|e| SurfaceError::Spawn(e.to_string()))?;
        // Drop the slave so the child owns the only slave-side handle.
        drop(pair.slave);

        let reader = pair
            .master
            .try_clone_reader()
            .map_err(|e| SurfaceError::Io(io_other(e)))?;
        let writer = pair
            .master
            .take_writer()
            .map_err(|e| SurfaceError::Io(io_other(e)))?;
        let killer = child.clone_killer();
        let pid = child.process_id();

        Ok(Self {
            master: Mutex::new(pair.master),
            writer: Arc::new(Mutex::new(writer)),
            child: Mutex::new(child),
            killer: Mutex::new(killer),
            reader: Mutex::new(Some(reader)),
            pid,
        })
    }

    /// 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 — used by the A2 drain thread to answer DSR
    /// through the same serialized writer the caller injects input through.
    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`).
    pub fn kill(&self) -> Result<(), SurfaceError> {
        self.killer.lock().unwrap().kill().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)))
    }
}

/// Fold a `portable-pty` error (a private `anyhow::Error` alias) into a
/// `std::io::Error` so the crate's public surface never leaks `anyhow`. Generic
/// over `Display` to avoid naming the un-exported error type.
fn io_other<E: std::fmt::Display>(e: E) -> std::io::Error {
    std::io::Error::new(std::io::ErrorKind::Other, e.to_string())
}

-    /// The shared writer handle — used by the A2 drain thread to answer DSR

-    /// through the same serialized writer the caller injects input through.

-    pub(crate) fn shared_writer(&self) -> SharedWriter {


A1 builds clean now. Write A1 tests — resize-under-load (cross-OS, runs here on Windows) + unix-gated capture.


Write

//! A1 regression tests for the native-PTY [`SessionSurface`] backend.
//!
//! Two guarantees:
//!  - **resize-under-load** does not leak/hang/panic (Spike #5) — runs on both
//!    OSes; the master lock must serialize a resize storm against itself.
//!  - **output is captured** off a real PTY — Unix-gated here (the `forkpty` raw
//!    pipe EOFs cleanly); the Windows/ConPTY capture path needs the A2 DSR
//!    auto-answer and is proven by that task's regression test.

use std::sync::Arc;
use std::thread;

use spt_term::{PtySession, SessionSurface, SurfaceSize};

/// A long-lived child, OS-appropriate, that stays alive while we hammer it.
fn long_lived() -> PtySession {
    #[cfg(unix)]
    let s = PtySession::spawn_program("sleep", ["30"], SurfaceSize::new(24, 80));
    #[cfg(windows)]
    let s = PtySession::spawn_program(
        "ping",
        ["-n", "30", "127.0.0.1"],
        SurfaceSize::new(24, 80),
    );
    s.expect("spawn long-lived child under a pty")
}

// [unit->REQ-TERM-1]
#[test]
fn resize_under_load_does_not_hang_or_panic() {
    let session = Arc::new(long_lived());
    assert!(session.process_id().is_some(), "a real PTY child has a pid");

    // Three concurrent resizers contend the master lock: the storm must
    // serialize cleanly (no panic, no error, no deadlock).
    let mut handles = Vec::new();
    for t in 0..3u16 {
        let s = Arc::clone(&session);
        handles.push(thread::spawn(move || {
            for i in 0..150u16 {
                let rows = 10 + ((t * 7 + i) % 40);
                let cols = 40 + ((t * 13 + i) % 80);
                s.resize(SurfaceSize::new(rows, cols))
                    .expect("resize under contention must not fail");
            }
        }));
    }
    for h in handles {
        h.join().expect("resizer thread must not panic");
    }

    // Surface still usable after the storm; tear the child down cleanly.
    session
        .resize(SurfaceSize::new(24, 80))
        .expect("post-storm resize");
    session.kill().expect("kill child");
    let _ = session.wait().expect("reap child after kill");
}

// [unit->REQ-TERM-1]
#[cfg(unix)]
#[test]
fn captures_child_output_off_the_pty() {
    use std::io::Read;

    let session = PtySession::spawn_program(
        "sh",
        ["-c", "printf 'hello-surface\\n'"],
        SurfaceSize::new(24, 80),
    )
    .expect("spawn echo child");

    let mut reader = session.take_reader().expect("take the master reader");
    // Second take is rejected — output has exactly one consumer.
    assert!(session.take_reader().is_err(), "reader is once-only");

    let mut out = Vec::new();
    reader.read_to_end(&mut out).expect("read child output to EOF");
    let text = String::from_utf8_lossy(&out);
    assert!(
        text.contains("hello-surface"),
        "expected child stdout in the captured stream, got: {text:?}"
    );

    let _ = session.wait().expect("reap child");
}


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-term\tests\surface.rs (file state is current in your context — no need to Read it back)

//! A1 regression tests for the native-PTY [`SessionSurface`] backend.
//!
//! Two guarantees:
//!  - **resize-under-load** does not leak/hang/panic (Spike #5) — runs on both
//!    OSes; the master lock must serialize a resize storm against itself.
//!  - **output is captured** off a real PTY — Unix-gated here (the `forkpty` raw
//!    pipe EOFs cleanly); the Windows/ConPTY capture path needs the A2 DSR
//!    auto-answer and is proven by that task's regression test.

use std::sync::Arc;
use std::thread;

use spt_term::{PtySession, SessionSurface, SurfaceSize};

/// A long-lived child, OS-appropriate, that stays alive while we hammer it.
fn long_lived() -> PtySession {
    #[cfg(unix)]
    let s = PtySession::spawn_program("sleep", ["30"], SurfaceSize::new(24, 80));
    #[cfg(windows)]
    let s = PtySession::spawn_program(
        "ping",
        ["-n", "30", "127.0.0.1"],
        SurfaceSize::new(24, 80),
    );
    s.expect("spawn long-lived child under a pty")
}

// [unit->REQ-TERM-1]
#[test]
fn resize_under_load_does_not_hang_or_panic() {
    let session = Arc::new(long_lived());
    assert!(session.process_id().is_some(), "a real PTY child has a pid");

    // Three concurrent resizers contend the master lock: the storm must
    // serialize cleanly (no panic, no error, no deadlock).
    let mut handles = Vec::new();
    for t in 0..3u16 {
        let s = Arc::clone(&session);
        handles.push(thread::spawn(move || {
            for i in 0..150u16 {
                let rows = 10 + ((t * 7 + i) % 40);
                let cols = 40 + ((t * 13 + i) % 80);
                s.resize(SurfaceSize::new(rows, cols))
                    .expect("resize under contention must not fail");
            }
        }));
    }
    for h in handles {
        h.join().expect("resizer thread must not panic");
    }

    // Surface still usable after the storm; tear the child down cleanly.
    session
        .resize(SurfaceSize::new(24, 80))
        .expect("post-storm resize");
    session.kill().expect("kill child");
    let _ = session.wait().expect("reap child after kill");
}

// [unit->REQ-TERM-1]
#[cfg(unix)]
#[test]
fn captures_child_output_off_the_pty() {
    use std::io::Read;

    let session = PtySession::spawn_program(
        "sh",
        ["-c", "printf 'hello-surface\\n'"],
        SurfaceSize::new(24, 80),
    )
    .expect("spawn echo child");

    let mut reader = session.take_reader().expect("take the master reader");
    // Second take is rejected — output has exactly one consumer.
    assert!(session.take_reader().is_err(), "reader is once-only");

    let mut out = Vec::new();
    reader.read_to_end(&mut out).expect("read child output to EOF");
    let text = String::from_utf8_lossy(&out);
    assert!(
        text.contains("hello-surface"),
        "expected child stdout in the captured stream, got: {text:?}"
    );

    let _ = session.wait().expect("reap child");
}


Bash

Run A1 tests on Windows (ConPTY)


   Compiling spt-term v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.14s
     Running unittests src\lib.rs (target\debug\deps\spt_term-48be9b7ca1471aa6.exe)

running 0 tests

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

     Running tests\surface.rs (target\debug\deps\surface-2c41d505ae7ef51e.exe)

running 1 test
test resize_under_load_does_not_hang_or_panic ... FAILED

failures:

---- resize_under_load_does_not_hang_or_panic stdout ----

thread 'resize_under_load_does_not_hang_or_panic' (60368) panicked at crates\spt-term\tests\surface.rs:56:20:
kill child: Io(Os { code: 6, kind: Uncategorized, message: "The handle is invalid." })
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures:
    resize_under_load_does_not_hang_or_panic

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

error: test failed, to rerun pass `-p spt-term --test surface`


Resize storm passed; kill failed with invalid-handle. Check portable-pty Windows kill/clone_killer impl.


PP=/c/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/portable-pty-0.8.1/src; grep -rn "clone_killer\|fn kill\|InvalidHandle\|struct.*Killer\|TerminateProcess\|process_id\|fn try_wait\|fn wait" "$PP/win/conpty.rs" 2>/dev/null | head -40; echo "===files==="; ls "$PP/win"

Inspect ConPTY child kill impl


===files===
conpty.rs
mod.rs
procthreadattr.rs
psuedocon.rs


PP=/c/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/portable-pty-0.8.1/src; grep -rn "clone_killer\|fn kill\|struct.*Killer\|TerminateProcess\|fn try_wait\|fn wait\|WaitForSingleObject\|fn process_id" "$PP/win/mod.rs" | head -40

Find child kill in win/mod.rs


11:use winapi::um::synchapi::WaitForSingleObject;
43:        let res = unsafe { TerminateProcess(proc.as_raw_handle() as _, 1) };
54:    fn kill(&mut self) -> IoResult<()> {
59:    fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
66:pub struct WinChildKiller {
71:    fn kill(&mut self) -> IoResult<()> {
72:        let res = unsafe { TerminateProcess(self.proc.as_raw_handle() as _, 1) };
81:    fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
88:    fn try_wait(&mut self) -> IoResult<Option<ExitStatus>> {
92:    fn wait(&mut self) -> IoResult<ExitStatus> {
98:            WaitForSingleObject(proc.as_raw_handle() as _, INFINITE);
109:    fn process_id(&self) -> Option<u32> {
141:                        WaitForSingleObject(handle.0 as _, INFINITE);


30	        if res != 0 {
31	            if status == STILL_ACTIVE {
32	                Ok(None)
33	            } else {
34	                Ok(Some(ExitStatus::with_exit_code(status)))
35	            }
36	        } else {
37	            Ok(None)
38	        }
39	    }
40
41	    fn do_kill(&mut self) -> IoResult<()> {
42	        let proc = self.proc.lock().unwrap().try_clone().unwrap();
43	        let res = unsafe { TerminateProcess(proc.as_raw_handle() as _, 1) };
44	        let err = IoError::last_os_error();
45	        if res != 0 {
46	            Err(err)
47	        } else {
48	            Ok(())
49	        }
50	    }
51	}
52
53	impl ChildKiller for WinChild {
54	    fn kill(&mut self) -> IoResult<()> {
55	        self.do_kill().ok();
56	        Ok(())
57	    }
58
59	    fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
60	        let proc = self.proc.lock().unwrap().try_clone().unwrap();
61	        Box::new(WinChildKiller { proc })
62	    }
63	}
64
65	#[derive(Debug)]
66	pub struct WinChildKiller {
67	    proc: OwnedHandle,
68	}
69
70	impl ChildKiller for WinChildKiller {
71	    fn kill(&mut self) -> IoResult<()> {
72	        let res = unsafe { TerminateProcess(self.proc.as_raw_handle() as _, 1) };
73	        let err = IoError::last_os_error();
74	        if res != 0 {
75	            Err(err)
76	        } else {
77	            Ok(())
78	        }
79	    }
80
81	    fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
82	        let proc = self.proc.try_clone().unwrap();
83	        Box::new(WinChildKiller { proc })
84	    }
85	}
86
87	impl Child for WinChild {
88	    fn try_wait(&mut self) -> IoResult<Option<ExitStatus>> {
89	        self.is_complete()
90	    }
91
92	    fn wait(&mut self) -> IoResult<ExitStatus> {
93	        if let Ok(Some(status)) = self.try_wait() {
94	            return Ok(status);
95	        }
96	        let proc = self.proc.lock().unwrap().try_clone().unwrap();
97	        unsafe {
98	            WaitForSingleObject(proc.as_raw_handle() as _, INFINITE);
99	        }
100	        let mut status: DWORD = 0;
101	        let res = unsafe { GetExitCodeProcess(proc.as_raw_handle() as _, &mut status) };
102	        if res != 0 {
103	            Ok(ExitStatus::with_exit_code(status))
104	        } else {
105	            Err(IoError::last_os_error())
106	        }
107	    }
108
109	    fn process_id(&self) -> Option<u32> {
110	        let res = unsafe { GetProcessId(self.proc.lock().unwrap().as_raw_handle() as _) };
111	        if res == 0 {
112	            None
113	        } else {
114	            Some(res)
115	        }
116	    }
117
118	    fn as_raw_handle(&self) -> Option<std::os::windows::io::RawHandle> {
119	        let proc = self.proc.lock().unwrap();
120	        Some(proc.as_raw_handle())
121	    }
122	}
123
124	impl std::future::Future for WinChild {

        if res != 0 {
            if status == STILL_ACTIVE {
                Ok(None)
            } else {
                Ok(Some(ExitStatus::with_exit_code(status)))
            }
        } else {
            Ok(None)
        }
    }

    fn do_kill(&mut self) -> IoResult<()> {
        let proc = self.proc.lock().unwrap().try_clone().unwrap();
        let res = unsafe { TerminateProcess(proc.as_raw_handle() as _, 1) };
        let err = IoError::last_os_error();
        if res != 0 {
            Err(err)
        } else {
            Ok(())
        }
    }
}

impl ChildKiller for WinChild {
    fn kill(&mut self) -> IoResult<()> {
        self.do_kill().ok();
        Ok(())
    }

    fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
        let proc = self.proc.lock().unwrap().try_clone().unwrap();
        Box::new(WinChildKiller { proc })
    }
}

#[derive(Debug)]
pub struct WinChildKiller {
    proc: OwnedHandle,
}

impl ChildKiller for WinChildKiller {
    fn kill(&mut self) -> IoResult<()> {
        let res = unsafe { TerminateProcess(self.proc.as_raw_handle() as _, 1) };
        let err = IoError::last_os_error();
        if res != 0 {
            Err(err)
        } else {
            Ok(())
        }
    }

    fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
        let proc = self.proc.try_clone().unwrap();
        Box::new(WinChildKiller { proc })
    }
}

impl Child for WinChild {
    fn try_wait(&mut self) -> IoResult<Option<ExitStatus>> {
        self.is_complete()
    }

    fn wait(&mut self) -> IoResult<ExitStatus> {
        if let Ok(Some(status)) = self.try_wait() {
            return Ok(status);
        }
        let proc = self.proc.lock().unwrap().try_clone().unwrap();
        unsafe {
            WaitForSingleObject(proc.as_raw_handle() as _, INFINITE);
        }
        let mut status: DWORD = 0;
        let res = unsafe { GetExitCodeProcess(proc.as_raw_handle() as _, &mut status) };
        if res != 0 {
            Ok(ExitStatus::with_exit_code(status))
        } else {
            Err(IoError::last_os_error())
        }
    }

    fn process_id(&self) -> Option<u32> {
        let res = unsafe { GetProcessId(self.proc.lock().unwrap().as_raw_handle() as _) };
        if res == 0 {
            None
        } else {
            Some(res)
        }
    }

    fn as_raw_handle(&self) -> Option<std::os::windows::io::RawHandle> {
        let proc = self.proc.lock().unwrap();
        Some(proc.as_raw_handle())
    }
}

impl std::future::Future for WinChild {


Found it: portable-pty 0.8's `WinChildKiller::kill` has an inverted success check — it returns `last_os_error` even when `TerminateProcess` succeeds. `WinChild::kill` swallows it; the cloned killer doesn't. Make our `kill()` OS-aware.


Edit

    /// Terminate the child. Safe to call from another thread while `wait`
    /// blocks (uses the split `ChildKiller`).
    pub fn kill(&self) -> Result<(), SurfaceError> {
        self.killer.lock().unwrap().kill().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)
        }
    }


    /// 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)
        }
    }

//! Native-PTY session backend — ConPTY on Windows 10+, `forkpty(3)` on Unix,
//! both via `portable-pty` (the exact crate + handoff shape Spikes #1 / #4
//! proved).
//!
//! [`PtySession`] spawns a child under a real PTY, owns the master reader and
//! writer, and serves the [`SessionSurface`] contract. It is **single-surface
//! per handle** — one child's PTY, not the multiplexed fleet (that supervisor is
//! the M3b broker).
//!
//! ## Concurrency discipline (Spike #5, binding)
//!
//! `resize()` races the reader thread on Windows; the master is therefore held
//! behind a `Mutex` and every `resize` takes that lock. The writer is a shared
//! `Arc<Mutex<…>>` so the A2 DSR drain thread can answer ConPTY's startup
//! cursor query through the same serialized writer the caller injects input
//! through — no torn writes.

use std::io::{Read, Write};
use std::sync::{Arc, Mutex};

use portable_pty::{
    native_pty_system, Child, ChildKiller, CommandBuilder, ExitStatus, MasterPty,
};

use crate::surface::{SessionSurface, SurfaceError, SurfaceSize};

/// Shared, serialized handle to the PTY master writer. Cloned to the A2 drain
/// thread so the DSR auto-answer and caller input never interleave mid-write.
pub(crate) type SharedWriter = Arc<Mutex<Box<dyn Write + Send>>>;

/// A child process running under a native PTY, exposed as a [`SessionSurface`].
///
/// Build one with [`PtySession::spawn`]; drive input via [`SessionSurface::write_input`]
/// (or the A3 `send_keys`/`send_line` helpers), drain output via
/// [`PtySession::take_reader`], and observe lifecycle via [`PtySession::try_wait`] /
/// [`PtySession::wait`] / [`PtySession::kill`].
pub struct PtySession {
    /// The master end, behind a lock so `resize` never races the reader (Spike #5).
    master: Mutex<Box<dyn MasterPty + Send>>,
    /// The single PTY writer, shared so the A2 drain thread can answer DSR.
    writer: SharedWriter,
    /// The spawned child; behind a lock for `wait`/`try_wait`.
    child: Mutex<Box<dyn Child + Send + Sync>>,
    /// A kill handle split from the child so we can terminate from another
    /// thread even while `wait` blocks (`portable-pty` `ChildKiller`).
    killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
    /// The master reader, taken at most once (`take_reader`).
    reader: Mutex<Option<Box<dyn Read + Send>>>,
    /// The child OS pid, captured at spawn.
    pid: Option<u32>,
}

impl PtySession {
    /// Spawn `cmd` under a fresh native PTY sized to `size`.
    ///
    /// The slave end is dropped immediately after the child is spawned: the
    /// child holds the only remaining slave handle, which lets EOF propagate on
    /// the Unix raw-pipe backend once the child exits. (On Windows the no-EOF
    /// behaviour is governed by the writer, handled by the A2 drain discipline.)
    //
    // [impl->REQ-TERM-1]
    pub fn spawn(cmd: CommandBuilder, size: SurfaceSize) -> Result<Self, SurfaceError> {
        let pty_system = native_pty_system();
        let pair = pty_system
            .openpty(size.into())
            .map_err(|e| SurfaceError::OpenPty(e.to_string()))?;

        let child = pair
            .slave
            .spawn_command(cmd)
            .map_err(|e| SurfaceError::Spawn(e.to_string()))?;
        // Drop the slave so the child owns the only slave-side handle.
        drop(pair.slave);

        let reader = pair
            .master
            .try_clone_reader()
            .map_err(|e| SurfaceError::Io(io_other(e)))?;
        let writer = pair
            .master
            .take_writer()
            .map_err(|e| SurfaceError::Io(io_other(e)))?;
        let killer = child.clone_killer();
        let pid = child.process_id();

        Ok(Self {
            master: Mutex::new(pair.master),
            writer: Arc::new(Mutex::new(writer)),
            child: Mutex::new(child),
            killer: Mutex::new(killer),
            reader: Mutex::new(Some(reader)),
            pid,
        })
    }

    /// 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)
    }

    /// 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`).
    pub fn kill(&self) -> Result<(), SurfaceError> {
        self.killer.lock().unwrap().kill().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)))
    }
}

/// Fold a `portable-pty` error (a private `anyhow::Error` alias) into a
/// `std::io::Error` so the crate's public surface never leaks `anyhow`. Generic
/// over `Display` to avoid naming the un-exported error type.
fn io_other<E: std::fmt::Display>(e: E) -> std::io::Error {
    std::io::Error::new(std::io::ErrorKind::Other, e.to_string())
}

-    /// blocks (uses the split `ChildKiller`).

+    /// blocks (uses the split `ChildKiller`). Confirm the reap via

+    /// [`PtySession::wait`] / [`PtySession::try_wait`].

-        self.killer.lock().unwrap().kill().map_err(SurfaceError::Io)

+        let res = self.killer.lock().unwrap().kill();

+            // 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.

+            res.map_err(SurfaceError::Io)


Bash

Re-run A1 tests on Windows


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

     Running tests\surface.rs (target\debug\deps\surface-2c41d505ae7ef51e.exe)

running 1 test
test resize_under_load_does_not_hang_or_panic ... ok

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

   Doc-tests spt_term

running 0 tests

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


A1 green on Windows (ConPTY). Activate REQ-TERM-1, clippy, then validate Unix path on gravity-linux.


Edit

id = "REQ-TERM-1"
title = "Process-supervisor terminal wrapper hosting broker PTYs"
required_stages = []

id = "REQ-TERM-1"
title = "Process-supervisor terminal wrapper hosting broker PTYs"
required_stages = ["impl", "unit"]


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

id = "REQ-TERM-1"
title = "Process-supervisor terminal wrapper hosting broker PTYs"
required_stages = []

id = "REQ-TERM-1"
title = "Process-supervisor terminal wrapper hosting broker PTYs"
required_stages = ["impl", "unit"]

title = "session-surface abstraction; send-keys + send-line injection"


Bash

cargo clippy -p spt-term --all-targets -- -D warnings 2>&1 | tail -15; echo "===CHECK==="; /c/Users/decid/AppData/Local/Programs/traceable-reqs/traceable-reqs check 2>&1 | grep -E "REQ-TERM-1|FAIL|MISSING|error" | head

Clippy spt-term + traceable check REQ-TERM-1


185 |     std::io::Error::new(std::io::ErrorKind::Other, e.to_string())
    |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    |
    = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.93.0/index.html#io_other_error
    = note: `-D clippy::io-other-error` implied by `-D warnings`
    = help: to override `-D warnings` add `#[allow(clippy::io_other_error)]`
help: use `std::io::Error::other`
    |
185 -     std::io::Error::new(std::io::ErrorKind::Other, e.to_string())
185 +     std::io::Error::other(e.to_string())
    |

error: could not compile `spt-term` (lib) due to 1 previous error
warning: build failed, waiting for other jobs to finish...
error: could not compile `spt-term` (lib test) due to 1 previous error
===CHECK===
  [OK] REQ-TERM-1  required: [impl, unit]  stages: -doc +impl +unit -int


Clippy: use `Error::other`. Fix.


Edit

fn io_other<E: std::fmt::Display>(e: E) -> std::io::Error {
    std::io::Error::new(std::io::ErrorKind::Other, e.to_string())
}

fn io_other<E: std::fmt::Display>(e: E) -> std::io::Error {
    std::io::Error::other(e.to_string())
}


fn io_other<E: std::fmt::Display>(e: E) -> std::io::Error {
    std::io::Error::other(e.to_string())
}

//! Native-PTY session backend — ConPTY on Windows 10+, `forkpty(3)` on Unix,
//! both via `portable-pty` (the exact crate + handoff shape Spikes #1 / #4
//! proved).
//!
//! [`PtySession`] spawns a child under a real PTY, owns the master reader and
//! writer, and serves the [`SessionSurface`] contract. It is **single-surface
//! per handle** — one child's PTY, not the multiplexed fleet (that supervisor is
//! the M3b broker).
//!
//! ## Concurrency discipline (Spike #5, binding)
//!
//! `resize()` races the reader thread on Windows; the master is therefore held
//! behind a `Mutex` and every `resize` takes that lock. The writer is a shared
//! `Arc<Mutex<…>>` so the A2 DSR drain thread can answer ConPTY's startup
//! cursor query through the same serialized writer the caller injects input
//! through — no torn writes.

use std::io::{Read, Write};
use std::sync::{Arc, Mutex};

use portable_pty::{
    native_pty_system, Child, ChildKiller, CommandBuilder, ExitStatus, MasterPty,
};

use crate::surface::{SessionSurface, SurfaceError, SurfaceSize};

/// Shared, serialized handle to the PTY master writer. Cloned to the A2 drain
/// thread so the DSR auto-answer and caller input never interleave mid-write.
pub(crate) type SharedWriter = Arc<Mutex<Box<dyn Write + Send>>>;

/// A child process running under a native PTY, exposed as a [`SessionSurface`].
///
/// Build one with [`PtySession::spawn`]; drive input via [`SessionSurface::write_input`]
/// (or the A3 `send_keys`/`send_line` helpers), drain output via
/// [`PtySession::take_reader`], and observe lifecycle via [`PtySession::try_wait`] /
/// [`PtySession::wait`] / [`PtySession::kill`].
pub struct PtySession {
    /// The master end, behind a lock so `resize` never races the reader (Spike #5).
    master: Mutex<Box<dyn MasterPty + Send>>,
    /// The single PTY writer, shared so the A2 drain thread can answer DSR.
    writer: SharedWriter,
    /// The spawned child; behind a lock for `wait`/`try_wait`.
    child: Mutex<Box<dyn Child + Send + Sync>>,
    /// A kill handle split from the child so we can terminate from another
    /// thread even while `wait` blocks (`portable-pty` `ChildKiller`).
    killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
    /// The master reader, taken at most once (`take_reader`).
    reader: Mutex<Option<Box<dyn Read + Send>>>,
    /// The child OS pid, captured at spawn.
    pid: Option<u32>,
}

impl PtySession {
    /// Spawn `cmd` under a fresh native PTY sized to `size`.
    ///
    /// The slave end is dropped immediately after the child is spawned: the
    /// child holds the only remaining slave handle, which lets EOF propagate on
    /// the Unix raw-pipe backend once the child exits. (On Windows the no-EOF
    /// behaviour is governed by the writer, handled by the A2 drain discipline.)
    //
    // [impl->REQ-TERM-1]
    pub fn spawn(cmd: CommandBuilder, size: SurfaceSize) -> Result<Self, SurfaceError> {
        let pty_system = native_pty_system();
        let pair = pty_system
            .openpty(size.into())
            .map_err(|e| SurfaceError::OpenPty(e.to_string()))?;

        let child = pair
            .slave
            .spawn_command(cmd)
            .map_err(|e| SurfaceError::Spawn(e.to_string()))?;
        // Drop the slave so the child owns the only slave-side handle.
        drop(pair.slave);

        let reader = pair
            .master
            .try_clone_reader()
            .map_err(|e| SurfaceError::Io(io_other(e)))?;
        let writer = pair
            .master
            .take_writer()
            .map_err(|e| SurfaceError::Io(io_other(e)))?;
        let killer = child.clone_killer();
        let pid = child.process_id();

        Ok(Self {
            master: Mutex::new(pair.master),
            writer: Arc::new(Mutex::new(writer)),
            child: Mutex::new(child),
            killer: Mutex::new(killer),
            reader: Mutex::new(Some(reader)),
            pid,
        })
    }

    /// 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)
    }

    /// 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)))
    }
}

/// Fold a `portable-pty` error (a private `anyhow::Error` alias) into a
/// `std::io::Error` so the crate's public surface never leaks `anyhow`. Generic
/// over `Display` to avoid naming the un-exported error type.
fn io_other<E: std::fmt::Display>(e: E) -> std::io::Error {
    std::io::Error::new(std::io::ErrorKind::Other, e.to_string())
}

-    std::io::Error::new(std::io::ErrorKind::Other, e.to_string())

+    std::io::Error::other(e.to_string())


Bash

cargo clippy -p spt-term --all-targets -- -D warnings 2>&1 | tail -6

Re-run clippy


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


Clippy clean. Let me validate the forkpty path on gravity-linux before committing A1. Check reachability.


cat > /tmp/askpass.sh <<'EOF'
#!/bin/sh
echo bigscreen
EOF
chmod +x /tmp/askpass.sh
SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no -o ConnectTimeout=8 david@gravity-linux 'echo OK; uname -a; which cargo rustc rsync' < /dev/null 2>&1 | head

Test gravity-linux ssh connectivity


OK
Linux gravity-linux 6.8.0-117-generic #117~22.04.1-Ubuntu SMP PREEMPT_DYNAMIC Thu May  7 22:17:46 UTC  x86_64 x86_64 x86_64 GNU/Linux
/usr/bin/rsync


Reachable. rsync the crate workspace (sans target) and run the forkpty path on Linux.


\
SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force \
rsync -az --delete \
  --exclude target --exclude .git \
  -e "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
  ./ david@gravity-linux:~/spt-core-sync/ 2>&1 | tail -5; echo "RSYNC_EXIT=${PIPESTATUS[0]}"

rsync workspace to gravity-linux


/usr/bin/bash: line 10: rsync: command not found
RSYNC_EXIT=127


No local rsync. Pipe a tarball over ssh instead.


\
tar czf - --exclude=target --exclude=.git . 2>/dev/null | \
SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force \
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 david@gravity-linux \
  'rm -rf ~/spt-core-sync && mkdir -p ~/spt-core-sync && tar xzf - -C ~/spt-core-sync && echo EXTRACTED && ls ~/spt-core-sync/crates' < /dev/null 2>&1 | tail

Tar workspace over ssh to gravity-linux


gzip: stdin: unexpected end of file
tar: Child returned status 1
tar: Error is not recoverable: exiting now


\
tar czf /tmp/spt-sync.tgz --exclude=target --exclude=.git . 2>/dev/null && ls -la /tmp/spt-sync.tgz && \
SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force \
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 david@gravity-linux \
  'rm -rf ~/spt-core-sync && mkdir -p ~/spt-core-sync && cat > /tmp/spt-sync.tgz' < /tmp/spt-sync.tgz 2>&1 | tail -3; echo "done piping"

Create tarball, pipe to gravity-linux


-rw-r--r-- 1 decid 197609 277887 Jun  2 01:51 /tmp/spt-sync.tgz
done piping


SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force \
ssh -o StrictHostKeyChecking=no david@gravity-linux \
  'tar xzf /tmp/spt-sync.tgz -C ~/spt-core-sync && export PATH=$HOME/.cargo/bin:$PATH && cd ~/spt-core-sync && echo "=== TEST spt-term ===" && cargo test -p spt-term 2>&1 | tail -30' < /dev/null 2>&1 | tail -40

Extract + cargo test spt-term on Linux


=== TEST spt-term ===
   Compiling downcast-rs v1.2.1
   Compiling shell-words v1.1.1
   Compiling fastrand v2.4.1
   Compiling rusqlite v0.32.1
   Compiling spt-store v0.0.0 (/home/david/spt-core-sync/crates/spt-store)
   Compiling spt-msg v0.0.0 (/home/david/spt-core-sync/crates/spt-msg)
   Compiling tempfile v3.27.0
   Compiling portable-pty v0.8.1
   Compiling spt-term v0.0.0 (/home/david/spt-core-sync/crates/spt-term)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 25.66s
     Running unittests src/lib.rs (target/debug/deps/spt_term-c10689f35e280344)

running 0 tests

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

     Running tests/surface.rs (target/debug/deps/surface-423800a3dc2e08e6)

running 2 tests
test captures_child_output_off_the_pty ... ok
test resize_under_load_does_not_hang_or_panic ... ok

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

   Doc-tests spt_term

running 0 tests

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


Both pass on Linux (forkpty) — capture + resize. A1 validated cross-OS. Commit.


cargo build --workspace 2>&1 | tail -3 && git add -A && git commit -q -m "feat(term): SessionSurface trait + native PTY backend (M3a A1)

The load-bearing session-surface mechanism: PtySession spawns a child
under a real PTY — ConPTY on Windows, forkpty on Unix, via portable-pty
(the Spike #1/#4 handoff shape) — and serves the OS-neutral
SessionSurface contract (write_input + resize). Master held behind a
lock so resize never races the reader (Spike #5); writer shared so the
A2 DSR drain can answer through the same serialized handle; reader
taken once; child wait/try_wait/kill with a split ChildKiller.

OS-neutrality (Spike #4/#5) is honored: the trait exposes only
input+resize and says nothing about raw-pipe vs screen-buffer stream
semantics. Worked around portable-pty 0.8's inverted Windows
ChildKiller success check (returns spurious last_os_error on a
successful TerminateProcess) — kill is best-effort on Windows, confirmed
by reaping.

Tests (regression): resize-under-load (3-thread resize storm, no
leak/hang/panic) runs cross-OS; output-capture is Unix-gated here
(forkpty raw pipe EOFs cleanly) — the ConPTY capture path lands with A2.
Validated green on BOTH Windows (ConPTY, local) and Linux (forkpty,
gravity-linux). Activates REQ-TERM-1 (impl, unit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>" && git log --oneline -1

Build workspace + commit A1


   Compiling spt-live v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-live)
   Compiling spt v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.13s
warning: in the working copy of 'crates/spt-term/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-term/src/pty.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-term/src/surface.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-term/tests/surface.rs', LF will be replaced by CRLF the next time Git touches it
4a00304 feat(term): SessionSurface trait + native PTY backend (M3a A1)


A2 next: ConPTY-DSR auto-answer drain thread. Find the Spike #1 mechanism to copy.


grep -rln "6n\|1;1R\|DSR\|ESC\[6n\|cursor.position\|drain" --include=*.rs --include=*.md . 2>/dev/null | grep -iv "spt-term" | head; echo "===spike dirs==="; find . -type d -iname "*spike*" 2>/dev/null; ls docs 2>/dev/null; find . -iname "*spike*" -name "*.md" 2>/dev/null | head

Locate Spike #1 DSR code/notes


./CONTEXT.md
./crates/spt/src/api/delivery.rs
./crates/spt/src/api/startup.rs
./crates/spt/src/api/worker.rs
./crates/spt/src/cli.rs
./crates/spt/tests/contract_e2e.rs
./crates/spt-msg/src/deliver.rs
./crates/spt-msg/src/ready.rs
./crates/spt-msg/src/ring.rs
./crates/spt-msg/tests/killer_quickstart.rs
===spike dirs===
./docs/spikes
CONTEXT-MEMORY.md
DEFERRED.md
DOCS-STRATEGY.md
KNOWN-HAZARDS.md
MANIFEST.md
STORAGE.md
TRACEABILITY.md
adr
reviews
spikes
./docs/spikes/SPIKE-01-broker-handoff.md
./docs/spikes/SPIKE-02-iroh-smoke.md
./docs/spikes/SPIKE-03-quic-survival.md
./docs/spikes/SPIKE-04-forkpty-parity.md
./docs/spikes/SPIKE-05-restart-stress.md
./docs/spikes/SPIKE-06-idempotent-boundary.md


grep -n "6n\|1;1R\|DSR\|drain\|ESC\|0x1b\|\\\\x1b\|blocking\|EOF\|writer" docs/spikes/SPIKE-01-broker-handoff.md | head -40

DSR refs in Spike #1 doc