Write

//! A3 regression tests for input injection (`send_keys` / `send_line`).
//!
//! An echo child (`cat` on Unix, `findstr "."` on Windows) reads stdin and
//! prints each line back, so injected input round-trips through the real PTY and
//! into the drained output stream. Ordering is asserted by index; `Ctrl-C`
//! (`0x03`) must interrupt the child.

use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

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

/// An echo child: reads stdin, writes each line to stdout, OS-appropriate.
fn echo_child() -> PtySession {
    #[cfg(unix)]
    let s = PtySession::spawn_program("cat", Vec::<String>::new(), SurfaceSize::new(24, 80));
    #[cfg(windows)]
    let s = PtySession::spawn_program("findstr", ["."], SurfaceSize::new(24, 80));
    s.expect("spawn echo child under a pty")
}

fn drain_into(session: &PtySession) -> (Drain, Arc<Mutex<Vec<u8>>>) {
    let collected = Arc::new(Mutex::new(Vec::new()));
    let sink = Arc::clone(&collected);
    let drain = session
        .drain(move |chunk| sink.lock().unwrap().extend_from_slice(chunk))
        .expect("start drain");
    (drain, collected)
}

fn text(collected: &Arc<Mutex<Vec<u8>>>) -> String {
    String::from_utf8_lossy(&collected.lock().unwrap()).into_owned()
}

fn wait_until<F: Fn(&str) -> bool>(collected: &Arc<Mutex<Vec<u8>>>, pred: F) -> bool {
    for _ in 0..500 {
        if pred(&text(collected)) {
            return true;
        }
        thread::sleep(Duration::from_millis(10));
    }
    false
}

// [int->REQ-TERM-2]
#[test]
fn send_line_reaches_child_and_preserves_order() {
    let session = echo_child();
    let (drain, collected) = drain_into(&session);

    session.send_line("INJECT-ALPHA").expect("send first line");
    session.send_line("INJECT-BRAVO").expect("send second line");

    let ok = wait_until(&collected, |t| {
        t.contains("INJECT-ALPHA") && t.contains("INJECT-BRAVO")
    });

    // Tear down before asserting so a failure never leaves the child running.
    let _ = session.send_keys(&[0x03]);
    drain.request_stop();
    let _ = session.kill();

    let t = text(&collected);
    assert!(ok, "both injected lines must round-trip through the child: {t:?}");
    let a = t.find("INJECT-ALPHA").unwrap();
    let b = t.find("INJECT-BRAVO").unwrap();
    assert!(a < b, "injection order must be preserved (alpha before bravo): {t:?}");
}

// [int->REQ-TERM-2]
#[test]
fn ctrl_c_interrupts_the_child() {
    let session = echo_child();
    let (drain, _collected) = drain_into(&session);
    assert!(
        session.try_wait().expect("poll").is_none(),
        "echo child should be alive, blocked on stdin"
    );

    // Ctrl-C as a raw keystroke: SIGINT (unix) / CTRL_C_EVENT (windows).
    session.send_keys(&[0x03]).expect("inject Ctrl-C");

    let mut exited = false;
    for _ in 0..500 {
        if session.try_wait().expect("poll").is_some() {
            exited = true;
            break;
        }
        thread::sleep(Duration::from_millis(10));
    }
    drain.request_stop();
    let _ = session.kill();
    assert!(exited, "Ctrl-C must interrupt the child");
}


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

//! A3 regression tests for input injection (`send_keys` / `send_line`).
//!
//! An echo child (`cat` on Unix, `findstr "."` on Windows) reads stdin and
//! prints each line back, so injected input round-trips through the real PTY and
//! into the drained output stream. Ordering is asserted by index; `Ctrl-C`
//! (`0x03`) must interrupt the child.

use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

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

/// An echo child: reads stdin, writes each line to stdout, OS-appropriate.
fn echo_child() -> PtySession {
    #[cfg(unix)]
    let s = PtySession::spawn_program("cat", Vec::<String>::new(), SurfaceSize::new(24, 80));
    #[cfg(windows)]
    let s = PtySession::spawn_program("findstr", ["."], SurfaceSize::new(24, 80));
    s.expect("spawn echo child under a pty")
}

fn drain_into(session: &PtySession) -> (Drain, Arc<Mutex<Vec<u8>>>) {
    let collected = Arc::new(Mutex::new(Vec::new()));
    let sink = Arc::clone(&collected);
    let drain = session
        .drain(move |chunk| sink.lock().unwrap().extend_from_slice(chunk))
        .expect("start drain");
    (drain, collected)
}

fn text(collected: &Arc<Mutex<Vec<u8>>>) -> String {
    String::from_utf8_lossy(&collected.lock().unwrap()).into_owned()
}

fn wait_until<F: Fn(&str) -> bool>(collected: &Arc<Mutex<Vec<u8>>>, pred: F) -> bool {
    for _ in 0..500 {
        if pred(&text(collected)) {
            return true;
        }
        thread::sleep(Duration::from_millis(10));
    }
    false
}

// [int->REQ-TERM-2]
#[test]
fn send_line_reaches_child_and_preserves_order() {
    let session = echo_child();
    let (drain, collected) = drain_into(&session);

    session.send_line("INJECT-ALPHA").expect("send first line");
    session.send_line("INJECT-BRAVO").expect("send second line");

    let ok = wait_until(&collected, |t| {
        t.contains("INJECT-ALPHA") && t.contains("INJECT-BRAVO")
    });

    // Tear down before asserting so a failure never leaves the child running.
    let _ = session.send_keys(&[0x03]);
    drain.request_stop();
    let _ = session.kill();

    let t = text(&collected);
    assert!(ok, "both injected lines must round-trip through the child: {t:?}");
    let a = t.find("INJECT-ALPHA").unwrap();
    let b = t.find("INJECT-BRAVO").unwrap();
    assert!(a < b, "injection order must be preserved (alpha before bravo): {t:?}");
}

// [int->REQ-TERM-2]
#[test]
fn ctrl_c_interrupts_the_child() {
    let session = echo_child();
    let (drain, _collected) = drain_into(&session);
    assert!(
        session.try_wait().expect("poll").is_none(),
        "echo child should be alive, blocked on stdin"
    );

    // Ctrl-C as a raw keystroke: SIGINT (unix) / CTRL_C_EVENT (windows).
    session.send_keys(&[0x03]).expect("inject Ctrl-C");

    let mut exited = false;
    for _ in 0..500 {
        if session.try_wait().expect("poll").is_some() {
            exited = true;
            break;
        }
        thread::sleep(Duration::from_millis(10));
    }
    drain.request_stop();
    let _ = session.kill();
    assert!(exited, "Ctrl-C must interrupt the child");
}


Bash

cargo test -p spt-term --test inject 2>&1 | tail -25

Run A3 injection tests on Windows


   Compiling spt-term v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.78s
     Running tests\inject.rs (target\debug\deps\inject-25060f61de2f08e8.exe)

running 2 tests
test send_line_reaches_child_and_preserves_order ... ok
test ctrl_c_interrupts_the_child ... FAILED

failures:

---- ctrl_c_interrupts_the_child stdout ----

thread 'ctrl_c_interrupts_the_child' (34204) panicked at crates\spt-term\tests\inject.rs:94:5:
Ctrl-C must interrupt the child
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures:
    ctrl_c_interrupts_the_child

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

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


send_line round-trip + ordering pass. Ctrl-C via raw `0x03` didn't kill `findstr` on ConPTY. Probe whether ConPTY delivers Ctrl-C at all (broker needs this on Windows).


Write

use std::thread;
use std::time::{Duration, Instant};
use spt_term::{PtySession, SessionSurface, SurfaceSize};

fn try_child(label: &str, prog: &str, args: &[&str]) {
    let session = PtySession::spawn_program(prog, args, SurfaceSize::new(24, 80)).unwrap();
    let _d = session.drain(|_| {}).unwrap();
    thread::sleep(Duration::from_millis(600));
    let alive_before = session.try_wait().unwrap().is_none();
    session.send_keys(&[0x03]).unwrap();
    let start = Instant::now();
    let mut exited = false;
    while start.elapsed() < Duration::from_secs(3) {
        if session.try_wait().unwrap().is_some() {
            exited = true;
            break;
        }
        thread::sleep(Duration::from_millis(20));
    }
    println!(
        "{label}: alive_before={alive_before} exited_on_ctrlc={exited} after={:?}",
        start.elapsed()
    );
    let _ = session.kill();
}

fn main() {
    try_child("ping-t", "ping", &["-t", "127.0.0.1"]);
    try_child("findstr", "findstr", &["."]);
    // CRLF-terminated Ctrl-C variant on ping
    let session = PtySession::spawn_program("ping", &["-t", "127.0.0.1"], SurfaceSize::new(24, 80)).unwrap();
    let _d = session.drain(|_| {}).unwrap();
    thread::sleep(Duration::from_millis(600));
    session.send_keys(&[0x03, b'\r']).unwrap();
    let start = Instant::now();
    let mut exited = false;
    while start.elapsed() < Duration::from_secs(3) {
        if session.try_wait().unwrap().is_some() { exited = true; break; }
        thread::sleep(Duration::from_millis(20));
    }
    println!("ping-t (0x03 CR): exited_on_ctrlc={exited}");
    let _ = session.kill();
}


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

use std::thread;
use std::time::{Duration, Instant};
use spt_term::{PtySession, SessionSurface, SurfaceSize};

fn try_child(label: &str, prog: &str, args: &[&str]) {
    let session = PtySession::spawn_program(prog, args, SurfaceSize::new(24, 80)).unwrap();
    let _d = session.drain(|_| {}).unwrap();
    thread::sleep(Duration::from_millis(600));
    let alive_before = session.try_wait().unwrap().is_none();
    session.send_keys(&[0x03]).unwrap();
    let start = Instant::now();
    let mut exited = false;
    while start.elapsed() < Duration::from_secs(3) {
        if session.try_wait().unwrap().is_some() {
            exited = true;
            break;
        }
        thread::sleep(Duration::from_millis(20));
    }
    println!(
        "{label}: alive_before={alive_before} exited_on_ctrlc={exited} after={:?}",
        start.elapsed()
    );
    let _ = session.kill();
}

fn main() {
    try_child("ping-t", "ping", &["-t", "127.0.0.1"]);
    try_child("findstr", "findstr", &["."]);
    // CRLF-terminated Ctrl-C variant on ping
    let session = PtySession::spawn_program("ping", &["-t", "127.0.0.1"], SurfaceSize::new(24, 80)).unwrap();
    let _d = session.drain(|_| {}).unwrap();
    thread::sleep(Duration::from_millis(600));
    session.send_keys(&[0x03, b'\r']).unwrap();
    let start = Instant::now();
    let mut exited = false;
    while start.elapsed() < Duration::from_secs(3) {
        if session.try_wait().unwrap().is_some() { exited = true; break; }
        thread::sleep(Duration::from_millis(20));
    }
    println!("ping-t (0x03 CR): exited_on_ctrlc={exited}");
    let _ = session.kill();
}


Bash

cargo run -p spt-term --example ctrlc_probe 2>&1 | grep -E "ping-t|findstr|exited" | head

Probe ConPTY Ctrl-C delivery


ping-t: alive_before=true exited_on_ctrlc=false after=3.0190562s
findstr: alive_before=true exited_on_ctrlc=true after=20.4212ms
ping-t (0x03 CR): exited_on_ctrlc=false


