    // [unit->REQ-TERM-4] the manifest seam compiles into a working engine, and a
    // first feed yields a full update with the parsed turn.
    #[test]
    fn from_manifest_feeds_and_snapshots() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
        let up = e.feed(b"\nUSER> hello\nAGENT> hi there\n").expect("changed");
        assert_eq!(up.version, 1);
        assert!(up.full, "first observed change is a full update");
        assert_eq!(up.turns.len(), 1);
        assert_eq!(up.turns[0].input.as_deref(), Some("hello"));
        assert_eq!(e.snapshot().turns.len(), 1);
    }

    // [unit->REQ-TERM-4] the manifest seam compiles into a working engine, and a
    // first feed yields an update (from=0) with the parsed turn.
    #[test]
    fn from_manifest_feeds_and_snapshots() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
        let up = e.feed(b"\nUSER> hello\nAGENT> hi there\n").expect("changed");
        assert_eq!(up.version, 1);
        assert_eq!(up.from, 0, "from an empty base, the change starts at index 0");
        assert_eq!(up.turns.len(), 1);
        assert_eq!(up.turns[0].input.as_deref(), Some("hello"));
        assert_eq!(e.snapshot().turns.len(), 1);
    }


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

    // [unit->REQ-TERM-4] the manifest seam compiles into a working engine, and a
    // first feed yields a full update with the parsed turn.
    #[test]
    fn from_manifest_feeds_and_snapshots() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
        let up = e.feed(b"\nUSER> hello\nAGENT> hi there\n").expect("changed");
        assert_eq!(up.version, 1);
        assert!(up.full, "first observed change is a full update");
        assert_eq!(up.turns.len(), 1);
        assert_eq!(up.turns[0].input.as_deref(), Some("hello"));
        assert_eq!(e.snapshot().turns.len(), 1);
    }

    // [unit->REQ-TERM-4] the manifest seam compiles into a working engine, and a
    // first feed yields an update (from=0) with the parsed turn.
    #[test]
    fn from_manifest_feeds_and_snapshots() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
        let up = e.feed(b"\nUSER> hello\nAGENT> hi there\n").expect("changed");
        assert_eq!(up.version, 1);
        assert_eq!(up.from, 0, "from an empty base, the change starts at index 0");
        assert_eq!(up.turns.len(), 1);
        assert_eq!(up.turns[0].input.as_deref(), Some("hello"));
        assert_eq!(e.snapshot().turns.len(), 1);
    }

-    // first feed yields a full update with the parsed turn.

+    // first feed yields an update (from=0) with the parsed turn.

-        assert!(up.full, "first observed change is a full update");

+        assert_eq!(up.from, 0, "from an empty base, the change starts at index 0");


Edit

    // [unit->REQ-TERM-4] the active turn growing in place is a TAIL delta (only
    // the changed last turn is sent), not a full re-push.
    #[test]
    fn active_turn_growth_is_a_tail_delta() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
        // Two settled turns + an active third.
        e.feed(b"\nUSER> a\nAGENT> 1\n").unwrap();
        e.feed(b"\nUSER> b\nAGENT> 2\n").unwrap();
        let up = e.feed(b"\nUSER> c\nAGENT> 3").unwrap();
        // The new turn 'c' appended: the differing tail is just that one turn.
        assert!(!up.full, "a pure append/extend is a tail delta");
        assert_eq!(up.turns.len(), 1);
        assert_eq!(up.turns.last().unwrap().input.as_deref(), Some("c"));
    }

    // [unit->REQ-TERM-4] when the rolling window slides (oldest turn evicted), the
    // update is FULL — tail offsets no longer align so the whole view is resent.
    #[test]
    fn window_slide_is_a_full_update() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // window 3
        for i in 0..3 {
            e.feed(format!("\nUSER> u{i}\nAGENT> r{i}\n").as_bytes()).unwrap();
        }
        // A 4th turn evicts u0 → the window frame shifts.
        let up = e.feed(b"\nUSER> u3\nAGENT> r3\n").unwrap();
        assert!(up.full, "a front eviction forces a full refresh");
        assert_eq!(up.turns.len(), 3, "the full current window");
        assert_eq!(up.turns[0].input.as_deref(), Some("u1"), "oldest is now u1");
    }

    // [unit->REQ-TERM-4] the active turn growing in place is a minimal tail delta:
    // `from` points past the unchanged head, `turns` is just the changed tail.
    #[test]
    fn active_turn_growth_is_a_tail_delta() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
        // Two settled turns + an active third.
        e.feed(b"\nUSER> a\nAGENT> 1\n").unwrap();
        e.feed(b"\nUSER> b\nAGENT> 2\n").unwrap();
        let up = e.feed(b"\nUSER> c\nAGENT> 3").unwrap();
        // 'a' and 'b' are unchanged head; only the new 'c' turn is sent.
        assert_eq!(up.from, 2, "the two settled turns are the unchanged head");
        assert_eq!(up.turns.len(), 1);
        assert_eq!(up.turns.last().unwrap().input.as_deref(), Some("c"));
    }

    // [unit->REQ-TERM-4] when the rolling window slides (oldest turn evicted), the
    // head no longer matches → from=0, the whole current window is resent.
    #[test]
    fn window_slide_resends_from_zero() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // window 3
        for i in 0..3 {
            e.feed(format!("\nUSER> u{i}\nAGENT> r{i}\n").as_bytes()).unwrap();
        }
        // A 4th turn evicts u0 → the window frame shifts.
        let up = e.feed(b"\nUSER> u3\nAGENT> r3\n").unwrap();
        assert_eq!(up.from, 0, "a front eviction resends from index 0");
        assert_eq!(up.turns.len(), 3, "the full current window");
        assert_eq!(up.turns[0].input.as_deref(), Some("u1"), "oldest is now u1");
    }

    // [unit->REQ-TERM-4] applying successive deltas (truncate-to-`from` + append)
    // reconstructs the engine's snapshot exactly — the subscriber stays in sync.
    #[test]
    fn applying_deltas_reconstructs_snapshot() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // window 3
        let mut view: Vec<Turn> = Vec::new();
        let feeds: &[&[u8]] = &[
            b"\nUSER> a\nAGENT> 1\n",
            b"\nUSER> b\nAGENT> 2",
            b" more\n",
            b"\nUSER> c\nAGENT> 3\n",
            b"\nUSER> d\nAGENT> 4\n", // evicts 'a' → from=0 refresh
        ];
        for f in feeds {
            if let Some(up) = e.feed(f) {
                view.truncate(up.from);
                view.extend(up.turns);
            }
            assert_eq!(view, e.snapshot().turns, "applied deltas track the snapshot");
        }
    }


NOTE fROM THE USER: proceed with next steps.


    // [unit->REQ-TERM-4] the active turn growing in place is a TAIL delta (only
    // the changed last turn is sent), not a full re-push.
    #[test]
    fn active_turn_growth_is_a_tail_delta() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
        // Two settled turns + an active third.
        e.feed(b"\nUSER> a\nAGENT> 1\n").unwrap();
        e.feed(b"\nUSER> b\nAGENT> 2\n").unwrap();
        let up = e.feed(b"\nUSER> c\nAGENT> 3").unwrap();
        // The new turn 'c' appended: the differing tail is just that one turn.
        assert!(!up.full, "a pure append/extend is a tail delta");
        assert_eq!(up.turns.len(), 1);
        assert_eq!(up.turns.last().unwrap().input.as_deref(), Some("c"));
    }

    // [unit->REQ-TERM-4] when the rolling window slides (oldest turn evicted), the
    // update is FULL — tail offsets no longer align so the whole view is resent.
    #[test]
    fn window_slide_is_a_full_update() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // window 3
        for i in 0..3 {
            e.feed(format!("\nUSER> u{i}\nAGENT> r{i}\n").as_bytes()).unwrap();
        }
        // A 4th turn evicts u0 → the window frame shifts.
        let up = e.feed(b"\nUSER> u3\nAGENT> r3\n").unwrap();
        assert!(up.full, "a front eviction forces a full refresh");
        assert_eq!(up.turns.len(), 3, "the full current window");
        assert_eq!(up.turns[0].input.as_deref(), Some("u1"), "oldest is now u1");
    }

    // [unit->REQ-TERM-4] the active turn growing in place is a minimal tail delta:
    // `from` points past the unchanged head, `turns` is just the changed tail.
    #[test]
    fn active_turn_growth_is_a_tail_delta() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
        // Two settled turns + an active third.
        e.feed(b"\nUSER> a\nAGENT> 1\n").unwrap();
        e.feed(b"\nUSER> b\nAGENT> 2\n").unwrap();
        let up = e.feed(b"\nUSER> c\nAGENT> 3").unwrap();
        // 'a' and 'b' are unchanged head; only the new 'c' turn is sent.
        assert_eq!(up.from, 2, "the two settled turns are the unchanged head");
        assert_eq!(up.turns.len(), 1);
        assert_eq!(up.turns.last().unwrap().input.as_deref(), Some("c"));
    }

    // [unit->REQ-TERM-4] when the rolling window slides (oldest turn evicted), the
    // head no longer matches → from=0, the whole current window is resent.
    #[test]
    fn window_slide_resends_from_zero() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // window 3
        for i in 0..3 {
            e.feed(format!("\nUSER> u{i}\nAGENT> r{i}\n").as_bytes()).unwrap();
        }
        // A 4th turn evicts u0 → the window frame shifts.
        let up = e.feed(b"\nUSER> u3\nAGENT> r3\n").unwrap();
        assert_eq!(up.from, 0, "a front eviction resends from index 0");
        assert_eq!(up.turns.len(), 3, "the full current window");
        assert_eq!(up.turns[0].input.as_deref(), Some("u1"), "oldest is now u1");
    }

    // [unit->REQ-TERM-4] applying successive deltas (truncate-to-`from` + append)
    // reconstructs the engine's snapshot exactly — the subscriber stays in sync.
    #[test]
    fn applying_deltas_reconstructs_snapshot() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // window 3
        let mut view: Vec<Turn> = Vec::new();
        let feeds: &[&[u8]] = &[
            b"\nUSER> a\nAGENT> 1\n",
            b"\nUSER> b\nAGENT> 2",
            b" more\n",
            b"\nUSER> c\nAGENT> 3\n",
            b"\nUSER> d\nAGENT> 4\n", // evicts 'a' → from=0 refresh
        ];
        for f in feeds {
            if let Some(up) = e.feed(f) {
                view.truncate(up.from);
                view.extend(up.turns);
            }
            assert_eq!(view, e.snapshot().turns, "applied deltas track the snapshot");
        }
    }

assert_eq!(e.version(), v, "version did not advance on a no-op");

-    // [unit->REQ-TERM-4] the active turn growing in place is a TAIL delta (only

-    // the changed last turn is sent), not a full re-push.

+    // [unit->REQ-TERM-4] the active turn growing in place is a minimal tail delta:

+    // `from` points past the unchanged head, `turns` is just the changed tail.

-        // The new turn 'c' appended: the differing tail is just that one turn.

-        assert!(!up.full, "a pure append/extend is a tail delta");

+        // 'a' and 'b' are unchanged head; only the new 'c' turn is sent.

+        assert_eq!(up.from, 2, "the two settled turns are the unchanged head");

-    // update is FULL — tail offsets no longer align so the whole view is resent.

+    // head no longer matches → from=0, the whole current window is resent.

-    fn window_slide_is_a_full_update() {

+    fn window_slide_resends_from_zero() {

-        assert!(up.full, "a front eviction forces a full refresh");

+        assert_eq!(up.from, 0, "a front eviction resends from index 0");

+    // [unit->REQ-TERM-4] applying successive deltas (truncate-to-`from` + append)

+    // reconstructs the engine's snapshot exactly — the subscriber stays in sync.

+    fn applying_deltas_reconstructs_snapshot() {

+        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // window 3

+        let mut view: Vec<Turn> = Vec::new();

+            b"\nUSER> d\nAGENT> 4\n", // evicts 'a' → from=0 refresh

+            if let Some(up) = e.feed(f) {

+                view.truncate(up.from);

+            assert_eq!(view, e.snapshot().turns, "applied deltas track the snapshot");

// [unit->REQ-TERM-4] collapsed tool sprints survive into the structured turn

// (the digest is structured, source-tagged — not flat text).


Now fix the hub: `subscribe` base send + tests use `full`/`from`:


                if version >= req.from_version {
                    let base_update = DigestUpdate {
                        version,
                        full: true,
                        turns: base.turns,
                        newly_completed: Vec::new(),
                    };
                    write_frame(&mut conn, &delta_frame(&base_update))?;
                }

                if version >= req.from_version {
                    let base_update = DigestUpdate {
                        version,
                        from: 0, // a fresh subscriber replaces its (empty) view
                        turns: base.turns,
                        newly_completed: Vec::new(),
                    };
                    write_frame(&mut conn, &delta_frame(&base_update))?;
                }


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

                if version >= req.from_version {
                    let base_update = DigestUpdate {
                        version,
                        full: true,
                        turns: base.turns,
                        newly_completed: Vec::new(),
                    };
                    write_frame(&mut conn, &delta_frame(&base_update))?;
                }

                if version >= req.from_version {
                    let base_update = DigestUpdate {
                        version,
                        from: 0, // a fresh subscriber replaces its (empty) view
                        turns: base.turns,
                        newly_completed: Vec::new(),
                    };
                    write_frame(&mut conn, &delta_frame(&base_update))?;
                }

+                        from: 0, // a fresh subscriber replaces its (empty) view


    fn an_update(version: u64) -> DigestUpdate {
        DigestUpdate { version, full: true, turns: vec![turn("hi")], newly_completed: Vec::new() }
    }

    fn an_update(version: u64) -> DigestUpdate {
        DigestUpdate { version, from: 0, turns: vec![turn("hi")], newly_completed: Vec::new() }
    }


    fn an_update(version: u64) -> DigestUpdate {
        DigestUpdate { version, full: true, turns: vec![turn("hi")], newly_completed: Vec::new() }
    }

    fn an_update(version: u64) -> DigestUpdate {
        DigestUpdate { version, from: 0, turns: vec![turn("hi")], newly_completed: Vec::new() }
    }

-        DigestUpdate { version, full: true, turns: vec![turn("hi")], newly_completed: Vec::new() }

+        DigestUpdate { version, from: 0, turns: vec![turn("hi")], newly_completed: Vec::new() }

// [unit->REQ-TERM-4] publish stores the latest snapshot; a pull reads it back.


Bash

cargo test -p spt-daemon --lib digest 2>&1 | tail -25

Run digest engine + hub unit tests


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

running 11 tests
test digesthub::tests::subscribe_returns_current_base ... ok
test digesthub::tests::publish_then_snapshot_reads_latest ... ok
test digesthub::tests::subscriber_receives_changes_then_is_pruned ... ok
test digest::tests::persistence_off_surfaces_nothing ... ok
test digest::tests::unchanged_feed_emits_no_delta ... ok
test digest::tests::from_manifest_feeds_and_snapshots ... ok
test digest::tests::active_turn_growth_is_a_tail_delta ... ok
test digest::tests::window_slide_resends_from_zero ... ok
test digest::tests::applying_deltas_reconstructs_snapshot ... ok
test digest::tests::tool_sprint_is_structured_in_the_turn ... ok
test digest::tests::persistence_reports_each_completed_turn_once ... ok

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


Engine + hub green. **Step 4 — the reusable feed bridge** (`ingest_chunk`) + `Brain::run_digest_feed`. Add the bridge to digest.rs:


Edit

use serde::{Deserialize, Serialize};

use spt_runtime::PtyDigest;
use spt_term::{Digest, DigestConfig, DigestError, DigestParser, DigestPatterns, Turn};

use serde::{Deserialize, Serialize};

use spt_runtime::PtyDigest;
use spt_store::perch::{self, ParentHint};
use spt_term::{Digest, DigestConfig, DigestError, DigestParser, DigestPatterns, Turn};

use crate::digesthub::DigestHub;


use serde::{Deserialize, Serialize};

use spt_runtime::PtyDigest;
use spt_term::{Digest, DigestConfig, DigestError, DigestParser, DigestPatterns, Turn};

use serde::{Deserialize, Serialize};

use spt_runtime::PtyDigest;
use spt_store::perch::{self, ParentHint};
use spt_term::{Digest, DigestConfig, DigestError, DigestParser, DigestPatterns, Turn};

use crate::digesthub::DigestHub;

/// A structured digest change to push to a subscriber. Sent only when the digest

/// actually changed (so the stream carries deltas, not heartbeats).

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


/// Length of the equal-by-value prefix shared by two turn slices.
fn common_prefix_len(a: &[Turn], b: &[Turn]) -> usize {
    a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
}

/// Length of the equal-by-value prefix shared by two turn slices.
fn common_prefix_len(a: &[Turn], b: &[Turn]) -> usize {
    a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
}

/// Feed one PTY-output chunk for `endpoint_id` through `engine`, publish any
/// resulting change to `hub`, and — when the engine opted into persistence —
/// append each newly-completed turn to the endpoint's Path-B history store. This
/// is the single per-chunk bridge the brain's digest feed
/// ([`crate::brain::Brain::run_digest_feed`]) and any in-process daemon feed
/// share, so the publish + persist behavior lives in exactly one place.
// [impl->REQ-TERM-4]
pub fn ingest_chunk(engine: &mut DigestEngine, hub: &DigestHub, endpoint_id: &str, bytes: &[u8]) {
    let Some(update) = engine.feed(bytes) else {
        return; // unchanged — nothing to publish or persist
    };
    if engine.persist() {
        for turn in &update.newly_completed {
            persist_turn(endpoint_id, turn);
        }
    }
    hub.publish(endpoint_id, engine.snapshot(), update);
}

/// Append one completed turn to `endpoint_id`'s Path-B history store as a coarse
/// digest-activity record (ADR-0008 "Option (C)"). Best-effort: a store error is
/// swallowed — the digest log is a convenience, never load-bearing.
fn persist_turn(endpoint_id: &str, turn: &Turn) {
    let perch_path = perch::resolve_perch_path(endpoint_id, ParentHint::Infer);
    let record = serde_json::json!({ "kind": "digest_turn", "turn": turn });
    let _ = spt_store::history::append_record(&perch_path, &record.to_string());
}


/// Length of the equal-by-value prefix shared by two turn slices.
fn common_prefix_len(a: &[Turn], b: &[Turn]) -> usize {
    a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
}

/// Feed one PTY-output chunk for `endpoint_id` through `engine`, publish any
/// resulting change to `hub`, and — when the engine opted into persistence —
/// append each newly-completed turn to the endpoint's Path-B history store. This
/// is the single per-chunk bridge the brain's digest feed
/// ([`crate::brain::Brain::run_digest_feed`]) and any in-process daemon feed
/// share, so the publish + persist behavior lives in exactly one place.
// [impl->REQ-TERM-4]
pub fn ingest_chunk(engine: &mut DigestEngine, hub: &DigestHub, endpoint_id: &str, bytes: &[u8]) {
    let Some(update) = engine.feed(bytes) else {
        return; // unchanged — nothing to publish or persist
    };
    if engine.persist() {
        for turn in &update.newly_completed {
            persist_turn(endpoint_id, turn);
        }
    }
    hub.publish(endpoint_id, engine.snapshot(), update);
}

/// Append one completed turn to `endpoint_id`'s Path-B history store as a coarse
/// digest-activity record (ADR-0008 "Option (C)"). Best-effort: a store error is
/// swallowed — the digest log is a convenience, never load-bearing.
fn persist_turn(endpoint_id: &str, turn: &Turn) {
    let perch_path = perch::resolve_perch_path(endpoint_id, ParentHint::Infer);
    let record = serde_json::json!({ "kind": "digest_turn", "turn": turn });
    let _ = spt_store::history::append_record(&perch_path, &record.to_string());
}

+/// Feed one PTY-output chunk for `endpoint_id` through `engine`, publish any

+/// resulting change to `hub`, and — when the engine opted into persistence —

+/// append each newly-completed turn to the endpoint's Path-B history store. This

+/// is the single per-chunk bridge the brain's digest feed

+/// ([`crate::brain::Brain::run_digest_feed`]) and any in-process daemon feed

+/// share, so the publish + persist behavior lives in exactly one place.

+pub fn ingest_chunk(engine: &mut DigestEngine, hub: &DigestHub, endpoint_id: &str, bytes: &[u8]) {

+    let Some(update) = engine.feed(bytes) else {

+        return; // unchanged — nothing to publish or persist

+        for turn in &update.newly_completed {

+            persist_turn(endpoint_id, turn);

+    hub.publish(endpoint_id, engine.snapshot(), update);

+/// Append one completed turn to `endpoint_id`'s Path-B history store as a coarse

+/// digest-activity record (ADR-0008 "Option (C)"). Best-effort: a store error is

+/// swallowed — the digest log is a convenience, never load-bearing.

+fn persist_turn(endpoint_id: &str, turn: &Turn) {

+    let perch_path = perch::resolve_perch_path(endpoint_id, ParentHint::Infer);

+    let record = serde_json::json!({ "kind": "digest_turn", "turn": turn });

+    let _ = spt_store::history::append_record(&perch_path, &record.to_string());


Now add `attach` + `run_digest_feed` to brain.rs:


use std::io;
use std::time::{SystemTime, UNIX_EPOCH};

use interprocess::local_socket::Stream;

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

use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

use interprocess::local_socket::Stream;

use crate::codec::{read_frame, write_frame};
use crate::digest::{ingest_chunk, DigestEngine};
use crate::digesthub::DigestHub;
use crate::frame::{Envelope, Role};


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

use std::io;
use std::time::{SystemTime, UNIX_EPOCH};

use interprocess::local_socket::Stream;

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

use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

use interprocess::local_socket::Stream;

use crate::codec::{read_frame, write_frame};
use crate::digest::{ingest_chunk, DigestEngine};
use crate::digesthub::DigestHub;
use crate::frame::{Envelope, Role};

decode_bytes, encode_bytes, AppliedEvent, ErrorEvent, ExitEvent, InputReq, KillReq, OutputEvent,


    /// Re-attach to a session's output from `from_seq` (handoff resume).
    fn subscribe(&mut self, session_id: u64, from_seq: u64) -> io::Result<()> {

    /// Attach this (freshly-connected) brain to an **existing** broker session's
    /// output from `from_seq`, e.g. a dedicated digest-feed subscriber that did
    /// not spawn the session itself. Sets the resume cursor + session, then
    /// subscribes (the broker replays from `from_seq`, then streams live). Unlike
    /// [`Brain::handoff`] this does not touch the generation — it is a *second*
    /// reader of a session another brain drives, not a logic handoff.
    pub fn attach(&mut self, session_id: u64, from_seq: u64) -> io::Result<()> {
        self.session_id = Some(session_id);
        self.next_seq = from_seq;
        self.subscribe(session_id, from_seq)
    }

    /// Run the **PTY-digest feed** for `endpoint_id` (B8): read this session's
    /// output events and fold each chunk through `engine`, publishing every change
    /// to `hub` (and persisting completed turns when the engine opted in) via
    /// [`ingest_chunk`]. This is the brain-side digest projection — the parse is
    /// logic, so it lives here, never in the broker (ADR-0004 §B). Returns when
    /// the session exits, `stop` is set (checked between events), or the broker
    /// connection drops. A non-output event other than `exit` is ignored (the
    /// feed only cares about the byte stream).
    // [impl->REQ-TERM-4]
    pub fn run_digest_feed(
        &mut self,
        engine: &mut DigestEngine,
        hub: &DigestHub,
        endpoint_id: &str,
        stop: &AtomicBool,
    ) -> io::Result<()> {
        loop {
            if stop.load(Ordering::Relaxed) {
                return Ok(());
            }
            match self.read_event()? {
                BrokerEvent::Output { bytes, .. } => {
                    ingest_chunk(engine, hub, endpoint_id, &bytes)
                }
                BrokerEvent::Exit { .. } => return Ok(()),
                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
                _ => {}
            }
        }
    }

    /// Re-attach to a session's output from `from_seq` (handoff resume).
    fn subscribe(&mut self, session_id: u64, from_seq: u64) -> io::Result<()> {


    /// Attach this (freshly-connected) brain to an **existing** broker session's
    /// output from `from_seq`, e.g. a dedicated digest-feed subscriber that did
    /// not spawn the session itself. Sets the resume cursor + session, then
    /// subscribes (the broker replays from `from_seq`, then streams live). Unlike
    /// [`Brain::handoff`] this does not touch the generation — it is a *second*
    /// reader of a session another brain drives, not a logic handoff.
    pub fn attach(&mut self, session_id: u64, from_seq: u64) -> io::Result<()> {
        self.session_id = Some(session_id);
        self.next_seq = from_seq;
        self.subscribe(session_id, from_seq)
    }

    /// Run the **PTY-digest feed** for `endpoint_id` (B8): read this session's
    /// output events and fold each chunk through `engine`, publishing every change
    /// to `hub` (and persisting completed turns when the engine opted in) via
    /// [`ingest_chunk`]. This is the brain-side digest projection — the parse is
    /// logic, so it lives here, never in the broker (ADR-0004 §B). Returns when
    /// the session exits, `stop` is set (checked between events), or the broker
    /// connection drops. A non-output event other than `exit` is ignored (the
    /// feed only cares about the byte stream).
    // [impl->REQ-TERM-4]
    pub fn run_digest_feed(
        &mut self,
        engine: &mut DigestEngine,
        hub: &DigestHub,
        endpoint_id: &str,
        stop: &AtomicBool,
    ) -> io::Result<()> {
        loop {
            if stop.load(Ordering::Relaxed) {
                return Ok(());
            }
            match self.read_event()? {
                BrokerEvent::Output { bytes, .. } => {
                    ingest_chunk(engine, hub, endpoint_id, &bytes)
                }
                BrokerEvent::Exit { .. } => return Ok(()),
                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
                _ => {}
            }
        }
    }

    /// Re-attach to a session's output from `from_seq` (handoff resume).
    fn subscribe(&mut self, session_id: u64, from_seq: u64) -> io::Result<()> {

+    /// Attach this (freshly-connected) brain to an **existing** broker session's

+    /// output from `from_seq`, e.g. a dedicated digest-feed subscriber that did

+    /// not spawn the session itself. Sets the resume cursor + session, then

+    /// subscribes (the broker replays from `from_seq`, then streams live). Unlike

+    /// [`Brain::handoff`] this does not touch the generation — it is a *second*

+    /// reader of a session another brain drives, not a logic handoff.

+    pub fn attach(&mut self, session_id: u64, from_seq: u64) -> io::Result<()> {

+        self.session_id = Some(session_id);

+        self.subscribe(session_id, from_seq)

+    /// Run the **PTY-digest feed** for `endpoint_id` (B8): read this session's

+    /// output events and fold each chunk through `engine`, publishing every change

+    /// to `hub` (and persisting completed turns when the engine opted in) via

+    /// [`ingest_chunk`]. This is the brain-side digest projection — the parse is

+    /// logic, so it lives here, never in the broker (ADR-0004 §B). Returns when

+    /// the session exits, `stop` is set (checked between events), or the broker

+    /// connection drops. A non-output event other than `exit` is ignored (the

+    /// feed only cares about the byte stream).

+            if stop.load(Ordering::Relaxed) {

+                BrokerEvent::Output { bytes, .. } => {

+                    ingest_chunk(engine, hub, endpoint_id, &bytes)

+                BrokerEvent::Exit { .. } => return Ok(()),

+                BrokerEvent::Error { message } => return Err(io::Error::other(message)),


Now lib.rs exports for the new public surface + daemon wiring (bind digest control):


pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::DaemonConfig;
pub use daemon::{ensure_running, is_running, Daemon};
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use endpoint::{broker_socket_name, daemon_pid_path, seed_socket_name};

pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::DaemonConfig;
pub use daemon::{ensure_running, is_running, Daemon};
pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
pub use digesthub::{follow, pull_snapshot, serve_digest_control, DigestHub};
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};


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

pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::DaemonConfig;
pub use daemon::{ensure_running, is_running, Daemon};
pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
pub use digesthub::{follow, pull_snapshot, serve_digest_control, DigestHub};
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};

//! `spt-daemon` — the per-machine broker/brain supervisor (M3b).
//!
//! ADR-0004 splits the logical daemon into two implementation layers so a
//! routine self-update swaps the logic without disturbing live endpoints:
//!
//! - a **broker** (stable kernel) holds *only* the un-transferable resources a
//!   live consumer would lose continuity on if the logic restarted — PTY
//!   masters (from [`spt_term`]), spawned harness children, accepted/listening
//!   sockets — and runs no logic;
//! - a **brain** (restartable logic) holds routing, registry, manifest parse,
//!   and the re-hosted [`spt_live`] lifecycle, attaching to the broker over a
//!   **versioned local IPC** so a *newer brain talks to an older broker*.
//!
//! **B0 (this commit)** lands the crate's layer position plus that versioned
//! IPC seam — the load-bearing contract everything else rides:
//!
//! - [`frame`] — the forward-compatible [`Envelope`] schema + the version
//!   [`Hello`] handshake (KNOWN-HAZARDS 2.3, [`REQ-HAZARD-HANDOFF-ARGV-COMPAT`]);
//! - [`codec`] — length-prefixed JSON framing over any [`std::io::Read`]/`Write`;
//! - [`transport`] — the OS-split carrier (named pipe / UDS) behind one
//!   [`DaemonTransport`] trait, mirroring `spt-term`'s `SessionSurface`.
//!
//! **B1 (this commit)** adds the **broker kernel** ([`broker`]) — the stable
//! half that hosts a real `spt-term` PTY child and relays its output/input over
//! the IPC, holding only the ADR-0004 §B resources and no logic — plus the
//! broker↔brain message catalog ([`msg`]).
//!
//! **B3 (this commit)** consolidates the interim no-daemon model into the brain:
//! the `api listen` pulse / Psyche / drop-ingest loops become scheduled
//! in-process loops ([`lifecycle::BrainLifecycle`]) paced by a durable,
//! configurable period ([`config::DaemonConfig`]) instead of the interim 5s
//! constant; the only residue left in the harness-hosted session is the thin,
//! stateless [`relay::Relay`]. The `spt` binary's `LiveHost` becomes a shim over
//! `BrainLifecycle` (re-host, not rewrite) ([`REQ-DAEMON-1`]).
//!
//! **B5 (this commit)** makes the broker↔brain boundary **exactly-once across a
//! brain crash** ([`effect::EffectJournal`]): an idempotent `input` carries a
//! durable `op_id`, and the broker dedups at the effect keyed by that id over a
//! broker-owned recovery anchor (KNOWN-HAZARDS 7.2, [`REQ-HAZARD-RESTART-IDEMPOTENT`];
//! Spike #6's two constraints — broker-owned anchor, dedup-at-effect-by-id —
//! instantiated on the real PTY-write boundary).
//!
//! The brain + gapless handoff (B2) and the rest of the M3b task table build on
//! this seam. `spt-daemon` is internal supervisor machinery, **not** public SDK
//! (R-ARCH-2).
//!
//! [`Envelope`]: frame::Envelope
//! [`Hello`]: frame::Hello
//! [`DaemonTransport`]: transport::DaemonTransport

pub mod brain;
pub mod broker;
pub mod codec;
pub mod config;
pub mod daemon;
pub mod digest;
pub mod digesthub;
pub mod effect;
pub mod endpoint;
pub mod frame;
pub mod lifecycle;
pub mod msg;
pub mod relay;
pub mod seedmap;
pub mod transport;

#[cfg(test)]
mod test_home;

pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::DaemonConfig;
pub use daemon::{ensure_running, is_running, Daemon};
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use endpoint::{broker_socket_name, daemon_pid_path, seed_socket_name};
pub use lifecycle::{BrainLifecycle, TickReport};
pub use relay::Relay;
pub use seedmap::{put_seed, take_seed, SeedRegistry};
pub use frame::{
    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
    MIN_COMPATIBLE_VERSION,
};
pub use transport::{DaemonTransport, LocalSocketTransport};

+pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};

+pub use digesthub::{follow, pull_snapshot, serve_digest_control, DigestHub};

-pub use endpoint::{broker_socket_name, daemon_pid_path, seed_socket_name};

+pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};


Wire digest-control into `Daemon::run`:


use crate::broker::Broker;
use crate::endpoint::{broker_socket_name, daemon_pid_path, seed_socket_name};
use crate::seedmap::{self, SeedRegistry};

use crate::broker::Broker;
use crate::digesthub::{self, DigestHub};
use crate::endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
use crate::seedmap::{self, SeedRegistry};


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

use crate::broker::Broker;
use crate::digesthub::{self, DigestHub};
use crate::endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
use crate::seedmap::{self, SeedRegistry};

//! The daemon process entrypoint + auto-start (B6).
//!
//! [`Daemon::run`] is the body of the hidden `spt daemon` subcommand: it binds
//! the broker (B1/B2 PTY hosting) and the seed-control channel (B6) under the
//! canonical per-home names ([`crate::endpoint`]) and serves both for the life
//! of the process.
//!
//! [`ensure_running`] is the auto-start seam (REQ-DAEMON-3): any `api`
//! invocation calls it; if no daemon answers the seed-control ping it spawns
//! `spt daemon` detached and waits (bounded) for it to come up. The reliable
//! anchor is the seed channel — bound for the whole daemon lifetime, so a ping
//! against it is a true liveness test (`listen` is the interim model's anchor;
//! here the seed channel plays that role for cold-start detection).
// [impl->REQ-DAEMON-3]

use std::io;
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use crate::broker::Broker;
use crate::endpoint::{broker_socket_name, daemon_pid_path, seed_socket_name};
use crate::seedmap::{self, SeedRegistry};

/// How long auto-start waits for a freshly-spawned daemon to bind its channel.
const START_TIMEOUT: Duration = Duration::from_secs(5);
/// Poll cadence while waiting for the spawned daemon to come up.
const START_POLL: Duration = Duration::from_millis(25);

/// The per-machine broker/brain daemon process.
pub struct Daemon;

impl Daemon {
    /// Run the daemon: bind the broker (served on a background thread) + the
    /// seed-control channel (the foreground loop + liveness anchor) and serve
    /// until the process is killed. The body of `spt daemon`.
    pub fn run() -> io::Result<()> {
        let reg = Arc::new(SeedRegistry::new());

        // The broker hosts PTYs (B1/B2). Serve it on a background thread; a bind
        // failure there must not stop the seed channel (the B6 surface) — the
        // PTY-hosting consumers arrive in B7/B8.
        match Broker::bind(&broker_socket_name()) {
            Ok(broker) => {
                thread::spawn(move || {
                    let _ = broker.serve();
                });
            }
            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
        }

        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
        // is the socket ping, never this file, so a stale value is harmless.
        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());

        // The seed-control channel is the foreground loop. Binding it is what
        // arbitrates the single-daemon-per-home invariant: a second daemon racing
        // to start fails this bind and exits, leaving the winner serving.
        seedmap::serve_seed_control(&seed_socket_name(), reg)
    }
}

/// Whether a daemon is already serving the seed-control channel for this home.
pub fn is_running() -> bool {
    seedmap::ping(&seed_socket_name()).is_ok()
}

/// Auto-start (REQ-DAEMON-3): ensure a daemon is up for this home. A no-op if one
/// already answers; otherwise spawn `spt daemon` detached and wait (bounded) for
/// it to bind. Idempotent + race-tolerant: if two callers spawn at once, one
/// wins the bind and the other exits — and this check passes either way.
pub fn ensure_running() -> io::Result<()> {
    if is_running() {
        return Ok(());
    }
    spawn_detached()?;
    let mut waited = Duration::ZERO;
    while waited < START_TIMEOUT {
        if is_running() {
            return Ok(());
        }
        thread::sleep(START_POLL);
        waited += START_POLL;
    }
    Err(io::Error::new(
        io::ErrorKind::TimedOut,
        "daemon did not come up within the start window",
    ))
}

/// Spawn `spt daemon` as a detached background process. Inherits the environment
/// — so the child resolves the **same** `SPT_HOME`, hence the same canonical
/// socket names — but drops the std handles so it outlives the launching CLI
/// call. The child is independent (not killed when the parent exits): on unix it
/// is reparented to init when the launcher exits; on Windows a child is not in
/// the parent's job by default.
///
/// **Windows handle-inheritance guard.** A caller may capture this process's
/// stdout/stderr through a pipe (`Command::output()`, a harness hook reading the
/// command's output). On Windows `CreateProcess` is invoked with
/// `bInheritHandles = TRUE`, so the long-lived daemon would inherit that pipe's
/// write end and hold it open forever — the capturing reader never sees EOF and
/// hangs. Unix is immune (Rust marks pipe fds `CLOEXEC`). So before spawning we
/// strip `HANDLE_FLAG_INHERIT` from this process's std handles; the daemon then
/// cannot inherit them, the pipe closes when *this* process exits, and the
/// caller's read completes. Stripping the flag does not affect this process's own
/// use of its std handles — only what a child inherits.
fn spawn_detached() -> io::Result<()> {
    #[cfg(windows)]
    block_std_handle_inheritance();
    let exe = std::env::current_exe()?;
    let mut cmd = Command::new(exe);
    cmd.arg("daemon")
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null());
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW: no
        // inherited console, no popup window, its own signal group.
        cmd.creation_flags(0x0000_0008 | 0x0000_0200 | 0x0800_0000);
    }
    cmd.spawn()?;
    Ok(())
}

/// Clear `HANDLE_FLAG_INHERIT` on this process's std handles so a spawned child
/// (the daemon) cannot inherit a pipe a caller is capturing. Declared inline to
/// avoid a winapi dependency for these two calls.
#[cfg(windows)]
fn block_std_handle_inheritance() {
    extern "system" {
        fn GetStdHandle(nStdHandle: u32) -> isize;
        fn SetHandleInformation(hObject: isize, dwMask: u32, dwFlags: u32) -> i32;
    }
    const STD_INPUT_HANDLE: u32 = 0xFFFF_FFF6; // -10
    const STD_OUTPUT_HANDLE: u32 = 0xFFFF_FFF5; // -11
    const STD_ERROR_HANDLE: u32 = 0xFFFF_FFF4; // -12
    const HANDLE_FLAG_INHERIT: u32 = 0x1;
    const INVALID: isize = -1;
    unsafe {
        for which in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
            let h = GetStdHandle(which);
            if h != 0 && h != INVALID {
                // Best-effort: a console handle that rejects the call is harmless.
                SetHandleInformation(h, HANDLE_FLAG_INHERIT, 0);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::seedmap::{serve_seed_control, SeedRegistry};
    use crate::test_home::with_home;

    // [unit->REQ-DAEMON-3] with no daemon up, is_running() is false; once a
    // seed-control server answers, it flips true and ensure_running() is a no-op
    // (the spawn branch — which would need the real `spt` binary — is not taken).
    #[test]
    fn is_running_tracks_the_seed_channel() {
        with_home(|_| {
            assert!(!is_running(), "no daemon bound yet");

            let reg = Arc::new(SeedRegistry::new());
            let name = seed_socket_name();
            let srv = Arc::clone(&reg);
            thread::spawn(move || {
                let _ = serve_seed_control(&name, srv);
            });
            for _ in 0..400 {
                if is_running() {
                    break;
                }
                thread::sleep(Duration::from_millis(5));
            }
            assert!(is_running(), "the seed channel answers → daemon is up");
            // ensure_running short-circuits (no real process spawn).
            ensure_running().expect("ensure is a no-op when already up");
        });
    }
}

-use crate::endpoint::{broker_socket_name, daemon_pid_path, seed_socket_name};

+use crate::endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};


        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
        // is the socket ping, never this file, so a stale value is harmless.
        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());

        // The digest hub (B8) holds the live per-endpoint PTY-digest projections;
        // the digest-control channel serves `spt digest <id>` snapshot pulls +
        // delta-stream subscribes against it. Served on a background thread like
        // the broker. The per-session feed that *populates* the hub is driven by
        // the brain (the parse is logic — ADR-0004 §B), wired as daemon-hosted
        // sessions are spawned through the broker; a bare daemon serves the
        // channel and answers "no live digest" until a session is hosted.
        let digest_hub = Arc::new(DigestHub::new());
        {
            let hub = Arc::clone(&digest_hub);
            thread::spawn(move || {
                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);
            });
        }

        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
        // is the socket ping, never this file, so a stale value is harmless.
        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());


        // The digest hub (B8) holds the live per-endpoint PTY-digest projections;
        // the digest-control channel serves `spt digest <id>` snapshot pulls +
        // delta-stream subscribes against it. Served on a background thread like
        // the broker. The per-session feed that *populates* the hub is driven by
        // the brain (the parse is logic — ADR-0004 §B), wired as daemon-hosted
        // sessions are spawned through the broker; a bare daemon serves the
        // channel and answers "no live digest" until a session is hosted.
        let digest_hub = Arc::new(DigestHub::new());
        {
            let hub = Arc::clone(&digest_hub);
            thread::spawn(move || {
                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);
            });
        }

        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
        // is the socket ping, never this file, so a stale value is harmless.
        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());

//! The daemon process entrypoint + auto-start (B6).
//!
//! [`Daemon::run`] is the body of the hidden `spt daemon` subcommand: it binds
//! the broker (B1/B2 PTY hosting) and the seed-control channel (B6) under the
//! canonical per-home names ([`crate::endpoint`]) and serves both for the life
//! of the process.
//!
//! [`ensure_running`] is the auto-start seam (REQ-DAEMON-3): any `api`
//! invocation calls it; if no daemon answers the seed-control ping it spawns
//! `spt daemon` detached and waits (bounded) for it to come up. The reliable
//! anchor is the seed channel — bound for the whole daemon lifetime, so a ping
//! against it is a true liveness test (`listen` is the interim model's anchor;
//! here the seed channel plays that role for cold-start detection).
// [impl->REQ-DAEMON-3]

use std::io;
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use crate::broker::Broker;
use crate::digesthub::{self, DigestHub};
use crate::endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
use crate::seedmap::{self, SeedRegistry};

/// How long auto-start waits for a freshly-spawned daemon to bind its channel.
const START_TIMEOUT: Duration = Duration::from_secs(5);
/// Poll cadence while waiting for the spawned daemon to come up.
const START_POLL: Duration = Duration::from_millis(25);

/// The per-machine broker/brain daemon process.
pub struct Daemon;

impl Daemon {
    /// Run the daemon: bind the broker (served on a background thread) + the
    /// seed-control channel (the foreground loop + liveness anchor) and serve
    /// until the process is killed. The body of `spt daemon`.
    pub fn run() -> io::Result<()> {
        let reg = Arc::new(SeedRegistry::new());

        // The broker hosts PTYs (B1/B2). Serve it on a background thread; a bind
        // failure there must not stop the seed channel (the B6 surface) — the
        // PTY-hosting consumers arrive in B7/B8.
        match Broker::bind(&broker_socket_name()) {
            Ok(broker) => {
                thread::spawn(move || {
                    let _ = broker.serve();
                });
            }
            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
        }

        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
        // is the socket ping, never this file, so a stale value is harmless.
        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());

        // The seed-control channel is the foreground loop. Binding it is what
        // arbitrates the single-daemon-per-home invariant: a second daemon racing
        // to start fails this bind and exits, leaving the winner serving.
        seedmap::serve_seed_control(&seed_socket_name(), reg)
    }
}

/// Whether a daemon is already serving the seed-control channel for this home.
pub fn is_running() -> bool {
    seedmap::ping(&seed_socket_name()).is_ok()
}

/// Auto-start (REQ-DAEMON-3): ensure a daemon is up for this home. A no-op if one
/// already answers; otherwise spawn `spt daemon` detached and wait (bounded) for
/// it to bind. Idempotent + race-tolerant: if two callers spawn at once, one
/// wins the bind and the other exits — and this check passes either way.
pub fn ensure_running() -> io::Result<()> {
    if is_running() {
        return Ok(());
    }
    spawn_detached()?;
    let mut waited = Duration::ZERO;
    while waited < START_TIMEOUT {
        if is_running() {
            return Ok(());
        }
        thread::sleep(START_POLL);
        waited += START_POLL;
    }
    Err(io::Error::new(
        io::ErrorKind::TimedOut,
        "daemon did not come up within the start window",
    ))
}

/// Spawn `spt daemon` as a detached background process. Inherits the environment
/// — so the child resolves the **same** `SPT_HOME`, hence the same canonical
/// socket names — but drops the std handles so it outlives the launching CLI
/// call. The child is independent (not killed when the parent exits): on unix it
/// is reparented to init when the launcher exits; on Windows a child is not in
/// the parent's job by default.
///
/// **Windows handle-inheritance guard.** A caller may capture this process's
/// stdout/stderr through a pipe (`Command::output()`, a harness hook reading the
/// command's output). On Windows `CreateProcess` is invoked with
/// `bInheritHandles = TRUE`, so the long-lived daemon would inherit that pipe's
/// write end and hold it open forever — the capturing reader never sees EOF and
/// hangs. Unix is immune (Rust marks pipe fds `CLOEXEC`). So before spawning we
/// strip `HANDLE_FLAG_INHERIT` from this process's std handles; the daemon then
/// cannot inherit them, the pipe closes when *this* process exits, and the
/// caller's read completes. Stripping the flag does not affect this process's own
/// use of its std handles — only what a child inherits.
fn spawn_detached() -> io::Result<()> {
    #[cfg(windows)]
    block_std_handle_inheritance();
    let exe = std::env::current_exe()?;
    let mut cmd = Command::new(exe);
    cmd.arg("daemon")
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null());
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW: no
        // inherited console, no popup window, its own signal group.
        cmd.creation_flags(0x0000_0008 | 0x0000_0200 | 0x0800_0000);
    }
    cmd.spawn()?;
    Ok(())
}