findstr *does* honor Ctrl-C (20ms in probe) — the test raced: `0x03` arrived before the child installed its handler. Fix with a readiness handshake (inject a line, wait for echo, then Ctrl-C). Remove probe.


rm crates/spt-term/examples/ctrlc_probe.rs && rmdir crates/spt-term/examples 2>/dev/null; echo removed

Remove ctrl-c probe


removed


Edit

// [int->REQ-TERM-2]
#[test]
fn ctrl_c_interrupts_the_child() {
    let session = echo_child();
    let (drain, _collected) = drain_into(&session);
    assert!(
        session.try_wait().expect("poll").is_none(),
        "echo child should be alive, blocked on stdin"
    );

    // Ctrl-C as a raw keystroke: SIGINT (unix) / CTRL_C_EVENT (windows).
    session.send_keys(&[0x03]).expect("inject Ctrl-C");

// [int->REQ-TERM-2]
#[test]
fn ctrl_c_interrupts_the_child() {
    let session = echo_child();
    let (drain, collected) = drain_into(&session);

    // Handshake: inject a line and wait for it to echo back. This proves the
    // child is up and *reading* before we deliver Ctrl-C — otherwise the signal
    // races child startup and can be dropped before its handler is installed.
    session.send_line("READY-PROBE").expect("send readiness probe");
    let ready = wait_until(&collected, |t| t.contains("READY-PROBE"));
    assert!(ready, "echo child must come up and echo before Ctrl-C: {:?}", text(&collected));
    assert!(
        session.try_wait().expect("poll").is_none(),
        "echo child should still be alive, blocked on stdin"
    );

    // Ctrl-C as a raw keystroke: SIGINT (unix) / CTRL_C_EVENT (windows).
    session.send_keys(&[0x03]).expect("inject Ctrl-C");


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

// [int->REQ-TERM-2]
#[test]
fn ctrl_c_interrupts_the_child() {
    let session = echo_child();
    let (drain, collected) = drain_into(&session);

    // Handshake: inject a line and wait for it to echo back. This proves the
    // child is up and *reading* before we deliver Ctrl-C — otherwise the signal
    // races child startup and can be dropped before its handler is installed.
    session.send_line("READY-PROBE").expect("send readiness probe");
    let ready = wait_until(&collected, |t| t.contains("READY-PROBE"));
    assert!(ready, "echo child must come up and echo before Ctrl-C: {:?}", text(&collected));
    assert!(
        session.try_wait().expect("poll").is_none(),
        "echo child should still be alive, blocked on stdin"
    );

    // Ctrl-C as a raw keystroke: SIGINT (unix) / CTRL_C_EVENT (windows).
    session.send_keys(&[0x03]).expect("inject Ctrl-C");

//! A3 regression tests for input injection (`send_keys` / `send_line`).
//!
//! An echo child (`cat` on Unix, `findstr "."` on Windows) reads stdin and
//! prints each line back, so injected input round-trips through the real PTY and
//! into the drained output stream. Ordering is asserted by index; `Ctrl-C`
//! (`0x03`) must interrupt the child.

use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

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

/// An echo child: reads stdin, writes each line to stdout, OS-appropriate.
fn echo_child() -> PtySession {
    #[cfg(unix)]
    let s = PtySession::spawn_program("cat", Vec::<String>::new(), SurfaceSize::new(24, 80));
    #[cfg(windows)]
    let s = PtySession::spawn_program("findstr", ["."], SurfaceSize::new(24, 80));
    s.expect("spawn echo child under a pty")
}

fn drain_into(session: &PtySession) -> (Drain, Arc<Mutex<Vec<u8>>>) {
    let collected = Arc::new(Mutex::new(Vec::new()));
    let sink = Arc::clone(&collected);
    let drain = session
        .drain(move |chunk| sink.lock().unwrap().extend_from_slice(chunk))
        .expect("start drain");
    (drain, collected)
}

fn text(collected: &Arc<Mutex<Vec<u8>>>) -> String {
    String::from_utf8_lossy(&collected.lock().unwrap()).into_owned()
}

fn wait_until<F: Fn(&str) -> bool>(collected: &Arc<Mutex<Vec<u8>>>, pred: F) -> bool {
    for _ in 0..500 {
        if pred(&text(collected)) {
            return true;
        }
        thread::sleep(Duration::from_millis(10));
    }
    false
}

// [int->REQ-TERM-2]
#[test]
fn send_line_reaches_child_and_preserves_order() {
    let session = echo_child();
    let (drain, collected) = drain_into(&session);

    session.send_line("INJECT-ALPHA").expect("send first line");
    session.send_line("INJECT-BRAVO").expect("send second line");

    let ok = wait_until(&collected, |t| {
        t.contains("INJECT-ALPHA") && t.contains("INJECT-BRAVO")
    });

    // Tear down before asserting so a failure never leaves the child running.
    let _ = session.send_keys(&[0x03]);
    drain.request_stop();
    let _ = session.kill();

    let t = text(&collected);
    assert!(ok, "both injected lines must round-trip through the child: {t:?}");
    let a = t.find("INJECT-ALPHA").unwrap();
    let b = t.find("INJECT-BRAVO").unwrap();
    assert!(a < b, "injection order must be preserved (alpha before bravo): {t:?}");
}

// [int->REQ-TERM-2]
#[test]
fn ctrl_c_interrupts_the_child() {
    let session = echo_child();
    let (drain, _collected) = drain_into(&session);
    assert!(
        session.try_wait().expect("poll").is_none(),
        "echo child should be alive, blocked on stdin"
    );

    // Ctrl-C as a raw keystroke: SIGINT (unix) / CTRL_C_EVENT (windows).
    session.send_keys(&[0x03]).expect("inject Ctrl-C");

    let mut exited = false;
    for _ in 0..500 {
        if session.try_wait().expect("poll").is_some() {
            exited = true;
            break;
        }
        thread::sleep(Duration::from_millis(10));
    }
    drain.request_stop();
    let _ = session.kill();
    assert!(exited, "Ctrl-C must interrupt the child");
}

-    let (drain, _collected) = drain_into(&session);

+    let (drain, collected) = drain_into(&session);

+    // Handshake: inject a line and wait for it to echo back. This proves the

+    // child is up and *reading* before we deliver Ctrl-C — otherwise the signal

+    // races child startup and can be dropped before its handler is installed.

+    session.send_line("READY-PROBE").expect("send readiness probe");

+    let ready = wait_until(&collected, |t| t.contains("READY-PROBE"));

+    assert!(ready, "echo child must come up and echo before Ctrl-C: {:?}", text(&collected));

-        "echo child should be alive, blocked on stdin"

+        "echo child should still be alive, blocked on stdin"


Bash

cargo test -p spt-term --test inject 2>&1 | tail -12

Re-run injection tests with handshake


   Compiling spt-term v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.55s
     Running tests\inject.rs (target\debug\deps\inject-25060f61de2f08e8.exe)

running 2 tests
test ctrl_c_interrupts_the_child ... ok
test send_line_reaches_child_and_preserves_order ... ok

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


Both pass on Windows. Clippy + activate REQ-TERM-2 + validate Linux.


Edit

id = "REQ-TERM-2"
title = "session-surface abstraction; send-keys + send-line injection"
required_stages = []

id = "REQ-TERM-2"
title = "session-surface abstraction; send-keys + send-line injection"
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-2"
title = "session-surface abstraction; send-keys + send-line injection"
required_stages = []

id = "REQ-TERM-2"
title = "session-surface abstraction; send-keys + send-line injection"
required_stages = ["impl", "unit"]

title = "Byte-stream remote terminal streaming for v1"


REQ-TERM-2 needs `unit` evidence — retag the injection tests `[unit->]` (matching the A1 `tests/` convention).


Bash

sed -i 's|// \[int->REQ-TERM-2\]|// [unit->REQ-TERM-2]|g' crates/spt-term/tests/inject.rs && grep -n "REQ-TERM-2" crates/spt-term/tests/inject.rs crates/spt-term/src/surface.rs

Retag injection tests as unit


crates/spt-term/tests/inject.rs:46:// [unit->REQ-TERM-2]
crates/spt-term/tests/inject.rs:71:// [unit->REQ-TERM-2]
crates/spt-term/src/surface.rs:112:    // [impl->REQ-TERM-2]
crates/spt-term/src/surface.rs:122:    // [impl->REQ-TERM-2]


38	        if pred(&text(collected)) {
39	            return true;
40	        }
41	        thread::sleep(Duration::from_millis(10));
42	    }
43	    false
44	}
45
46	// [unit->REQ-TERM-2]
47	#[test]
48	fn send_line_reaches_child_and_preserves_order() {
49	    let session = echo_child();
50	    let (drain, collected) = drain_into(&session);
51
52	    session.send_line("INJECT-ALPHA").expect("send first line");
53	    session.send_line("INJECT-BRAVO").expect("send second line");
54
...
63
64	    let t = text(&collected);
65	    assert!(ok, "both injected lines must round-trip through the child: {t:?}");
66	    let a = t.find("INJECT-ALPHA").unwrap();
67	    let b = t.find("INJECT-BRAVO").unwrap();
68	    assert!(a < b, "injection order must be preserved (alpha before bravo): {t:?}");
69	}
70
71	// [unit->REQ-TERM-2]
72	#[test]
73	fn ctrl_c_interrupts_the_child() {
74	    let session = echo_child();
75	    let (drain, collected) = drain_into(&session);
76
77	    // Handshake: inject a line and wait for it to echo back. This proves the
78	    // child is up and *reading* before we deliver Ctrl-C — otherwise the signal
79	    // races child startup and can be dropped before its handler is installed.


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

Clippy + check REQ-TERM-2


    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.35s
===CHECK===
  [OK] REQ-TERM-2  required: [impl, unit]  stages: -doc +impl +unit -int


tar czf /tmp/spt-sync.tgz --exclude=target --exclude=.git . 2>/dev/null && \
SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux 'cat > /tmp/spt-sync.tgz' < /tmp/spt-sync.tgz 2>&1 | tail -1
SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux 'rm -rf ~/spt-core-sync && mkdir -p ~/spt-core-sync && tar xzf /tmp/spt-sync.tgz -C ~/spt-core-sync && export PATH=$HOME/.cargo/bin:$PATH && cd ~/spt-core-sync && cargo test -p spt-term --test inject 2>&1 | tail -10' < /dev/null 2>&1 | tail -12

Validate A3 injection on Linux


   Compiling spt-term v0.0.0 (/home/david/spt-core-sync/crates/spt-term)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 22.72s
     Running tests/inject.rs (target/debug/deps/inject-9085fcd9bd80914a)

running 2 tests
test send_line_reaches_child_and_preserves_order ... ok
test ctrl_c_interrupts_the_child ... ok

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


Injection green on both OSes (cat/SIGINT on Linux, findstr/CTRL_C_EVENT on Windows). Commit A3.


git add -A && git commit -q -m "feat(term): send-keys + send-line injection (M3a A3)

Input injection as first-class SessionSurface methods (default impls
over write_input, so M4's network surface inherits them): send_keys
(raw keystrokes incl. control bytes / escape sequences) and send_line
(cooked — appends CR, the Enter keypress; slave line discipline maps
CR->NL). Each is one serialized write through the shared writer mutex,
so ordering is preserved and a concurrent injector can't split a line.

Tests round-trip injected lines through a real echo child (cat on
Unix, findstr on Windows) and assert ordering; Ctrl-C (0x03) interrupts
the child (SIGINT / CTRL_C_EVENT). Found + fixed a real race: on ConPTY
a Ctrl-C sent before the child installs its handler is dropped, so the
test handshakes (inject a line, wait for echo) to prove the child is up
and reading before signalling — a discipline the M3b broker inherits.

Validated on Windows (ConPTY) + Linux (forkpty, gravity-linux).
Activates REQ-TERM-2 (impl, unit).

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

Commit A3


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 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-term/tests/inject.rs', LF will be replaced by CRLF the next time Git touches it
ad21334 feat(term): send-keys + send-line injection (M3a A3)


A0–A3 committed. Now A4: bounded byte-stream. Build `OutputStream` over a `sync_channel` so a full channel blocks the drain → backpressure to the child.


Write

//! Bounded byte-stream local streaming (M3a A4).
//!
//! [`OutputStream`] is the v1 consumer shape: the A2 drain forwards every byte
//! into a bounded `sync_channel`, and a consumer pulls chunks off the other end.
//! When the consumer falls behind, the channel fills, the drain's `send` blocks,
//! the reader stops draining, the PTY buffer fills, and the child blocks on
//! write — **end-to-end bounded backpressure, no unbounded buffer**. The off-node
//! hop (M4 transport) and the predictive "feels-local" layer ride on top of this
//! substrate later; M3a delivers the local shape only.
//!
//! **OS-neutral (Spike #4 / #5):** the stream is bytes, nothing more. It makes no
//! ordering promise beyond "every byte the reader forwarded, in the order it was
//! forwarded" — on ConPTY a repaint-on-resize legitimately reorders/duplicates
//! content, so consumers (e.g. the A5 digest) match on *value-set coverage*, not
//! byte-exact sequence.

use std::sync::mpsc::{sync_channel, Receiver};
use std::time::Duration;

use crate::pty::PtySession;
use crate::reader::Drain;
use crate::surface::SurfaceError;

/// A bounded stream of the child's output chunks. Dropping it unblocks a drain
/// that is parked on a full channel (the blocked `send` returns once the
/// receiver is gone), letting the pump wind down once the child is torn down.
pub struct OutputStream {
    rx: Receiver<Vec<u8>>,
    drain: Drain,
}

impl OutputStream {
    /// Block for the next output chunk. Returns `None` once the stream has ended
    /// (the drain thread exited and dropped its sender — i.e. the child's output
    /// reached EOF or the pump was stopped).
    pub fn recv(&self) -> Option<Vec<u8>> {
        self.rx.recv().ok()
    }

    /// Non-blocking poll for the next chunk, if one is already buffered.
    pub fn try_recv(&self) -> Option<Vec<u8>> {
        self.rx.try_recv().ok()
    }

    /// Block up to `timeout` for the next chunk.
    pub fn recv_timeout(&self, timeout: Duration) -> Option<Vec<u8>> {
        self.rx.recv_timeout(timeout).ok()
    }

    /// The underlying drain — live `bytes_forwarded` / `dsr_answered` counters
    /// and the cooperative stop flag.
    pub fn drain(&self) -> &Drain {
        &self.drain
    }

    /// Ask the pump to stop (cooperative — see [`Drain::request_stop`]).
    pub fn request_stop(&self) {
        self.drain.request_stop();
    }
}

impl PtySession {
    /// Stream this surface's output through a bounded channel of `capacity`
    /// chunks. `capacity == 0` is a rendezvous channel (maximum backpressure:
    /// every chunk blocks until the consumer takes it).
    ///
    /// Takes the master reader (once). The returned [`OutputStream`] is the sole
    /// consumer; chunks the consumer never takes apply backpressure rather than
    /// growing an unbounded buffer.
    //
    // [impl->REQ-TERM-3]
    pub fn byte_stream(&self, capacity: usize) -> Result<OutputStream, SurfaceError> {
        let (tx, rx) = sync_channel::<Vec<u8>>(capacity);
        let drain = self.drain(move |chunk| {
            // Bounded send: blocks when `capacity` chunks are unconsumed
            // (backpressure); drops silently once the receiver is gone so a
            // torn-down consumer never wedges the pump.
            let _ = tx.send(chunk.to_vec());
        })?;
        Ok(OutputStream { rx, drain })
    }
}


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

//! Bounded byte-stream local streaming (M3a A4).
//!
//! [`OutputStream`] is the v1 consumer shape: the A2 drain forwards every byte
//! into a bounded `sync_channel`, and a consumer pulls chunks off the other end.
//! When the consumer falls behind, the channel fills, the drain's `send` blocks,
//! the reader stops draining, the PTY buffer fills, and the child blocks on
//! write — **end-to-end bounded backpressure, no unbounded buffer**. The off-node
//! hop (M4 transport) and the predictive "feels-local" layer ride on top of this
//! substrate later; M3a delivers the local shape only.
//!
//! **OS-neutral (Spike #4 / #5):** the stream is bytes, nothing more. It makes no
//! ordering promise beyond "every byte the reader forwarded, in the order it was
//! forwarded" — on ConPTY a repaint-on-resize legitimately reorders/duplicates
//! content, so consumers (e.g. the A5 digest) match on *value-set coverage*, not
//! byte-exact sequence.

use std::sync::mpsc::{sync_channel, Receiver};
use std::time::Duration;

use crate::pty::PtySession;
use crate::reader::Drain;
use crate::surface::SurfaceError;

/// A bounded stream of the child's output chunks. Dropping it unblocks a drain
/// that is parked on a full channel (the blocked `send` returns once the
/// receiver is gone), letting the pump wind down once the child is torn down.
pub struct OutputStream {
    rx: Receiver<Vec<u8>>,
    drain: Drain,
}

impl OutputStream {
    /// Block for the next output chunk. Returns `None` once the stream has ended
    /// (the drain thread exited and dropped its sender — i.e. the child's output
    /// reached EOF or the pump was stopped).
    pub fn recv(&self) -> Option<Vec<u8>> {
        self.rx.recv().ok()
    }

    /// Non-blocking poll for the next chunk, if one is already buffered.
    pub fn try_recv(&self) -> Option<Vec<u8>> {
        self.rx.try_recv().ok()
    }

    /// Block up to `timeout` for the next chunk.
    pub fn recv_timeout(&self, timeout: Duration) -> Option<Vec<u8>> {
        self.rx.recv_timeout(timeout).ok()
    }

    /// The underlying drain — live `bytes_forwarded` / `dsr_answered` counters
    /// and the cooperative stop flag.
    pub fn drain(&self) -> &Drain {
        &self.drain
    }

    /// Ask the pump to stop (cooperative — see [`Drain::request_stop`]).
    pub fn request_stop(&self) {
        self.drain.request_stop();
    }
}

impl PtySession {
    /// Stream this surface's output through a bounded channel of `capacity`
    /// chunks. `capacity == 0` is a rendezvous channel (maximum backpressure:
    /// every chunk blocks until the consumer takes it).
    ///
    /// Takes the master reader (once). The returned [`OutputStream`] is the sole
    /// consumer; chunks the consumer never takes apply backpressure rather than
    /// growing an unbounded buffer.
    //
    // [impl->REQ-TERM-3]
    pub fn byte_stream(&self, capacity: usize) -> Result<OutputStream, SurfaceError> {
        let (tx, rx) = sync_channel::<Vec<u8>>(capacity);
        let drain = self.drain(move |chunk| {
            // Bounded send: blocks when `capacity` chunks are unconsumed
            // (backpressure); drops silently once the receiver is gone so a
            // torn-down consumer never wedges the pump.
            let _ = tx.send(chunk.to_vec());
        })?;
        Ok(OutputStream { rx, drain })
    }
}


Edit

//   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 reader;
pub mod surface;

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

//   A4  byte-stream local streaming (bounded backpressure)  (REQ-TERM-3)       ← landed
//   A5  live activity buffer (PTY digest) parser primitive  (REQ-TERM-4)

pub mod pty;
pub mod reader;
pub mod stream;
pub mod surface;

pub use portable_pty::CommandBuilder;
pub use pty::PtySession;
pub use reader::Drain;
pub use stream::OutputStream;
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)

//   A4  byte-stream local streaming (bounded backpressure)  (REQ-TERM-3)       ← landed
//   A5  live activity buffer (PTY digest) parser primitive  (REQ-TERM-4)

pub mod pty;
pub mod reader;
pub mod stream;
pub mod surface;

pub use portable_pty::CommandBuilder;
pub use pty::PtySession;
pub use reader::Drain;
pub use stream::OutputStream;
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)       ← landed
//   A2  ConPTY-DSR auto-answer drain thread                 (REQ-HAZARD-CONPTY-DSR) ← landed
//   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 reader;
pub mod surface;

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

-//   A4  byte-stream local streaming (bounded backpressure)  (REQ-TERM-3)

+//   A4  byte-stream local streaming (bounded backpressure)  (REQ-TERM-3)       ← landed


Write

//! A4 regression tests for the bounded byte-stream.
//!
//!  - **bounded backpressure**: a flooding child whose output goes unconsumed
//!    must *stall the reader* (no unbounded buffer); consuming resumes the flow.
//!  - **value-set coverage under resize** (Spike #5): every injected value is
//!    delivered even as the surface is resized mid-stream — asserted as a set
//!    (ConPTY repaint may reorder/duplicate; nothing is lost).

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

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

/// An echo child (reads stdin, prints each line), OS-appropriate.
fn echo_child() -> PtySession {
    #[cfg(unix)]
    let s = PtySession::spawn_program("cat", Vec::<String>::new(), SurfaceSize::new(24, 80));
    #[cfg(windows)]
    let s = PtySession::spawn_program("findstr", ["."], SurfaceSize::new(24, 80));
    s.expect("spawn echo child")
}

/// A child that floods stdout as fast as it can, OS-appropriate.
fn flooder() -> PtySession {
    #[cfg(unix)]
    let s = PtySession::spawn_program("yes", ["FLOOD-LINE"], SurfaceSize::new(24, 80));
    #[cfg(windows)]
    let s = PtySession::spawn_program(
        "cmd",
        ["/c", "for /l %i in (1,1,100000000) do @echo FLOOD-LINE"],
        SurfaceSize::new(24, 80),
    );
    s.expect("spawn flooder child")
}

// [unit->REQ-TERM-3]
#[test]
fn bounded_backpressure_stalls_an_unconsumed_reader() {
    let session = Arc::new(flooder());
    let stream = session.byte_stream(2).expect("open bounded stream");

    // Let the channel fill (2 chunks) and the next send block.
    thread::sleep(Duration::from_millis(400));
    let b1 = stream.drain().bytes_forwarded();
    // Still not consuming — a bounded buffer cannot keep growing.
    thread::sleep(Duration::from_millis(400));
    let b2 = stream.drain().bytes_forwarded();
    assert_eq!(
        b1, b2,
        "an unconsumed bounded stream must stall the reader (got {b1} -> {b2})"
    );
    assert!(b1 > 0, "some bytes were buffered before the stall");

    // Drain a few chunks — the flow must resume past the old plateau.
    for _ in 0..8 {
        let _ = stream.recv_timeout(Duration::from_millis(500));
    }
    thread::sleep(Duration::from_millis(200));
    let b3 = stream.drain().bytes_forwarded();
    assert!(b3 > b2, "consuming must resume the flow ({b2} -> {b3})");

    stream.request_stop();
    let _ = session.kill();
}

// [unit->REQ-TERM-3]
#[test]
fn delivers_every_value_under_resize() {
    let session = echo_child();
    // Capacity comfortably above the in-flight echoes so single-threaded
    // inject-then-drain never deadlocks; backpressure is the other test's job.
    let stream = session.byte_stream(256).expect("open stream");

    const N: usize = 40;
    let mut collected = Vec::new();
    for i in 0..N {
        session.send_line(&format!("VAL-{i:03}")).expect("inject value");
        if i % 4 == 0 {
            let rows = 8 + (i as u16 % 30);
            let cols = 40 + (i as u16 % 60);
            session.resize(SurfaceSize::new(rows, cols)).expect("resize mid-stream");
        }
        while let Some(c) = stream.try_recv() {
            collected.extend_from_slice(&c);
        }
    }

    // Final settle: pull remaining echoes until every value lands or we time out.
    let mut have_all = false;
    for _ in 0..200 {
        if let Some(c) = stream.recv_timeout(Duration::from_millis(25)) {
            collected.extend_from_slice(&c);
        }
        let text = String::from_utf8_lossy(&collected);
        if (0..N).all(|i| text.contains(&format!("VAL-{i:03}"))) {
            have_all = true;
            break;
        }
    }

    stream.request_stop();
    let _ = session.send_keys(&[0x03]);
    let _ = session.kill();

    let text = String::from_utf8_lossy(&collected);
    let missing: Vec<_> = (0..N)
        .map(|i| format!("VAL-{i:03}"))
        .filter(|m| !text.contains(m.as_str()))
        .collect();
    assert!(
        have_all,
        "every injected value must survive resize (value-set coverage); missing: {missing:?}"
    );
}


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

//! A4 regression tests for the bounded byte-stream.
//!
//!  - **bounded backpressure**: a flooding child whose output goes unconsumed
//!    must *stall the reader* (no unbounded buffer); consuming resumes the flow.
//!  - **value-set coverage under resize** (Spike #5): every injected value is
//!    delivered even as the surface is resized mid-stream — asserted as a set
//!    (ConPTY repaint may reorder/duplicate; nothing is lost).

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

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

/// An echo child (reads stdin, prints each line), OS-appropriate.
fn echo_child() -> PtySession {
    #[cfg(unix)]
    let s = PtySession::spawn_program("cat", Vec::<String>::new(), SurfaceSize::new(24, 80));
    #[cfg(windows)]
    let s = PtySession::spawn_program("findstr", ["."], SurfaceSize::new(24, 80));
    s.expect("spawn echo child")
}

/// A child that floods stdout as fast as it can, OS-appropriate.
fn flooder() -> PtySession {
    #[cfg(unix)]
    let s = PtySession::spawn_program("yes", ["FLOOD-LINE"], SurfaceSize::new(24, 80));
    #[cfg(windows)]
    let s = PtySession::spawn_program(
        "cmd",
        ["/c", "for /l %i in (1,1,100000000) do @echo FLOOD-LINE"],
        SurfaceSize::new(24, 80),
    );
    s.expect("spawn flooder child")
}

// [unit->REQ-TERM-3]
#[test]
fn bounded_backpressure_stalls_an_unconsumed_reader() {
    let session = Arc::new(flooder());
    let stream = session.byte_stream(2).expect("open bounded stream");

    // Let the channel fill (2 chunks) and the next send block.
    thread::sleep(Duration::from_millis(400));
    let b1 = stream.drain().bytes_forwarded();
    // Still not consuming — a bounded buffer cannot keep growing.
    thread::sleep(Duration::from_millis(400));
    let b2 = stream.drain().bytes_forwarded();
    assert_eq!(
        b1, b2,
        "an unconsumed bounded stream must stall the reader (got {b1} -> {b2})"
    );
    assert!(b1 > 0, "some bytes were buffered before the stall");

    // Drain a few chunks — the flow must resume past the old plateau.
    for _ in 0..8 {
        let _ = stream.recv_timeout(Duration::from_millis(500));
    }
    thread::sleep(Duration::from_millis(200));
    let b3 = stream.drain().bytes_forwarded();
    assert!(b3 > b2, "consuming must resume the flow ({b2} -> {b3})");

    stream.request_stop();
    let _ = session.kill();
}

// [unit->REQ-TERM-3]
#[test]
fn delivers_every_value_under_resize() {
    let session = echo_child();
    // Capacity comfortably above the in-flight echoes so single-threaded
    // inject-then-drain never deadlocks; backpressure is the other test's job.
    let stream = session.byte_stream(256).expect("open stream");

    const N: usize = 40;
    let mut collected = Vec::new();
    for i in 0..N {
        session.send_line(&format!("VAL-{i:03}")).expect("inject value");
        if i % 4 == 0 {
            let rows = 8 + (i as u16 % 30);
            let cols = 40 + (i as u16 % 60);
            session.resize(SurfaceSize::new(rows, cols)).expect("resize mid-stream");
        }
        while let Some(c) = stream.try_recv() {
            collected.extend_from_slice(&c);
        }
    }

    // Final settle: pull remaining echoes until every value lands or we time out.
    let mut have_all = false;
    for _ in 0..200 {
        if let Some(c) = stream.recv_timeout(Duration::from_millis(25)) {
            collected.extend_from_slice(&c);
        }
        let text = String::from_utf8_lossy(&collected);
        if (0..N).all(|i| text.contains(&format!("VAL-{i:03}"))) {
            have_all = true;
            break;
        }
    }

    stream.request_stop();
    let _ = session.send_keys(&[0x03]);
    let _ = session.kill();

    let text = String::from_utf8_lossy(&collected);
    let missing: Vec<_> = (0..N)
        .map(|i| format!("VAL-{i:03}"))
        .filter(|m| !text.contains(m.as_str()))
        .collect();
    assert!(
        have_all,
        "every injected value must survive resize (value-set coverage); missing: {missing:?}"
    );
}


Bash

cargo test -p spt-term --test stream 2>&1 | tail -25

Run A4 stream tests on Windows


   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.05s
     Running tests\stream.rs (target\debug\deps\stream-1b3a42d1c51fe336.exe)

running 2 tests
test delivers_every_value_under_resize ... ok
test bounded_backpressure_stalls_an_unconsumed_reader ... ok

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


Both pass on Windows incl. the backpressure-stall assertion (b1==b2). Clippy + activate REQ-TERM-3 + Linux.


Edit

id = "REQ-TERM-3"
title = "Byte-stream remote terminal streaming for v1"
required_stages = []

id = "REQ-TERM-3"
title = "Byte-stream remote terminal streaming for v1"
required_stages = ["impl", "unit"]


id = "REQ-TERM-3"
title = "Byte-stream remote terminal streaming for v1"
required_stages = []

id = "REQ-TERM-3"
title = "Byte-stream remote terminal streaming for v1"
required_stages = ["impl", "unit"]

# ── Live activity buffer / PTY digest (ADR-0008, 2026-06-01; activate M3) ──


Bash

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

Clippy + check REQ-TERM-3


    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.35s
===CHECK===
  [OK] REQ-TERM-3  required: [impl, unit]  stages: -doc +impl +unit -int


tar czf /tmp/spt-sync.tgz --exclude=target --exclude=.git . 2>/dev/null && \
SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux 'cat > /tmp/spt-sync.tgz' < /tmp/spt-sync.tgz 2>&1 | tail -1
SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux 'rm -rf ~/spt-core-sync && mkdir -p ~/spt-core-sync && tar xzf /tmp/spt-sync.tgz -C ~/spt-core-sync && export PATH=$HOME/.cargo/bin:$PATH && cd ~/spt-core-sync && cargo test -p spt-term --test stream 2>&1 | tail -10' < /dev/null 2>&1 | tail -12

Validate A4 stream on Linux


   Compiling spt-term v0.0.0 (/home/david/spt-core-sync/crates/spt-term)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 22.91s
     Running tests/stream.rs (target/debug/deps/stream-957d65bd0777cc05)

running 2 tests
test delivers_every_value_under_resize ... ok
test bounded_backpressure_stalls_an_unconsumed_reader ... ok

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


Stream green on both OSes (incl. `yes` flood backpressure on Linux). Commit A4.


git add -A && git commit -q -m "feat(term): bounded byte-stream (M3a A4)

OutputStream: the A2 drain forwards every byte into a bounded
sync_channel; a consumer pulls chunks off the far end. A full channel
blocks the drain's send -> the reader stops -> the PTY buffer fills ->
the child blocks on write: end-to-end bounded backpressure, no
unbounded buffer. capacity 0 = rendezvous (max backpressure). Dropping
the stream unblocks a send parked on a full channel so a torn-down
consumer never wedges the pump. recv/try_recv/recv_timeout + live drain
counters.

OS-neutral (Spike #4/#5): pure bytes, no ordering promise beyond
'every forwarded byte in forwarded order' — so the resize test asserts
value-set coverage (every injected value present), tolerating ConPTY
repaint reorder/dup. Backpressure test proves the stall rigorously:
an unconsumed flooder's bytes_forwarded plateaus (b1==b2) then resumes
once consumed.

Validated on Windows (ConPTY, cmd flood) + Linux (forkpty, yes flood).
Activates REQ-TERM-3 (impl, unit).

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

Commit A4


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/stream.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-term/tests/stream.rs', LF will be replaced by CRLF the next time Git touches it
4b789ee feat(term): bounded byte-stream (M3a A4)


A0–A4 done. A5 is the design-heavy one — read ADR-0008 + CONTEXT for the exact digest model/terms.


ls docs/adr/ | grep -i "0008\|digest\|activity"; echo "==="; cat docs/adr/*0008* 2>/dev/null

Read ADR-0008


0008-live-activity-buffer-pty-digest.md
===
# Live activity buffer (PTY digest)

## Status

accepted (2026-06-01)

## Context

We want a rolling, human-glanceable "what is this agent doing right now" view — the last ~N user turns + agent output between, with tool-usage sprints collapsed to a single `used: …` line — that a Shell, CLI, or GUI pane can render. The obvious implementation (spt-core parses the live PTY byte stream) looks like it **contradicts the history-subsystem decision** (CONTEXT §history subsystem), which deliberately refused to build per-harness parsers ("unbounded, version-fragile maintenance burden") and pushed parsing into adapters (Path A) or a native store the adapter writes (Path B). A future reader would reasonably flag the apparent reversal. This ADR records why it is *not* one, and the shape chosen.

## Decision

Add a **live activity buffer (PTY digest)** as a **distinct artifact** — not scrollback (raw bytes, R-TERM-2), not transcript history (full conversation behind communes/resume, Paths A/B). Its purpose is at-a-glance current activity, not replay.

The parsing-boundary distinction that resolves the tension:
- **The adapter supplies the patterns; spt-core only runs them.** The manifest provides `input_pattern` + `agent_pattern` (each opens a span capturing everything, newlines included, until the next pattern match of any kind) and `tool_pattern` (a **list** — one per tool, plus a final **catchall** for user-installed tools, capturing tool-name + arg fragment). This is exactly Path A's "adapter owns the parser" rule and the "spt-core owns the mechanism, adapter owns the pattern" principle (identical to opaque command templates) — **not** a built-in per-harness parser. The rejected maintenance burden stays in the adapter.
- **It parses the live PTY byte stream, not the harness transcript.** A light rolling regex over bytes the broker already holds is a categorically different artifact from locating + parsing version-fragile harness transcript files (the thing Path A/B exist to avoid).

Shape:
- **Broker-PTY-dependent → spt-hosted-primary.** Needs the broker-owned PTY (topology 2). **Harness-hosted (topology 1) is capability-gated:** an adapter that can feed spt-core the stream (e.g. a `pi`-style harness) opts in via a declared capability; harnesses that own their process tree opaquely (Claude Code) cannot, and the digest is simply unavailable there.
- **Structured, source-tagged output** (input / agent / collapsed-tool entries) so consumers render richly. Window depth N (~3) is a config knob; tool-arg truncation (~25 chars) is spt-core-controlled *presentation* (the template controls *extraction*).
- **Two access modes over one transport.** The digest is a parsed projection of the same PTY stream the broker streams for R-TERM-3, so: **snapshot pull** (`spt digest <[subnet:]id[@node]>`) and a **broker-pushed structured-delta stream** (subscribe; pushes only changes for near-realtime reflection). The delta-stream is a structured overlay riding the **same R-TERM-3 byte-stream substrate** (raw vs parsed = two channels on the one session surface). Access is **address-gated** (same as messaging; respects ADR-0006 visibility).
- **Optional "Option (C)" persistence:** opt-in per-endpoint, append digests to the native history store (Path B) as a coarse activity log — a third logging surface, off by default, not a replacement for A/B.

## Consequences

- New v1 requirement to register: the live activity buffer + the `input_pattern`/`agent_pattern`/`tool_pattern[]` manifest seam + `spt digest` (pull) + the digest delta-stream (push). Lands with **M3** (needs the spt-term broker + PTY); Shell consumption is M5; the GUI pane is the frontend milestone; persistence is M3+.
- The manifest schema (`docs/MANIFEST.md`) gains the `pty_digest` seam; the capability-declaration seam gains the harness-hosted stream-feed opt-in.
- Does **not** reverse the history-subsystem decision — the no-built-in-parser rule still holds; the digest honors it by running adapter-supplied patterns over the live byte stream rather than parsing harness transcripts.
- Builds on R-TERM-1/2/3 (broker, session surface, byte-stream) and ADR-0006 (address-gating).


grep -n "live activity\|digest\|input_pattern\|agent_pattern\|tool_pattern\|sprint\|catchall\|used:" CONTEXT.md | head -30

CONTEXT digest terminology


270:**live activity buffer (PTY digest)**:
271:A rolling, *parsed*, human-glanceable view of **recent** PTY I/O — the last ~N user turns (default ~3) plus the agent output between them, with **tool-usage sprints collapsed** to a single entry (e.g. `<endpoint_id> used: **Write** `dir/file.txt` · **Bash** `first ~25 cmd chars` · […]`). Distinct from both the **raw scrollback ring** (unparsed bytes) and **transcript history** (the full conversation behind echo communes/resume, history subsystem Paths A/B): its job is *"what is this agent doing right now, at a glance,"* not conversation replay.
273:- **Philosophy-consistent parsing:** the **adapter supplies the regex/templates** (manifest); spt-core only *runs* them over the PTY bytes the broker already owns. This is Path A's "adapter owns the parser" rule, not a built-in per-harness parser — the rejected maintenance burden stays out. The seam (full schema in `docs/MANIFEST.md`) is three adapter-supplied patterns: **`input_pattern`** (user-input boundary) and **`agent_pattern`** (agent-output boundary) — each *opens a span that captures everything, newlines included, until the next pattern match of any kind* — and **`tool_pattern`**, a **list** of patterns (one per tool: Write, Bash, …) **plus a final catchall** for unknowable user-installed tools. Output is **structured with sources tagged explicitly** (input / agent / collapsed-tool entries) so consumers render richly. Window depth N (~3) is a config knob; the tool-arg truncation width (~25 chars) is spt-core-controlled *presentation* (template controls *extraction*).
274:- **Broker-PTY-dependent → spt-hosted-primary.** Tapping the live byte stream needs the **broker-owned PTY** (topology 2). **Harness-hosted (topology 1) is capability-gated:** an adapter that can feed spt-core the stream (e.g. a `pi`-style harness) may opt in via a declared capability; harnesses that own their process tree opaquely (Claude Code) cannot, and the digest is simply unavailable there. Lands with M3 (spt-term broker).
275:- **Two access modes over one transport.** The digest is a *parsed projection of the same PTY stream the broker already owns for byte-stream remote terminal (R-TERM-3)*, so it is offered both ways: **snapshot pull** — `spt digest <[subnet:]id[@node]>` returns the current structured buffer (a Shell/CLI/GUI on-demand render; feeds the frontend's "latest session output" pane) — and a **broker-pushed structured-delta stream** — a subscriber (live Shell pane / GUI) receives only *changes* (new turn, new tool entry, collapse update), not the whole buffer, for resource-efficient near-realtime reflection. The delta-stream is a **structured overlay riding the same R-TERM-3 streaming substrate** as the raw byte-stream (raw bytes vs parsed digest = two channels on the one session surface). **Access is address-gated** — fetch/subscribe is allowed for anyone who can address the endpoint (visible + resolvable per the resolution policy), the same gate as messaging.
276:- **"Option (C)" logging surface:** the digest may also be *persisted* (opt-in per-endpoint) as a coarse activity log appended to the native history store (Path B), on-boundary or periodic — a third endpoint-logging surface alongside history Path A / Path B (not a replacement), off by default.


Digest model clear. A5 = the **parser primitive only**: caller-supplied patterns (input/agent boundary spans + tool list + catchall), structured source-tagged turns, window N, arg-truncation. Add `regex`, write `digest.rs`.


Edit

spt-msg = { path = "../spt-msg" }
portable-pty = "0.8"

spt-msg = { path = "../spt-msg" }
portable-pty = "0.8"
regex = "1"


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

spt-msg = { path = "../spt-msg" }
portable-pty = "0.8"
regex = "1"

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

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

[dev-dependencies]
tempfile = "3"

spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
portable-pty = "0.8"
+regex = "1"
[dev-dependencies]
tempfile = "3"


Write

//! Live activity buffer (PTY digest) parser primitive (M3a A5, ADR-0008).
//!
//! A rolling, *parsed*, source-tagged view of recent PTY I/O — the last ~N user
//! turns plus the agent output between them, with tool-usage sprints collapsed
//! to a single entry. Distinct from raw scrollback (bytes) and transcript
//! history (the full conversation); its job is *"what is this agent doing right
//! now, at a glance."*
//!
//! ## The parsing-boundary rule (ADR-0008 — NOT a built-in per-harness parser)
//!
//! **The adapter supplies the patterns; this primitive only runs them.** Exactly
//! Path A's "adapter owns the parser" rule and the "spt-core owns the mechanism,
//! adapter owns the pattern" principle (identical to opaque command templates).
//! The seam is three adapter-supplied pattern kinds:
//! - **`input`** (user-input boundary) and **`agent`** (agent-output boundary) —
//!   each *opens a span capturing everything, newlines included, until the next
//!   pattern match of any kind*.
//! - **`tools`** — a list (one regex per known tool) **plus an optional
//!   `catchall`** for unknowable user-installed tools — each extracting a tool
//!   `name` and an `arg` fragment via named capture groups.
//!
//! Window depth `N` and the tool-arg truncation width are spt-core-controlled
//! *presentation*; the template controls *extraction*.
//!
//! ## Scope (M3a is the primitive ONLY)
//!
//! This is the parser: patterns in, structured [`Digest`] out, fed from the A4
//! byte stream. The manifest-sourcing of the patterns, the `spt digest` pull,
//! the broker-pushed delta-stream, and Path-B persistence are **M3b** — they need
//! the broker + manifest + history store this crate deliberately does not build.
//!
//! ## OS-neutrality (Spike #4 / #5)
//!
//! ConPTY repaints on resize — it reorders and *duplicates* stream content. The
//! parser therefore re-derives the digest from a bounded rolling text buffer and
//! reports the **tail** (last `N` turns): a repaint that resends a turn just
//! makes the latest copy win. ANSI control sequences (cursor moves, clears,
//! repaint machinery) are stripped before matching so the adapter patterns see
//! clean text on both the forkpty raw-pipe and ConPTY screen-buffer backends.

use regex::Regex;

/// A failure compiling an adapter-supplied pattern.
#[derive(Debug)]
pub enum DigestError {
    /// A pattern did not compile as a regex. Names which one.
    BadPattern { which: String, error: String },
}

impl std::fmt::Display for DigestError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            DigestError::BadPattern { which, error } => {
                write!(f, "digest pattern {which} did not compile: {error}")
            }
        }
    }
}

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

/// The adapter-supplied patterns (regex source strings). `input` / `agent` are
/// boundary markers; `tools` + `catchall` extract `name` + `arg` capture groups.
#[derive(Debug, Clone)]
pub struct DigestPatterns {
    /// User-input boundary marker.
    pub input: String,
    /// Agent-output boundary marker.
    pub agent: String,
    /// One regex per known tool; each should name an `arg` group (and may name a
    /// `name` group — else the tool's matched text leads).
    pub tools: Vec<String>,
    /// Optional final catchall for user-installed tools.
    pub catchall: Option<String>,
}

/// Presentation knobs owned by spt-core (not the template).
#[derive(Debug, Clone, Copy)]
pub struct DigestConfig {
    /// Window depth — keep the last `window_turns` user turns (~3).
    pub window_turns: usize,
    /// Tool-arg truncation width in characters (~25).
    pub arg_truncation: usize,
    /// Strip ANSI control sequences before matching (default on — see module docs).
    pub strip_ansi: bool,
    /// Rolling raw-text buffer cap in bytes; older bytes are dropped from the
    /// front so memory stays bounded under an endless stream.
    pub max_scan_bytes: usize,
}

impl Default for DigestConfig {
    fn default() -> Self {
        Self { window_turns: 3, arg_truncation: 25, strip_ansi: true, max_scan_bytes: 64 * 1024 }
    }
}

/// A single collapsed tool invocation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolUse {
    /// The tool name (e.g. `Write`).
    pub name: String,
    /// The (truncated) argument fragment.
    pub arg: String,
}

/// One source-tagged entry within a turn.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DigestEntry {
    /// Agent output text (between boundaries).
    Agent(String),
    /// A collapsed run of consecutive tool invocations (one "sprint").
    ToolSprint(Vec<ToolUse>),
}

/// One user turn: the user input that opened it (None for any pre-input
/// preamble) plus the agent output / tool sprints that followed, in order.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Turn {
    /// The user input text that opened this turn.
    pub input: Option<String>,
    /// Agent + tool-sprint entries that followed, in stream order.
    pub entries: Vec<DigestEntry>,
}

/// The structured rolling digest — the last `N` turns.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Digest {
    /// Turns oldest-to-newest, at most `window_turns` of them.
    pub turns: Vec<Turn>,
}

/// One located pattern hit while scanning the buffer.
enum Marker {
    Input { content: String },
    Agent { content: String },
    Tool(ToolUse),
}

/// The digest parser: feed bytes from the A4 stream, read a structured [`Digest`].
pub struct DigestParser {
    input_re: Regex,
    agent_re: Regex,
    tool_res: Vec<Regex>,
    catchall_re: Option<Regex>,
    ansi_re: Option<Regex>,
    config: DigestConfig,
    /// Rolling, ANSI-stripped text buffer.
    buf: String,
}

impl DigestParser {
    /// Compile the adapter-supplied patterns. Returns [`DigestError::BadPattern`]
    /// naming the first pattern that fails to compile.
    //
    // [impl->REQ-TERM-4]
    pub fn new(patterns: DigestPatterns, config: DigestConfig) -> Result<Self, DigestError> {
        let compile = |src: &str, which: &str| {
            Regex::new(src).map_err(|e| DigestError::BadPattern {
                which: which.to_string(),
                error: e.to_string(),
            })
        };
        let input_re = compile(&patterns.input, "input")?;
        let agent_re = compile(&patterns.agent, "agent")?;
        let mut tool_res = Vec::with_capacity(patterns.tools.len());
        for (i, t) in patterns.tools.iter().enumerate() {
            tool_res.push(compile(t, &format!("tools[{i}]"))?);
        }
        let catchall_re = match &patterns.catchall {
            Some(c) => Some(compile(c, "catchall")?),
            None => None,
        };
        // CSI / OSC / lone-ESC stripper. Compiled by us, never fails.
        let ansi_re = if config.strip_ansi {
            Some(
                Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]")
                    .expect("static ANSI regex compiles"),
            )
        } else {
            None
        };
        Ok(Self {
            input_re,
            agent_re,
            tool_res,
            catchall_re,
            ansi_re,
            config,
            buf: String::new(),
        })
    }

    /// Feed a chunk of the byte stream. Decoded lossily to UTF-8, ANSI-stripped
    /// (if configured), normalized (`\r\n`/`\r` → `\n`), appended, and trimmed to
    /// the rolling cap.
    //
    // [impl->REQ-TERM-4]
    pub fn feed(&mut self, bytes: &[u8]) {
        let text = String::from_utf8_lossy(bytes);
        let cleaned = match &self.ansi_re {
            Some(re) => re.replace_all(&text, "").into_owned(),
            None => text.into_owned(),
        };
        // Normalize carriage returns left by ConPTY repaint so boundary patterns
        // and span content match on line-oriented text.
        let cleaned = cleaned.replace("\r\n", "\n").replace('\r', "");
        self.buf.push_str(&cleaned);

        if self.buf.len() > self.config.max_scan_bytes {
            // Drop from the front, on a char boundary.
            let cut = self.buf.len() - self.config.max_scan_bytes;
            let mut idx = cut;
            while idx < self.buf.len() && !self.buf.is_char_boundary(idx) {
                idx += 1;
            }
            self.buf.drain(..idx);
        }
    }

    /// Re-derive the structured digest from the current buffer: scan all pattern
    /// hits in order, fold boundary spans + collapsed tool sprints into turns,
    /// and return the last `window_turns`.
    //
    // [impl->REQ-TERM-4]
    pub fn digest(&self) -> Digest {
        let markers = self.scan_markers();
        let mut turns: Vec<Turn> = Vec::new();
        let mut current = Turn::default();
        let mut current_started = false;

        for m in markers {
            match m {
                Marker::Input { content } => {
                    if current_started || !current.entries.is_empty() || current.input.is_some() {
                        turns.push(std::mem::take(&mut current));
                    }
                    current = Turn { input: Some(content), entries: Vec::new() };
                    current_started = true;
                }
                Marker::Agent { content } => {
                    if !content.is_empty() {
                        current.entries.push(DigestEntry::Agent(content));
                        current_started = true;
                    }
                }
                Marker::Tool(tu) => {
                    // Collapse into the trailing sprint if the last entry is one.
                    if let Some(DigestEntry::ToolSprint(sprint)) = current.entries.last_mut() {
                        sprint.push(tu);
                    } else {
                        current.entries.push(DigestEntry::ToolSprint(vec![tu]));
                    }
                    current_started = true;
                }
            }
        }
        if current_started || !current.entries.is_empty() || current.input.is_some() {
            turns.push(current);
        }

        // Keep only the last N turns.
        let n = self.config.window_turns;
        if turns.len() > n {
            turns.drain(..turns.len() - n);
        }
        Digest { turns }
    }

    /// Locate every pattern hit in the buffer, sorted by position, and resolve
    /// boundary span content (text from a boundary to the next hit of any kind).
    fn scan_markers(&self) -> Vec<Marker> {
        // (start, end, kind) where kind carries the extracted payload.
        enum Kind {
            Input,
            Agent,
            Tool(ToolUse),
        }
        let mut hits: Vec<(usize, usize, Kind)> = Vec::new();

        for m in self.input_re.find_iter(&self.buf) {
            hits.push((m.start(), m.end(), Kind::Input));
        }
        for m in self.agent_re.find_iter(&self.buf) {
            hits.push((m.start(), m.end(), Kind::Agent));
        }
        let mut push_tool = |re: &Regex| {
            for caps in re.captures_iter(&self.buf) {
                let whole = caps.get(0).unwrap();
                let name = caps
                    .name("name")
                    .map(|m| m.as_str().to_string())
                    .unwrap_or_else(|| {
                        // No name group: lead with the matched text's first token.
                        whole.as_str().split_whitespace().next().unwrap_or("").to_string()
                    });
                let arg_raw = caps.name("arg").map(|m| m.as_str()).unwrap_or("");
                let arg = truncate_arg(arg_raw.trim(), self.config.arg_truncation);
                hits.push((whole.start(), whole.end(), Kind::Tool(ToolUse { name, arg })));
            }
        };
        for re in &self.tool_res {
            push_tool(re);
        }
        if let Some(re) = &self.catchall_re {
            // Only count a catchall hit where no specific tool already matched.
            for caps in re.captures_iter(&self.buf) {
                let whole = caps.get(0).unwrap();
                if hits.iter().any(|(s, e, k)| {
                    matches!(k, Kind::Tool(_)) && *s <= whole.start() && whole.end() <= *e
                }) {
                    continue;
                }
                let name = caps
                    .name("name")
                    .map(|m| m.as_str().to_string())
                    .unwrap_or_else(|| {
                        whole.as_str().split_whitespace().next().unwrap_or("").to_string()
                    });
                let arg_raw = caps.name("arg").map(|m| m.as_str()).unwrap_or("");
                let arg = truncate_arg(arg_raw.trim(), self.config.arg_truncation);
                hits.push((whole.start(), whole.end(), Kind::Tool(ToolUse { name, arg })));
            }
        }

        // Order by position; on a tie, boundaries before tools (a boundary opens
        // a span, tools sit inside it).
        hits.sort_by(|a, b| {
            a.0.cmp(&b.0).then_with(|| boundary_rank(&a.2).cmp(&boundary_rank(&b.2)))
        });

        // Resolve boundary span content = text from this hit's end to the next
        // hit's start.
        let starts: Vec<usize> = hits.iter().map(|(s, _, _)| *s).collect();
        let mut out = Vec::with_capacity(hits.len());
        for (i, (_, end, kind)) in hits.iter().enumerate() {
            let next_start = starts.get(i + 1).copied().unwrap_or(self.buf.len());
            let span = self.buf.get(*end..next_start).unwrap_or("").trim().to_string();
            match kind {
                Kind::Input => out.push(Marker::Input { content: span }),
                Kind::Agent => out.push(Marker::Agent { content: span }),
                Kind::Tool(tu) => out.push(Marker::Tool(tu.clone())),
            }
        }
        out
    }
}

/// Boundaries sort before tools at the same offset.
fn boundary_rank(k: &impl BoundaryKind) -> u8 {
    if k.is_tool() {
        1
    } else {
        0
    }
}

trait BoundaryKind {
    fn is_tool(&self) -> bool;
}

/// Truncate `arg` to `width` characters, appending `…` when shortened.
fn truncate_arg(arg: &str, width: usize) -> String {
    if arg.chars().count() <= width {
        return arg.to_string();
    }
    let mut s: String = arg.chars().take(width).collect();
    s.push('…');
    s
}

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

    fn patterns() -> DigestPatterns {
        DigestPatterns {
            // Boundary markers (the prompt sigils an adapter would supply).
            input: r"\nUSER> ".to_string(),
            agent: r"\nAGENT> ".to_string(),
            tools: vec![
                r"Write\((?P<arg>[^)]*)\)".to_string().replace("Write", "(?P<name>Write)"),
                r"(?P<name>Bash)\((?P<arg>[^)]*)\)".to_string(),
            ],
            catchall: Some(r"(?P<name>[A-Z][a-zA-Z]+)\((?P<arg>[^)]*)\)".to_string()),
        }
    }

    fn parser() -> DigestParser {
        DigestParser::new(patterns(), DigestConfig { window_turns: 3, ..Default::default() })
            .expect("compile patterns")
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn tags_input_agent_and_collapses_tool_sprints() {
        let mut p = parser();
        p.feed(b"\nUSER> add a file\nAGENT> sure, doing it\nWrite(src/a.rs) Bash(cargo build) done\n");
        let d = p.digest();
        assert_eq!(d.turns.len(), 1);
        let t = &d.turns[0];
        assert_eq!(t.input.as_deref(), Some("add a file"));
        // First entry: agent text; second: a single collapsed sprint of 2 tools.
        assert_eq!(t.entries[0], DigestEntry::Agent("sure, doing it".to_string()));
        match &t.entries[1] {
            DigestEntry::ToolSprint(s) => {
                assert_eq!(s.len(), 2, "consecutive tools collapse into one sprint");
                assert_eq!(s[0], ToolUse { name: "Write".into(), arg: "src/a.rs".into() });
                assert_eq!(s[1], ToolUse { name: "Bash".into(), arg: "cargo build".into() });
            }
            other => panic!("expected a tool sprint, got {other:?}"),
        }
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn windows_to_last_n_turns() {
        let mut p = parser();
        for i in 0..6 {
            p.feed(format!("\nUSER> turn {i}\nAGENT> reply {i}\n").as_bytes());
        }
        let d = p.digest();
        assert_eq!(d.turns.len(), 3, "window keeps only the last N=3 turns");
        assert_eq!(d.turns[0].input.as_deref(), Some("turn 3"));
        assert_eq!(d.turns[2].input.as_deref(), Some("turn 5"));
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn truncates_tool_args_to_width() {
        let cfg = DigestConfig { window_turns: 3, arg_truncation: 10, ..Default::default() };
        let mut p = DigestParser::new(patterns(), cfg).unwrap();
        p.feed(b"\nUSER> x\nAGENT> y\nBash(this-is-a-very-long-command-line) z\n");
        let d = p.digest();
        let sprint = d.turns[0]
            .entries
            .iter()
            .find_map(|e| match e {
                DigestEntry::ToolSprint(s) => Some(s),
                _ => None,
            })
            .expect("a sprint");
        assert_eq!(sprint[0].name, "Bash");
        assert_eq!(sprint[0].arg, "this-is-a-…", "arg truncated to width with ellipsis");
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn strips_ansi_and_tolerates_repaint_duplication() {
        let mut p = parser();
        // A ConPTY-style repaint: clear + home + a duplicated earlier turn, then
        // the real latest turn. ANSI is stripped; the tail (last N) wins.
        p.feed(b"\nUSER> first\nAGENT> \x1b[2J\x1b[Hreply one\n");
        p.feed(b"\x1b[2J\x1b[H\nUSER> first\nAGENT> reply one\n\nUSER> second\nAGENT> reply two\x1b[0m\n");
        let d = p.digest();
        // No ANSI leaked into content.
        for t in &d.turns {
            if let Some(i) = &t.input {
                assert!(!i.contains('\x1b'), "ANSI stripped from input");
            }
            for e in &t.entries {
                if let DigestEntry::Agent(a) = e {
                    assert!(!a.contains('\x1b'), "ANSI stripped from agent text");
                    assert!(!a.contains("[2J"), "no CSI residue");
                }
            }
        }
        // The most recent turn is present and correct despite the repaint.
        let last = d.turns.last().unwrap();
        assert_eq!(last.input.as_deref(), Some("second"));
        assert_eq!(last.entries[0], DigestEntry::Agent("reply two".to_string()));
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn bad_pattern_is_reported_by_name() {
        let mut pats = patterns();
        pats.agent = r"(unclosed".to_string();
        let err = DigestParser::new(pats, DigestConfig::default()).unwrap_err();
        match err {
            DigestError::BadPattern { which, .. } => assert_eq!(which, "agent"),
        }
    }
}


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

//! Live activity buffer (PTY digest) parser primitive (M3a A5, ADR-0008).
//!
//! A rolling, *parsed*, source-tagged view of recent PTY I/O — the last ~N user
//! turns plus the agent output between them, with tool-usage sprints collapsed
//! to a single entry. Distinct from raw scrollback (bytes) and transcript
//! history (the full conversation); its job is *"what is this agent doing right
//! now, at a glance."*
//!
//! ## The parsing-boundary rule (ADR-0008 — NOT a built-in per-harness parser)
//!
//! **The adapter supplies the patterns; this primitive only runs them.** Exactly
//! Path A's "adapter owns the parser" rule and the "spt-core owns the mechanism,
//! adapter owns the pattern" principle (identical to opaque command templates).
//! The seam is three adapter-supplied pattern kinds:
//! - **`input`** (user-input boundary) and **`agent`** (agent-output boundary) —
//!   each *opens a span capturing everything, newlines included, until the next
//!   pattern match of any kind*.
//! - **`tools`** — a list (one regex per known tool) **plus an optional
//!   `catchall`** for unknowable user-installed tools — each extracting a tool
//!   `name` and an `arg` fragment via named capture groups.
//!
//! Window depth `N` and the tool-arg truncation width are spt-core-controlled
//! *presentation*; the template controls *extraction*.
//!
//! ## Scope (M3a is the primitive ONLY)
//!
//! This is the parser: patterns in, structured [`Digest`] out, fed from the A4
//! byte stream. The manifest-sourcing of the patterns, the `spt digest` pull,
//! the broker-pushed delta-stream, and Path-B persistence are **M3b** — they need
//! the broker + manifest + history store this crate deliberately does not build.
//!
//! ## OS-neutrality (Spike #4 / #5)
//!
//! ConPTY repaints on resize — it reorders and *duplicates* stream content. The
//! parser therefore re-derives the digest from a bounded rolling text buffer and
//! reports the **tail** (last `N` turns): a repaint that resends a turn just
//! makes the latest copy win. ANSI control sequences (cursor moves, clears,
//! repaint machinery) are stripped before matching so the adapter patterns see
//! clean text on both the forkpty raw-pipe and ConPTY screen-buffer backends.

use regex::Regex;

/// A failure compiling an adapter-supplied pattern.
#[derive(Debug)]
pub enum DigestError {
    /// A pattern did not compile as a regex. Names which one.
    BadPattern { which: String, error: String },
}

impl std::fmt::Display for DigestError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            DigestError::BadPattern { which, error } => {
                write!(f, "digest pattern {which} did not compile: {error}")
            }
        }
    }
}

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

/// The adapter-supplied patterns (regex source strings). `input` / `agent` are
/// boundary markers; `tools` + `catchall` extract `name` + `arg` capture groups.
#[derive(Debug, Clone)]
pub struct DigestPatterns {
    /// User-input boundary marker.
    pub input: String,
    /// Agent-output boundary marker.
    pub agent: String,
    /// One regex per known tool; each should name an `arg` group (and may name a
    /// `name` group — else the tool's matched text leads).
    pub tools: Vec<String>,
    /// Optional final catchall for user-installed tools.
    pub catchall: Option<String>,
}

/// Presentation knobs owned by spt-core (not the template).
#[derive(Debug, Clone, Copy)]
pub struct DigestConfig {
    /// Window depth — keep the last `window_turns` user turns (~3).
    pub window_turns: usize,
    /// Tool-arg truncation width in characters (~25).
    pub arg_truncation: usize,
    /// Strip ANSI control sequences before matching (default on — see module docs).
    pub strip_ansi: bool,
    /// Rolling raw-text buffer cap in bytes; older bytes are dropped from the
    /// front so memory stays bounded under an endless stream.
    pub max_scan_bytes: usize,
}

impl Default for DigestConfig {
    fn default() -> Self {
        Self { window_turns: 3, arg_truncation: 25, strip_ansi: true, max_scan_bytes: 64 * 1024 }
    }
}

/// A single collapsed tool invocation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolUse {
    /// The tool name (e.g. `Write`).
    pub name: String,
    /// The (truncated) argument fragment.
    pub arg: String,
}

/// One source-tagged entry within a turn.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DigestEntry {
    /// Agent output text (between boundaries).
    Agent(String),
    /// A collapsed run of consecutive tool invocations (one "sprint").
    ToolSprint(Vec<ToolUse>),
}

/// One user turn: the user input that opened it (None for any pre-input
/// preamble) plus the agent output / tool sprints that followed, in order.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Turn {
    /// The user input text that opened this turn.
    pub input: Option<String>,
    /// Agent + tool-sprint entries that followed, in stream order.
    pub entries: Vec<DigestEntry>,
}

/// The structured rolling digest — the last `N` turns.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Digest {
    /// Turns oldest-to-newest, at most `window_turns` of them.
    pub turns: Vec<Turn>,
}

/// One located pattern hit while scanning the buffer.
enum Marker {
    Input { content: String },
    Agent { content: String },
    Tool(ToolUse),
}

/// The digest parser: feed bytes from the A4 stream, read a structured [`Digest`].
pub struct DigestParser {
    input_re: Regex,
    agent_re: Regex,
    tool_res: Vec<Regex>,
    catchall_re: Option<Regex>,
    ansi_re: Option<Regex>,
    config: DigestConfig,
    /// Rolling, ANSI-stripped text buffer.
    buf: String,
}

impl DigestParser {
    /// Compile the adapter-supplied patterns. Returns [`DigestError::BadPattern`]
    /// naming the first pattern that fails to compile.
    //
    // [impl->REQ-TERM-4]
    pub fn new(patterns: DigestPatterns, config: DigestConfig) -> Result<Self, DigestError> {
        let compile = |src: &str, which: &str| {
            Regex::new(src).map_err(|e| DigestError::BadPattern {
                which: which.to_string(),
                error: e.to_string(),
            })
        };
        let input_re = compile(&patterns.input, "input")?;
        let agent_re = compile(&patterns.agent, "agent")?;
        let mut tool_res = Vec::with_capacity(patterns.tools.len());
        for (i, t) in patterns.tools.iter().enumerate() {
            tool_res.push(compile(t, &format!("tools[{i}]"))?);
        }
        let catchall_re = match &patterns.catchall {
            Some(c) => Some(compile(c, "catchall")?),
            None => None,
        };
        // CSI / OSC / lone-ESC stripper. Compiled by us, never fails.
        let ansi_re = if config.strip_ansi {
            Some(
                Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]")
                    .expect("static ANSI regex compiles"),
            )
        } else {
            None
        };
        Ok(Self {
            input_re,
            agent_re,
            tool_res,
            catchall_re,
            ansi_re,
            config,
            buf: String::new(),
        })
    }

    /// Feed a chunk of the byte stream. Decoded lossily to UTF-8, ANSI-stripped
    /// (if configured), normalized (`\r\n`/`\r` → `\n`), appended, and trimmed to
    /// the rolling cap.
    //
    // [impl->REQ-TERM-4]
    pub fn feed(&mut self, bytes: &[u8]) {
        let text = String::from_utf8_lossy(bytes);
        let cleaned = match &self.ansi_re {
            Some(re) => re.replace_all(&text, "").into_owned(),
            None => text.into_owned(),
        };
        // Normalize carriage returns left by ConPTY repaint so boundary patterns
        // and span content match on line-oriented text.
        let cleaned = cleaned.replace("\r\n", "\n").replace('\r', "");
        self.buf.push_str(&cleaned);

        if self.buf.len() > self.config.max_scan_bytes {
            // Drop from the front, on a char boundary.
            let cut = self.buf.len() - self.config.max_scan_bytes;
            let mut idx = cut;
            while idx < self.buf.len() && !self.buf.is_char_boundary(idx) {
                idx += 1;
            }
            self.buf.drain(..idx);
        }
    }

    /// Re-derive the structured digest from the current buffer: scan all pattern
    /// hits in order, fold boundary spans + collapsed tool sprints into turns,
    /// and return the last `window_turns`.
    //
    // [impl->REQ-TERM-4]
    pub fn digest(&self) -> Digest {
        let markers = self.scan_markers();
        let mut turns: Vec<Turn> = Vec::new();
        let mut current = Turn::default();
        let mut current_started = false;

        for m in markers {
            match m {
                Marker::Input { content } => {
                    if current_started || !current.entries.is_empty() || current.input.is_some() {
                        turns.push(std::mem::take(&mut current));
                    }
                    current = Turn { input: Some(content), entries: Vec::new() };
                    current_started = true;
                }
                Marker::Agent { content } => {
                    if !content.is_empty() {
                        current.entries.push(DigestEntry::Agent(content));
                        current_started = true;
                    }
                }
                Marker::Tool(tu) => {
                    // Collapse into the trailing sprint if the last entry is one.
                    if let Some(DigestEntry::ToolSprint(sprint)) = current.entries.last_mut() {
                        sprint.push(tu);
                    } else {
                        current.entries.push(DigestEntry::ToolSprint(vec![tu]));
                    }
                    current_started = true;
                }
            }
        }
        if current_started || !current.entries.is_empty() || current.input.is_some() {
            turns.push(current);
        }

        // Keep only the last N turns.
        let n = self.config.window_turns;
        if turns.len() > n {
            turns.drain(..turns.len() - n);
        }
        Digest { turns }
    }

    /// Locate every pattern hit in the buffer, sorted by position, and resolve
    /// boundary span content (text from a boundary to the next hit of any kind).
    fn scan_markers(&self) -> Vec<Marker> {
        // (start, end, kind) where kind carries the extracted payload.
        enum Kind {
            Input,
            Agent,
            Tool(ToolUse),
        }
        let mut hits: Vec<(usize, usize, Kind)> = Vec::new();

        for m in self.input_re.find_iter(&self.buf) {
            hits.push((m.start(), m.end(), Kind::Input));
        }
        for m in self.agent_re.find_iter(&self.buf) {
            hits.push((m.start(), m.end(), Kind::Agent));
        }
        let mut push_tool = |re: &Regex| {
            for caps in re.captures_iter(&self.buf) {
                let whole = caps.get(0).unwrap();
                let name = caps
                    .name("name")
                    .map(|m| m.as_str().to_string())
                    .unwrap_or_else(|| {
                        // No name group: lead with the matched text's first token.
                        whole.as_str().split_whitespace().next().unwrap_or("").to_string()
                    });
                let arg_raw = caps.name("arg").map(|m| m.as_str()).unwrap_or("");
                let arg = truncate_arg(arg_raw.trim(), self.config.arg_truncation);
                hits.push((whole.start(), whole.end(), Kind::Tool(ToolUse { name, arg })));
            }
        };
        for re in &self.tool_res {
            push_tool(re);
        }
        if let Some(re) = &self.catchall_re {
            // Only count a catchall hit where no specific tool already matched.
            for caps in re.captures_iter(&self.buf) {
                let whole = caps.get(0).unwrap();
                if hits.iter().any(|(s, e, k)| {
                    matches!(k, Kind::Tool(_)) && *s <= whole.start() && whole.end() <= *e
                }) {
                    continue;
                }
                let name = caps
                    .name("name")
                    .map(|m| m.as_str().to_string())
                    .unwrap_or_else(|| {
                        whole.as_str().split_whitespace().next().unwrap_or("").to_string()
                    });
                let arg_raw = caps.name("arg").map(|m| m.as_str()).unwrap_or("");
                let arg = truncate_arg(arg_raw.trim(), self.config.arg_truncation);
                hits.push((whole.start(), whole.end(), Kind::Tool(ToolUse { name, arg })));
            }
        }

        // Order by position; on a tie, boundaries before tools (a boundary opens
        // a span, tools sit inside it).
        hits.sort_by(|a, b| {
            a.0.cmp(&b.0).then_with(|| boundary_rank(&a.2).cmp(&boundary_rank(&b.2)))
        });

        // Resolve boundary span content = text from this hit's end to the next
        // hit's start.
        let starts: Vec<usize> = hits.iter().map(|(s, _, _)| *s).collect();
        let mut out = Vec::with_capacity(hits.len());
        for (i, (_, end, kind)) in hits.iter().enumerate() {
            let next_start = starts.get(i + 1).copied().unwrap_or(self.buf.len());
            let span = self.buf.get(*end..next_start).unwrap_or("").trim().to_string();
            match kind {
                Kind::Input => out.push(Marker::Input { content: span }),
                Kind::Agent => out.push(Marker::Agent { content: span }),
                Kind::Tool(tu) => out.push(Marker::Tool(tu.clone())),
            }
        }
        out
    }
}

/// Boundaries sort before tools at the same offset.
fn boundary_rank(k: &impl BoundaryKind) -> u8 {
    if k.is_tool() {
        1
    } else {
        0
    }
}

trait BoundaryKind {
    fn is_tool(&self) -> bool;
}

/// Truncate `arg` to `width` characters, appending `…` when shortened.
fn truncate_arg(arg: &str, width: usize) -> String {
    if arg.chars().count() <= width {
        return arg.to_string();
    }
    let mut s: String = arg.chars().take(width).collect();
    s.push('…');
    s
}

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

    fn patterns() -> DigestPatterns {
        DigestPatterns {
            // Boundary markers (the prompt sigils an adapter would supply).
            input: r"\nUSER> ".to_string(),
            agent: r"\nAGENT> ".to_string(),
            tools: vec![
                r"Write\((?P<arg>[^)]*)\)".to_string().replace("Write", "(?P<name>Write)"),
                r"(?P<name>Bash)\((?P<arg>[^)]*)\)".to_string(),
            ],
            catchall: Some(r"(?P<name>[A-Z][a-zA-Z]+)\((?P<arg>[^)]*)\)".to_string()),
        }
    }

    fn parser() -> DigestParser {
        DigestParser::new(patterns(), DigestConfig { window_turns: 3, ..Default::default() })
            .expect("compile patterns")
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn tags_input_agent_and_collapses_tool_sprints() {
        let mut p = parser();
        p.feed(b"\nUSER> add a file\nAGENT> sure, doing it\nWrite(src/a.rs) Bash(cargo build) done\n");
        let d = p.digest();
        assert_eq!(d.turns.len(), 1);
        let t = &d.turns[0];
        assert_eq!(t.input.as_deref(), Some("add a file"));
        // First entry: agent text; second: a single collapsed sprint of 2 tools.
        assert_eq!(t.entries[0], DigestEntry::Agent("sure, doing it".to_string()));
        match &t.entries[1] {
            DigestEntry::ToolSprint(s) => {
                assert_eq!(s.len(), 2, "consecutive tools collapse into one sprint");
                assert_eq!(s[0], ToolUse { name: "Write".into(), arg: "src/a.rs".into() });
                assert_eq!(s[1], ToolUse { name: "Bash".into(), arg: "cargo build".into() });
            }
            other => panic!("expected a tool sprint, got {other:?}"),
        }
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn windows_to_last_n_turns() {
        let mut p = parser();
        for i in 0..6 {
            p.feed(format!("\nUSER> turn {i}\nAGENT> reply {i}\n").as_bytes());
        }
        let d = p.digest();
        assert_eq!(d.turns.len(), 3, "window keeps only the last N=3 turns");
        assert_eq!(d.turns[0].input.as_deref(), Some("turn 3"));
        assert_eq!(d.turns[2].input.as_deref(), Some("turn 5"));
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn truncates_tool_args_to_width() {
        let cfg = DigestConfig { window_turns: 3, arg_truncation: 10, ..Default::default() };
        let mut p = DigestParser::new(patterns(), cfg).unwrap();
        p.feed(b"\nUSER> x\nAGENT> y\nBash(this-is-a-very-long-command-line) z\n");
        let d = p.digest();
        let sprint = d.turns[0]
            .entries
            .iter()
            .find_map(|e| match e {
                DigestEntry::ToolSprint(s) => Some(s),
                _ => None,
            })
            .expect("a sprint");
        assert_eq!(sprint[0].name, "Bash");
        assert_eq!(sprint[0].arg, "this-is-a-…", "arg truncated to width with ellipsis");
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn strips_ansi_and_tolerates_repaint_duplication() {
        let mut p = parser();
        // A ConPTY-style repaint: clear + home + a duplicated earlier turn, then
        // the real latest turn. ANSI is stripped; the tail (last N) wins.
        p.feed(b"\nUSER> first\nAGENT> \x1b[2J\x1b[Hreply one\n");
        p.feed(b"\x1b[2J\x1b[H\nUSER> first\nAGENT> reply one\n\nUSER> second\nAGENT> reply two\x1b[0m\n");
        let d = p.digest();
        // No ANSI leaked into content.
        for t in &d.turns {
            if let Some(i) = &t.input {
                assert!(!i.contains('\x1b'), "ANSI stripped from input");
            }
            for e in &t.entries {
                if let DigestEntry::Agent(a) = e {
                    assert!(!a.contains('\x1b'), "ANSI stripped from agent text");
                    assert!(!a.contains("[2J"), "no CSI residue");
                }
            }
        }
        // The most recent turn is present and correct despite the repaint.
        let last = d.turns.last().unwrap();
        assert_eq!(last.input.as_deref(), Some("second"));
        assert_eq!(last.entries[0], DigestEntry::Agent("reply two".to_string()));
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn bad_pattern_is_reported_by_name() {
        let mut pats = patterns();
        pats.agent = r"(unclosed".to_string();
        let err = DigestParser::new(pats, DigestConfig::default()).unwrap_err();
        match err {
            DigestError::BadPattern { which, .. } => assert_eq!(which, "agent"),
        }
    }
}


The `boundary_rank`/`BoundaryKind` trait over a function-local enum won't compile. Inline the ranking.


Edit

        // Order by position; on a tie, boundaries before tools (a boundary opens
        // a span, tools sit inside it).
        hits.sort_by(|a, b| {
            a.0.cmp(&b.0).then_with(|| boundary_rank(&a.2).cmp(&boundary_rank(&b.2)))
        });

        // Order by position; on a tie, boundaries before tools (a boundary opens
        // a span, tools sit inside it).
        let rank = |k: &Kind| -> u8 { matches!(k, Kind::Tool(_)) as u8 };
        hits.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| rank(&a.2).cmp(&rank(&b.2))));


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

        // Order by position; on a tie, boundaries before tools (a boundary opens
        // a span, tools sit inside it).
        hits.sort_by(|a, b| {
            a.0.cmp(&b.0).then_with(|| boundary_rank(&a.2).cmp(&boundary_rank(&b.2)))
        });

        // Order by position; on a tie, boundaries before tools (a boundary opens
        // a span, tools sit inside it).
        let rank = |k: &Kind| -> u8 { matches!(k, Kind::Tool(_)) as u8 };
        hits.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| rank(&a.2).cmp(&rank(&b.2))));

-            a.0.cmp(&b.0).then_with(|| boundary_rank(&a.2).cmp(&boundary_rank(&b.2)))

+        let rank = |k: &Kind| -> u8 { matches!(k, Kind::Tool(_)) as u8 };

+        hits.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| rank(&a.2).cmp(&rank(&b.2))));

// Resolve boundary span content = text from this hit's end to the next


/// Boundaries sort before tools at the same offset.
fn boundary_rank(k: &impl BoundaryKind) -> u8 {
    if k.is_tool() {
        1
    } else {
        0
    }
}

trait BoundaryKind {
    fn is_tool(&self) -> bool;
}

/// Truncate `arg` to `width` characters, appending `…` when shortened.

/// Truncate `arg` to `width` characters, appending `…` when shortened.


/// Boundaries sort before tools at the same offset.
fn boundary_rank(k: &impl BoundaryKind) -> u8 {
    if k.is_tool() {
        1
    } else {
        0
    }
}

trait BoundaryKind {
    fn is_tool(&self) -> bool;
}

/// Truncate `arg` to `width` characters, appending `…` when shortened.

-/// Boundaries sort before tools at the same offset.

-fn boundary_rank(k: &impl BoundaryKind) -> u8 {

fn truncate_arg(arg: &str, width: usize) -> String {


Wire `digest` into lib.rs + clean up the test pattern helper.


            tools: vec![
                r"Write\((?P<arg>[^)]*)\)".to_string().replace("Write", "(?P<name>Write)"),
                r"(?P<name>Bash)\((?P<arg>[^)]*)\)".to_string(),
            ],


            tools: vec![
                r"Write\((?P<arg>[^)]*)\)".to_string().replace("Write", "(?P<name>Write)"),
                r"(?P<name>Bash)\((?P<arg>[^)]*)\)".to_string(),
            ],

-                r"Write\((?P<arg>[^)]*)\)".to_string().replace("Write", "(?P<name>Write)"),


//   A5  live activity buffer (PTY digest) parser primitive  (REQ-TERM-4)

pub mod pty;
pub mod reader;
pub mod stream;
pub mod surface;

pub use portable_pty::CommandBuilder;
pub use pty::PtySession;
pub use reader::Drain;
pub use stream::OutputStream;
pub use surface::{SessionSurface, SurfaceError, SurfaceSize};

//   A5  live activity buffer (PTY digest) parser primitive  (REQ-TERM-4)       ← landed

pub mod digest;
pub mod pty;
pub mod reader;
pub mod stream;
pub mod surface;

pub use digest::{
    Digest, DigestConfig, DigestEntry, DigestError, DigestParser, DigestPatterns, ToolUse, Turn,
};
pub use portable_pty::CommandBuilder;
pub use pty::PtySession;
pub use reader::Drain;
pub use stream::OutputStream;
pub use surface::{SessionSurface, SurfaceError, SurfaceSize};