/// Clear `HANDLE_FLAG_INHERIT` on this process's std handles so a spawned child
/// (the daemon) cannot inherit a pipe a caller is capturing. Declared inline to
/// avoid a winapi dependency for these two calls.
#[cfg(windows)]
fn block_std_handle_inheritance() {
    extern "system" {
        fn GetStdHandle(nStdHandle: u32) -> isize;
        fn SetHandleInformation(hObject: isize, dwMask: u32, dwFlags: u32) -> i32;
    }
    const STD_INPUT_HANDLE: u32 = 0xFFFF_FFF6; // -10
    const STD_OUTPUT_HANDLE: u32 = 0xFFFF_FFF5; // -11
    const STD_ERROR_HANDLE: u32 = 0xFFFF_FFF4; // -12
    const HANDLE_FLAG_INHERIT: u32 = 0x1;
    const INVALID: isize = -1;
    unsafe {
        for which in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
            let h = GetStdHandle(which);
            if h != 0 && h != INVALID {
                // Best-effort: a console handle that rejects the call is harmless.
                SetHandleInformation(h, HANDLE_FLAG_INHERIT, 0);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::seedmap::{serve_seed_control, SeedRegistry};
    use crate::test_home::with_home;

    // [unit->REQ-DAEMON-3] with no daemon up, is_running() is false; once a
    // seed-control server answers, it flips true and ensure_running() is a no-op
    // (the spawn branch — which would need the real `spt` binary — is not taken).
    #[test]
    fn is_running_tracks_the_seed_channel() {
        with_home(|_| {
            assert!(!is_running(), "no daemon bound yet");

            let reg = Arc::new(SeedRegistry::new());
            let name = seed_socket_name();
            let srv = Arc::clone(&reg);
            thread::spawn(move || {
                let _ = serve_seed_control(&name, srv);
            });
            for _ in 0..400 {
                if is_running() {
                    break;
                }
                thread::sleep(Duration::from_millis(5));
            }
            assert!(is_running(), "the seed channel answers → daemon is up");
            // ensure_running short-circuits (no real process spawn).
            ensure_running().expect("ensure is a no-op when already up");
        });
    }
}

+        // The digest hub (B8) holds the live per-endpoint PTY-digest projections;

+        // the digest-control channel serves `spt digest <id>` snapshot pulls +

+        // the broker. The per-session feed that *populates* the hub is driven by

+        // the brain (the parse is logic — ADR-0004 §B), wired as daemon-hosted

+        // sessions are spawned through the broker; a bare daemon serves the

+        // channel and answers "no live digest" until a session is hosted.

+        let digest_hub = Arc::new(DigestHub::new());

+            let hub = Arc::clone(&digest_hub);

+                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);


Bash

cargo build -p spt-daemon 2>&1 | tail -20

Build spt-daemon with feed + control wiring


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


Builds. **Step 5 — `spt digest` CLI.** Add a render helper + Digest re-export in spt-daemon, then the CLI. Check spt deps first:


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

spt crate dependencies


[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
spt-daemon = { path = "../spt-daemon" }
clap = { version = "4", features = ["derive"] }

[dev-dependencies]
tempfile = "3"
toml = "0.8"


spt has no serde_json/spt-term dep — so spt-daemon provides all rendering (human + JSON) so the CLI stays thin. Add render helpers to digesthub.rs:


Edit

use serde::{Deserialize, Serialize};
use spt_term::Digest;

use crate::codec::{read_frame, write_frame};
use crate::digest::DigestUpdate;

use serde::{Deserialize, Serialize};
use spt_term::{Digest, DigestEntry, Turn};

use crate::codec::{read_frame, write_frame};
use crate::digest::DigestUpdate;


use serde::{Deserialize, Serialize};
use spt_term::Digest;

use crate::codec::{read_frame, write_frame};
use crate::digest::DigestUpdate;

use serde::{Deserialize, Serialize};
use spt_term::{Digest, DigestEntry, Turn};

use crate::codec::{read_frame, write_frame};
use crate::digest::DigestUpdate;

+use spt_term::{Digest, DigestEntry, Turn};


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

    fn turn(input: &str) -> Turn {

// ───────────────────────────── presentation ─────────────────────────────
//
// The `spt digest` CLI lives in the `spt` binary, which depends on neither
// `serde_json` nor `spt-term` directly — so all rendering (human + JSON) is
// provided here, keeping the CLI a thin printer.

/// Render a structured digest as a human-glanceable block: one `» input` line
/// per user turn, agent output collapsed to a single line, and each tool sprint
/// as a `used: name(arg), …` line. The at-a-glance "what is this agent doing"
/// view (ADR-0008).
pub fn render_digest(digest: &Digest) -> String {
    render_turns(&digest.turns)
}

/// Render the turns carried by a streamed [`DigestUpdate`] (the delta-stream
/// presentation — same per-turn shape as a snapshot).
pub fn render_update(update: &DigestUpdate) -> String {
    render_turns(&update.turns)
}

/// Serialize a digest to pretty JSON (the `--json` snapshot form).
pub fn digest_to_json(digest: &Digest) -> String {
    serde_json::to_string_pretty(digest).unwrap_or_else(|_| "{}".to_string())
}

/// Serialize a streamed update to compact JSON (one line per delta for `--json
/// --follow`).
pub fn update_to_json(update: &DigestUpdate) -> String {
    serde_json::to_string(update).unwrap_or_else(|_| "{}".to_string())
}

fn render_turns(turns: &[Turn]) -> String {
    let mut out = String::new();
    for t in turns {
        if let Some(input) = &t.input {
            out.push_str(&format!("» {}\n", one_line(input)));
        }
        for e in &t.entries {
            match e {
                DigestEntry::Agent(text) => {
                    let line = one_line(text);
                    if !line.is_empty() {
                        out.push_str(&format!("  {line}\n"));
                    }
                }
                DigestEntry::ToolSprint(tools) => {
                    let joined = tools
                        .iter()
                        .map(|tu| {
                            if tu.arg.is_empty() {
                                tu.name.clone()
                            } else {
                                format!("{}({})", tu.name, tu.arg)
                            }
                        })
                        .collect::<Vec<_>>()
                        .join(", ");
                    out.push_str(&format!("  used: {joined}\n"));
                }
            }
        }
    }
    out
}

/// Collapse internal whitespace/newlines to a single spaced line for glanceable
/// rendering (the digest is a summary, not a transcript).
fn one_line(s: &str) -> String {
    s.split_whitespace().collect::<Vec<_>>().join(" ")
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_term::{DigestEntry, ToolUse, Turn};

    fn turn(input: &str) -> Turn {


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

    fn turn(input: &str) -> Turn {

// ───────────────────────────── presentation ─────────────────────────────
//
// The `spt digest` CLI lives in the `spt` binary, which depends on neither
// `serde_json` nor `spt-term` directly — so all rendering (human + JSON) is
// provided here, keeping the CLI a thin printer.

/// Render a structured digest as a human-glanceable block: one `» input` line
/// per user turn, agent output collapsed to a single line, and each tool sprint
/// as a `used: name(arg), …` line. The at-a-glance "what is this agent doing"
/// view (ADR-0008).
pub fn render_digest(digest: &Digest) -> String {
    render_turns(&digest.turns)
}

/// Render the turns carried by a streamed [`DigestUpdate`] (the delta-stream
/// presentation — same per-turn shape as a snapshot).
pub fn render_update(update: &DigestUpdate) -> String {
    render_turns(&update.turns)
}

/// Serialize a digest to pretty JSON (the `--json` snapshot form).
pub fn digest_to_json(digest: &Digest) -> String {
    serde_json::to_string_pretty(digest).unwrap_or_else(|_| "{}".to_string())
}

/// Serialize a streamed update to compact JSON (one line per delta for `--json
/// --follow`).
pub fn update_to_json(update: &DigestUpdate) -> String {
    serde_json::to_string(update).unwrap_or_else(|_| "{}".to_string())
}

fn render_turns(turns: &[Turn]) -> String {
    let mut out = String::new();
    for t in turns {
        if let Some(input) = &t.input {
            out.push_str(&format!("» {}\n", one_line(input)));
        }
        for e in &t.entries {
            match e {
                DigestEntry::Agent(text) => {
                    let line = one_line(text);
                    if !line.is_empty() {
                        out.push_str(&format!("  {line}\n"));
                    }
                }
                DigestEntry::ToolSprint(tools) => {
                    let joined = tools
                        .iter()
                        .map(|tu| {
                            if tu.arg.is_empty() {
                                tu.name.clone()
                            } else {
                                format!("{}({})", tu.name, tu.arg)
                            }
                        })
                        .collect::<Vec<_>>()
                        .join(", ");
                    out.push_str(&format!("  used: {joined}\n"));
                }
            }
        }
    }
    out
}

/// Collapse internal whitespace/newlines to a single spaced line for glanceable
/// rendering (the digest is a summary, not a transcript).
fn one_line(s: &str) -> String {
    s.split_whitespace().collect::<Vec<_>>().join(" ")
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_term::{DigestEntry, ToolUse, Turn};

    fn turn(input: &str) -> Turn {

+// ───────────────────────────── presentation ─────────────────────────────

+// The `spt digest` CLI lives in the `spt` binary, which depends on neither

+// `serde_json` nor `spt-term` directly — so all rendering (human + JSON) is

+// provided here, keeping the CLI a thin printer.

+/// Render a structured digest as a human-glanceable block: one `» input` line

+/// per user turn, agent output collapsed to a single line, and each tool sprint

+/// as a `used: name(arg), …` line. The at-a-glance "what is this agent doing"

+pub fn render_digest(digest: &Digest) -> String {

+/// Render the turns carried by a streamed [`DigestUpdate`] (the delta-stream

+/// presentation — same per-turn shape as a snapshot).

+pub fn render_update(update: &DigestUpdate) -> String {

+/// Serialize a digest to pretty JSON (the `--json` snapshot form).

+pub fn digest_to_json(digest: &Digest) -> String {

+    serde_json::to_string_pretty(digest).unwrap_or_else(|_| "{}".to_string())

+/// Serialize a streamed update to compact JSON (one line per delta for `--json

+pub fn update_to_json(update: &DigestUpdate) -> String {

+    serde_json::to_string(update).unwrap_or_else(|_| "{}".to_string())

+fn render_turns(turns: &[Turn]) -> String {

+        if let Some(input) = &t.input {

+            out.push_str(&format!("» {}\n", one_line(input)));

+                DigestEntry::Agent(text) => {

+                    let line = one_line(text);

+                    if !line.is_empty() {

+                        out.push_str(&format!("  {line}\n"));

+                DigestEntry::ToolSprint(tools) => {

+                            if tu.arg.is_empty() {

+                                tu.name.clone()

+                                format!("{}({})", tu.name, tu.arg)

+                    out.push_str(&format!("  used: {joined}\n"));

+/// Collapse internal whitespace/newlines to a single spaced line for glanceable

+/// rendering (the digest is a summary, not a transcript).

+    s.split_whitespace().collect::<Vec<_>>().join(" ")

+    use spt_term::{DigestEntry, ToolUse, Turn};

Turn { input: Some(input.to_string()), entries: Vec::new() }


Add a render unit test + export the render helpers:


    // [unit->REQ-TERM-4] a fresh subscribe returns the current snapshot as its
    // base so later deltas have an apply anchor.
    #[test]
    fn subscribe_returns_current_base() {
        let hub = DigestHub::new();
        hub.publish("doyle", Digest { turns: vec![turn("base")] }, an_update(7));
        let (base, version, _rx) = hub.subscribe("doyle");
        assert_eq!(version, 7);
        assert_eq!(base.turns[0].input.as_deref(), Some("base"));
    }
}

    // [unit->REQ-TERM-4] a fresh subscribe returns the current snapshot as its
    // base so later deltas have an apply anchor.
    #[test]
    fn subscribe_returns_current_base() {
        let hub = DigestHub::new();
        hub.publish("doyle", Digest { turns: vec![turn("base")] }, an_update(7));
        let (base, version, _rx) = hub.subscribe("doyle");
        assert_eq!(version, 7);
        assert_eq!(base.turns[0].input.as_deref(), Some("base"));
    }

    // [unit->REQ-TERM-4] the human render shows input, collapsed agent text, and a
    // single `used:` line per tool sprint.
    #[test]
    fn render_is_glanceable() {
        let digest = Digest {
            turns: vec![Turn {
                input: Some("fix the\n  bug".to_string()),
                entries: vec![
                    DigestEntry::Agent("on it\nlooking".to_string()),
                    DigestEntry::ToolSprint(vec![
                        ToolUse { name: "Write".into(), arg: "a.rs".into() },
                        ToolUse { name: "Bash".into(), arg: "cargo".into() },
                    ]),
                ],
            }],
        };
        let out = render_digest(&digest);
        assert!(out.contains("» fix the bug"), "input on one line: {out}");
        assert!(out.contains("  on it looking"), "agent collapsed: {out}");
        assert!(out.contains("used: Write(a.rs), Bash(cargo)"), "tool sprint: {out}");
    }
}


    // [unit->REQ-TERM-4] a fresh subscribe returns the current snapshot as its
    // base so later deltas have an apply anchor.
    #[test]
    fn subscribe_returns_current_base() {
        let hub = DigestHub::new();
        hub.publish("doyle", Digest { turns: vec![turn("base")] }, an_update(7));
        let (base, version, _rx) = hub.subscribe("doyle");
        assert_eq!(version, 7);
        assert_eq!(base.turns[0].input.as_deref(), Some("base"));
    }
}

    // [unit->REQ-TERM-4] a fresh subscribe returns the current snapshot as its
    // base so later deltas have an apply anchor.
    #[test]
    fn subscribe_returns_current_base() {
        let hub = DigestHub::new();
        hub.publish("doyle", Digest { turns: vec![turn("base")] }, an_update(7));
        let (base, version, _rx) = hub.subscribe("doyle");
        assert_eq!(version, 7);
        assert_eq!(base.turns[0].input.as_deref(), Some("base"));
    }

    // [unit->REQ-TERM-4] the human render shows input, collapsed agent text, and a
    // single `used:` line per tool sprint.
    #[test]
    fn render_is_glanceable() {
        let digest = Digest {
            turns: vec![Turn {
                input: Some("fix the\n  bug".to_string()),
                entries: vec![
                    DigestEntry::Agent("on it\nlooking".to_string()),
                    DigestEntry::ToolSprint(vec![
                        ToolUse { name: "Write".into(), arg: "a.rs".into() },
                        ToolUse { name: "Bash".into(), arg: "cargo".into() },
                    ]),
                ],
            }],
        };
        let out = render_digest(&digest);
        assert!(out.contains("» fix the bug"), "input on one line: {out}");
        assert!(out.contains("  on it looking"), "agent collapsed: {out}");
        assert!(out.contains("used: Write(a.rs), Bash(cargo)"), "tool sprint: {out}");
    }
}

+    // [unit->REQ-TERM-4] the human render shows input, collapsed agent text, and a

+    // single `used:` line per tool sprint.

+                input: Some("fix the\n  bug".to_string()),

+                    DigestEntry::Agent("on it\nlooking".to_string()),

+                    DigestEntry::ToolSprint(vec![

+                        ToolUse { name: "Write".into(), arg: "a.rs".into() },

+                        ToolUse { name: "Bash".into(), arg: "cargo".into() },

+        let out = render_digest(&digest);

+        assert!(out.contains("» fix the bug"), "input on one line: {out}");

+        assert!(out.contains("  on it looking"), "agent collapsed: {out}");

+        assert!(out.contains("used: Write(a.rs), Bash(cargo)"), "tool sprint: {out}");


pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
pub use digesthub::{follow, pull_snapshot, serve_digest_control, DigestHub};

pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
pub use digesthub::{
    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,
    update_to_json, DigestHub,
};


pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
pub use digesthub::{
    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,
    update_to_json, DigestHub,
};

//! `spt-daemon` — the per-machine broker/brain supervisor (M3b).
//!
//! ADR-0004 splits the logical daemon into two implementation layers so a
//! routine self-update swaps the logic without disturbing live endpoints:
//!
//! - a **broker** (stable kernel) holds *only* the un-transferable resources a
//!   live consumer would lose continuity on if the logic restarted — PTY
//!   masters (from [`spt_term`]), spawned harness children, accepted/listening
//!   sockets — and runs no logic;
//! - a **brain** (restartable logic) holds routing, registry, manifest parse,
//!   and the re-hosted [`spt_live`] lifecycle, attaching to the broker over a
//!   **versioned local IPC** so a *newer brain talks to an older broker*.
//!
//! **B0 (this commit)** lands the crate's layer position plus that versioned
//! IPC seam — the load-bearing contract everything else rides:
//!
//! - [`frame`] — the forward-compatible [`Envelope`] schema + the version
//!   [`Hello`] handshake (KNOWN-HAZARDS 2.3, [`REQ-HAZARD-HANDOFF-ARGV-COMPAT`]);
//! - [`codec`] — length-prefixed JSON framing over any [`std::io::Read`]/`Write`;
//! - [`transport`] — the OS-split carrier (named pipe / UDS) behind one
//!   [`DaemonTransport`] trait, mirroring `spt-term`'s `SessionSurface`.
//!
//! **B1 (this commit)** adds the **broker kernel** ([`broker`]) — the stable
//! half that hosts a real `spt-term` PTY child and relays its output/input over
//! the IPC, holding only the ADR-0004 §B resources and no logic — plus the
//! broker↔brain message catalog ([`msg`]).
//!
//! **B3 (this commit)** consolidates the interim no-daemon model into the brain:
//! the `api listen` pulse / Psyche / drop-ingest loops become scheduled
//! in-process loops ([`lifecycle::BrainLifecycle`]) paced by a durable,
//! configurable period ([`config::DaemonConfig`]) instead of the interim 5s
//! constant; the only residue left in the harness-hosted session is the thin,
//! stateless [`relay::Relay`]. The `spt` binary's `LiveHost` becomes a shim over
//! `BrainLifecycle` (re-host, not rewrite) ([`REQ-DAEMON-1`]).
//!
//! **B5 (this commit)** makes the broker↔brain boundary **exactly-once across a
//! brain crash** ([`effect::EffectJournal`]): an idempotent `input` carries a
//! durable `op_id`, and the broker dedups at the effect keyed by that id over a
//! broker-owned recovery anchor (KNOWN-HAZARDS 7.2, [`REQ-HAZARD-RESTART-IDEMPOTENT`];
//! Spike #6's two constraints — broker-owned anchor, dedup-at-effect-by-id —
//! instantiated on the real PTY-write boundary).
//!
//! The brain + gapless handoff (B2) and the rest of the M3b task table build on
//! this seam. `spt-daemon` is internal supervisor machinery, **not** public SDK
//! (R-ARCH-2).
//!
//! [`Envelope`]: frame::Envelope
//! [`Hello`]: frame::Hello
//! [`DaemonTransport`]: transport::DaemonTransport

pub mod brain;
pub mod broker;
pub mod codec;
pub mod config;
pub mod daemon;
pub mod digest;
pub mod digesthub;
pub mod effect;
pub mod endpoint;
pub mod frame;
pub mod lifecycle;
pub mod msg;
pub mod relay;
pub mod seedmap;
pub mod transport;

#[cfg(test)]
mod test_home;

pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::DaemonConfig;
pub use daemon::{ensure_running, is_running, Daemon};
pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
pub use digesthub::{follow, pull_snapshot, serve_digest_control, DigestHub};
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
pub use lifecycle::{BrainLifecycle, TickReport};
pub use relay::Relay;
pub use seedmap::{put_seed, take_seed, SeedRegistry};
pub use frame::{
    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
    MIN_COMPATIBLE_VERSION,
};
pub use transport::{DaemonTransport, LocalSocketTransport};

-pub use digesthub::{follow, pull_snapshot, serve_digest_control, DigestHub};

+    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,


Now the `spt digest` CLI command:


    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
    Whoami,

    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
    Whoami,
    /// Show a daemon-hosted session's live activity buffer (PTY digest) — the
    /// at-a-glance "what is this agent doing now" view (ADR-0008). Pulls a
    /// snapshot, or `--follow`s the delta-stream. Local addressing only (the
    /// `[subnet:]id@node` cross-node form is M4).
    Digest {
        /// The (local) endpoint id to read.
        id: String,
        /// Stream live changes instead of a one-shot snapshot (Ctrl-C to stop).
        #[arg(long)]
        follow: bool,
        /// Emit structured JSON instead of the human-glanceable render.
        #[arg(long)]
        json: bool,
    },


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

    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
    Whoami,
    /// Show a daemon-hosted session's live activity buffer (PTY digest) — the
    /// at-a-glance "what is this agent doing now" view (ADR-0008). Pulls a
    /// snapshot, or `--follow`s the delta-stream. Local addressing only (the
    /// `[subnet:]id@node` cross-node form is M4).
    Digest {
        /// The (local) endpoint id to read.
        id: String,
        /// Stream live changes instead of a one-shot snapshot (Ctrl-C to stop).
        #[arg(long)]
        follow: bool,
        /// Emit structured JSON instead of the human-glanceable render.
        #[arg(long)]
        json: bool,
    },

+    /// Show a daemon-hosted session's live activity buffer (PTY digest) — the

+    /// at-a-glance "what is this agent doing now" view (ADR-0008). Pulls a

+    /// snapshot, or `--follow`s the delta-stream. Local addressing only (the

+    /// `[subnet:]id@node` cross-node form is M4).

+        /// The (local) endpoint id to read.

+        /// Stream live changes instead of a one-shot snapshot (Ctrl-C to stop).

+        /// Emit structured JSON instead of the human-glanceable render.

/// Harness-contract inbound surface (REQ-API-1): the entry points a

/// harness's hooks fire to mutate on-disk SPT state.


        Cmd::Whoami => cmd_whoami(),
        Cmd::Api(args) => crate::api::run(args),
        Cmd::Daemon => cmd_daemon(),
    }
}

        Cmd::Whoami => cmd_whoami(),
        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),
        Cmd::Api(args) => crate::api::run(args),
        Cmd::Daemon => cmd_daemon(),
    }
}

/// `spt digest <id>` — show a daemon-hosted session's live activity buffer
/// (REQ-TERM-4 / ADR-0008). Ensures the daemon is up, then pulls a snapshot or
/// follows the delta-stream over the digest-control channel. Local addressing
/// only: a qualified `[subnet:]id@node` form is refused (cross-node is M4).
// [impl->REQ-TERM-4]
fn cmd_digest(id: &str, follow: bool, json: bool) -> i32 {
    // Local addressing only — the qualified cross-node form is M4 (spt-net).
    if id.contains('@') || id.contains(':') {
        eprintln!("CROSS_NODE_M4:{id} — qualified [subnet:]id@node addressing is M4, not yet supported");
        return 1;
    }
    // The digest lives in the daemon; bring it up if absent (best-effort).
    if let Err(e) = spt_daemon::ensure_running() {
        eprintln!("DAEMON_START_WARN: {e}");
    }
    let name = spt_daemon::digest_socket_name();

    if follow {
        let res = spt_daemon::follow(&name, id, 0, |up| {
            if json {
                println!("{}", spt_daemon::update_to_json(up));
            } else {
                print!("{}", spt_daemon::render_update(up));
            }
            let _ = std::io::stdout().flush();
            true // stream until the connection ends / the user interrupts
        });
        return match res {
            Ok(()) => 0,
            Err(e) => {
                eprintln!("DIGEST_FOLLOW_FAIL:{id}: {e}");
                1
            }
        };
    }

    match spt_daemon::pull_snapshot(&name, id) {
        Ok(Some((digest, version))) => {
            if json {
                println!("{}", spt_daemon::digest_to_json(&digest));
            } else {
                print!("{}", spt_daemon::render_digest(&digest));
                let _ = std::io::stdout().flush();
            }
            eprintln!("DIGEST:{id} version={version}");
            0
        }
        Ok(None) => {
            eprintln!("NO_DIGEST:{id} has no live activity buffer (not a daemon-hosted session?)");
            1
        }
        Err(e) => {
            eprintln!("DIGEST_FAIL:{id}: {e}");
            1
        }
    }
}


        Cmd::Whoami => cmd_whoami(),
        Cmd::Api(args) => crate::api::run(args),
        Cmd::Daemon => cmd_daemon(),
    }
}

        Cmd::Whoami => cmd_whoami(),
        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),
        Cmd::Api(args) => crate::api::run(args),
        Cmd::Daemon => cmd_daemon(),
    }
}

/// `spt digest <id>` — show a daemon-hosted session's live activity buffer
/// (REQ-TERM-4 / ADR-0008). Ensures the daemon is up, then pulls a snapshot or
/// follows the delta-stream over the digest-control channel. Local addressing
/// only: a qualified `[subnet:]id@node` form is refused (cross-node is M4).
// [impl->REQ-TERM-4]
fn cmd_digest(id: &str, follow: bool, json: bool) -> i32 {
    // Local addressing only — the qualified cross-node form is M4 (spt-net).
    if id.contains('@') || id.contains(':') {
        eprintln!("CROSS_NODE_M4:{id} — qualified [subnet:]id@node addressing is M4, not yet supported");
        return 1;
    }
    // The digest lives in the daemon; bring it up if absent (best-effort).
    if let Err(e) = spt_daemon::ensure_running() {
        eprintln!("DAEMON_START_WARN: {e}");
    }
    let name = spt_daemon::digest_socket_name();

    if follow {
        let res = spt_daemon::follow(&name, id, 0, |up| {
            if json {
                println!("{}", spt_daemon::update_to_json(up));
            } else {
                print!("{}", spt_daemon::render_update(up));
            }
            let _ = std::io::stdout().flush();
            true // stream until the connection ends / the user interrupts
        });
        return match res {
            Ok(()) => 0,
            Err(e) => {
                eprintln!("DIGEST_FOLLOW_FAIL:{id}: {e}");
                1
            }
        };
    }

    match spt_daemon::pull_snapshot(&name, id) {
        Ok(Some((digest, version))) => {
            if json {
                println!("{}", spt_daemon::digest_to_json(&digest));
            } else {
                print!("{}", spt_daemon::render_digest(&digest));
                let _ = std::io::stdout().flush();
            }
            eprintln!("DIGEST:{id} version={version}");
            0
        }
        Ok(None) => {
            eprintln!("NO_DIGEST:{id} has no live activity buffer (not a daemon-hosted session?)");
            1
        }
        Err(e) => {
            eprintln!("DIGEST_FAIL:{id}: {e}");
            1
        }
    }
}

+        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),

+/// `spt digest <id>` — show a daemon-hosted session's live activity buffer

+/// (REQ-TERM-4 / ADR-0008). Ensures the daemon is up, then pulls a snapshot or

+/// follows the delta-stream over the digest-control channel. Local addressing

+/// only: a qualified `[subnet:]id@node` form is refused (cross-node is M4).

+fn cmd_digest(id: &str, follow: bool, json: bool) -> i32 {

+    // Local addressing only — the qualified cross-node form is M4 (spt-net).

+    if id.contains('@') || id.contains(':') {

+        eprintln!("CROSS_NODE_M4:{id} — qualified [subnet:]id@node addressing is M4, not yet supported");

+    // The digest lives in the daemon; bring it up if absent (best-effort).

+    if let Err(e) = spt_daemon::ensure_running() {

+        eprintln!("DAEMON_START_WARN: {e}");

+    let name = spt_daemon::digest_socket_name();

+        let res = spt_daemon::follow(&name, id, 0, |up| {

+                println!("{}", spt_daemon::update_to_json(up));

+                print!("{}", spt_daemon::render_update(up));

+            let _ = std::io::stdout().flush();

+            true // stream until the connection ends / the user interrupts

+                eprintln!("DIGEST_FOLLOW_FAIL:{id}: {e}");

+    match spt_daemon::pull_snapshot(&name, id) {

+        Ok(Some((digest, version))) => {

+                println!("{}", spt_daemon::digest_to_json(&digest));

+                print!("{}", spt_daemon::render_digest(&digest));

+                let _ = std::io::stdout().flush();

+            eprintln!("DIGEST:{id} version={version}");

+            eprintln!("NO_DIGEST:{id} has no live activity buffer (not a daemon-hosted session?)");

+            eprintln!("DIGEST_FAIL:{id}: {e}");

/// Run the per-machine daemon (the hidden `spt daemon` entry point). Blocks

/// serving the broker + seed-control channels until killed; a bind contention

/// (another daemon already owns this home) surfaces as a non-zero exit.


Add CLI parse tests for the digest subcommand:


        // The hidden daemon entry point parses (auto-started by `api`).
        assert!(matches!(parse(&["spt", "daemon"]).unwrap().cmd, Cmd::Daemon));
    }

        // The hidden daemon entry point parses (auto-started by `api`).
        assert!(matches!(parse(&["spt", "daemon"]).unwrap().cmd, Cmd::Daemon));
        // The digest surface + its flags.
        assert!(matches!(parse(&["spt", "digest", "doyle"]).unwrap().cmd, Cmd::Digest { .. }));
        assert!(matches!(
            parse(&["spt", "digest", "doyle", "--follow", "--json"]).unwrap().cmd,
            Cmd::Digest { follow: true, json: true, .. }
        ));
    }

    // [unit->REQ-TERM-4] digest defaults: a one-shot, human-rendered snapshot.
    #[test]
    fn digest_flag_defaults() {
        match parse(&["spt", "digest", "doyle"]).unwrap().cmd {
            Cmd::Digest { id, follow, json } => {
                assert_eq!(id, "doyle");
                assert!(!follow, "snapshot by default");
                assert!(!json, "human render by default");
            }
            _ => panic!("expected Digest"),
        }
    }


        // The hidden daemon entry point parses (auto-started by `api`).
        assert!(matches!(parse(&["spt", "daemon"]).unwrap().cmd, Cmd::Daemon));
    }

        // The hidden daemon entry point parses (auto-started by `api`).
        assert!(matches!(parse(&["spt", "daemon"]).unwrap().cmd, Cmd::Daemon));
        // The digest surface + its flags.
        assert!(matches!(parse(&["spt", "digest", "doyle"]).unwrap().cmd, Cmd::Digest { .. }));
        assert!(matches!(
            parse(&["spt", "digest", "doyle", "--follow", "--json"]).unwrap().cmd,
            Cmd::Digest { follow: true, json: true, .. }
        ));
    }

    // [unit->REQ-TERM-4] digest defaults: a one-shot, human-rendered snapshot.
    #[test]
    fn digest_flag_defaults() {
        match parse(&["spt", "digest", "doyle"]).unwrap().cmd {
            Cmd::Digest { id, follow, json } => {
                assert_eq!(id, "doyle");
                assert!(!follow, "snapshot by default");
                assert!(!json, "human render by default");
            }
            _ => panic!("expected Digest"),
        }
    }

+        // The digest surface + its flags.

+        assert!(matches!(parse(&["spt", "digest", "doyle"]).unwrap().cmd, Cmd::Digest { .. }));

+            parse(&["spt", "digest", "doyle", "--follow", "--json"]).unwrap().cmd,

+            Cmd::Digest { follow: true, json: true, .. }

+    // [unit->REQ-TERM-4] digest defaults: a one-shot, human-rendered snapshot.

+        match parse(&["spt", "digest", "doyle"]).unwrap().cmd {

+            Cmd::Digest { id, follow, json } => {

+                assert_eq!(id, "doyle");

+                assert!(!follow, "snapshot by default");

+                assert!(!json, "human render by default");

+            _ => panic!("expected Digest"),

// [unit->REQ-MSG-2] ring timeout defaults to 60s when unspecified.


Bash

Build spt binary with digest CLI


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


**Step 6 — integration test** (the `int` evidence). Add dev-deps the external test crate needs:


Edit

[dev-dependencies]
tempfile = "3"
# Integration tests are separate crates and do not inherit the lib's deps, so the
# B8 digest int-test re-declares the two it touches directly: the manifest seam
# (PtyDigest) and the perch-path/history resolution it asserts persistence against.
spt-runtime = { path = "../spt-runtime" }
spt-store = { path = "../spt-store" }


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

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


20	# PTY output is arbitrary bytes; the forward-compat JSON envelope carries it as
21	# a base64 string in the payload rather than forking the codec for binary.
22	base64 = "0.22"
23	# Cross-platform local IPC: named pipe (Windows) / Unix-domain socket (unix)
24	# behind one API — the OS-split substrate the DaemonTransport trait wraps,
25	# mirroring how spt-term abstracts ConPTY vs forkpty behind SessionSurface.
26	interprocess = "2"
27
28	[dev-dependencies]
29	tempfile = "3"
30

# PTY output is arbitrary bytes; the forward-compat JSON envelope carries it as
# a base64 string in the payload rather than forking the codec for binary.
base64 = "0.22"
# Cross-platform local IPC: named pipe (Windows) / Unix-domain socket (unix)
# behind one API — the OS-split substrate the DaemonTransport trait wraps,
# mirroring how spt-term abstracts ConPTY vs forkpty behind SessionSurface.
interprocess = "2"

[dev-dependencies]
tempfile = "3"


[dev-dependencies]
tempfile = "3"
# Integration tests are separate crates and do not inherit the lib's deps, so the
# B8 digest int-test re-declares the two it touches directly: the manifest seam
# (PtyDigest) and the perch-path/history resolution it asserts persistence against.
spt-runtime = { path = "../spt-runtime" }
spt-store = { path = "../spt-store" }


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

[dev-dependencies]
tempfile = "3"
# Integration tests are separate crates and do not inherit the lib's deps, so the
# B8 digest int-test re-declares the two it touches directly: the manifest seam
# (PtyDigest) and the perch-path/history resolution it asserts persistence against.
spt-runtime = { path = "../spt-runtime" }
spt-store = { path = "../spt-store" }

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

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

[dev-dependencies]
tempfile = "3"

+# Integration tests are separate crates and do not inherit the lib's deps, so the

+# B8 digest int-test re-declares the two it touches directly: the manifest seam

+# (PtyDigest) and the perch-path/history resolution it asserts persistence against.

+spt-runtime = { path = "../spt-runtime" }


Check how `spt_home`/perch path resolve from env (for the persistence assertion):


Bash

grep -n "pub fn spt_home\|SPT_HOME\|pub fn resolve_perch_path\|pub fn owlery_dir" crates/spt-store/src/perch.rs | head

perch path + spt_home resolution


24:/// Root of all spt-core on-disk state. Honors `$SPT_HOME`, else the platform
32:/// override (`$SPT_HOME`) is the relocation knob either way.
34:pub fn spt_home() -> PathBuf {
35:    if let Ok(over) = std::env::var("SPT_HOME") {
62:pub fn owlery_dir() -> PathBuf {
130:pub fn resolve_perch_path(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
142:pub fn resolve_perch_path_in(owlery: &Path, child_id: &str, hint: ParentHint<'_>) -> PathBuf {
234:            let prev = std::env::var("SPT_HOME").ok();
235:            std::env::set_var("SPT_HOME", tmp);
242:                Some(v) => std::env::set_var("SPT_HOME", v),


`SPT_HOME` honored. Now write the integration test: