use spt_net::net::endpoint::{
    addr_for_node_hex, BindScope, EndpointAddr, LocalDiscovery, NetEndpoint, RelayPolicy,
};
use spt_net::net::pairing::ratelimit::PairingRateLimiter;
use spt_net::net::pairing::totp::TotpSeed;
use spt_net::net::pairing::wire::{run_initiator, run_responder, SPT_PAIR_ALPN};

use spt_net::net::endpoint::{
    addr_for_node_hex, BindScope, EndpointAddr, LocalDiscovery, RelayPolicy,
};
use spt_net::net::pairing::totp::TotpSeed;

use spt_daemon::release::{sha256_hex, ReleaseMetadata, SignedRelease};

-    addr_for_node_hex, BindScope, EndpointAddr, LocalDiscovery, NetEndpoint, RelayPolicy,

+    addr_for_node_hex, BindScope, EndpointAddr, LocalDiscovery, RelayPolicy,

-use spt_net::net::pairing::wire::{run_initiator, run_responder, SPT_PAIR_ALPN};


Bash

cargo check -p spt-daemon --all-targets 2>&1 | grep -E "^error|^warning: unused" -A5 | head -20; cargo check -p spt-daemon --all-targets 2>&1 | tail -1

Recheck twohost compiles clean


Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.45s


quickstart_e2e rewrite:


Write

//! M6-D4 quickstart rung (rewritten M7 D5) — the published human quickstart
//! (docs-site/src/quickstart/messaging.md) executed step for step against the
//! real binary: the `how-to` topics the agent prompt blocks point at name the
//! commands the guide relies on → ready brings sergey online → live send
//! delivers (`SENT`) and the body + `__REPLY_TO__` header land on sergey's
//! stdout → sergey goes away → send spools (`QUEUED`) → `ready --once` drains
//! the backlog. If the documented flow stops working, this fails — the doc
//! cannot silently rot (REQ-DOCS-2's "runnable, deterministic" promise,
//! CI-run on both platforms). The pairing `<details>` section is two-machine
//! by nature — its evidence is the twohost ladder's join rung, not this test.
//!
//! Hermetic: isolated SPT_HOME per run; not env-gated (cheap, local-only).
//
// [int->REQ-DOCS-2]

use std::io::{BufRead, BufReader, Read};
use std::process::{Command, Stdio};
use std::time::Duration;

fn spt() -> Command {
    Command::new(env!("CARGO_BIN_EXE_spt"))
}

#[test]
fn published_messaging_quickstart_runs_as_written() {
    let home = tempfile::tempdir().expect("tempdir");
    let home = home.path();

    // ---- steps 3/4 preamble: the agent prompt blocks say "run `spt how-to
    // <topic>` and follow it" — assert the in-binary guidance actually names
    // the commands the guide's captured outputs rely on (REQ-DOCS-6: the
    // binary is the single source; if these texts rot, the quickstart's
    // prompts mislead every agent that follows them).
    // [int->REQ-DOCS-6]
    let topics = run_ok(home, &["how-to"]);
    assert!(topics.contains("ready"), "topic list names ready: {topics:?}");
    assert!(topics.contains("send"), "topic list names send: {topics:?}");
    let ready_text = run_ok(home, &["how-to", "ready"]);
    assert!(ready_text.contains("spt ready <id>"), "ready how-to names the command");
    assert!(ready_text.contains("--once"), "ready how-to teaches the one-shot fallback");
    assert!(ready_text.contains("__REPLY_TO__"), "ready how-to explains reply routing");
    let send_text = run_ok(home, &["how-to", "send"]);
    assert!(send_text.contains("spt send <target>"), "send how-to names the command");
    assert!(send_text.contains("QUEUED"), "send how-to explains SENT vs QUEUED");

    // ---- step 3: the receiver agent comes online (`spt ready sergey`) ----
    let mut sergey = spt()
        .args(["ready", "sergey"])
        .env("SPT_HOME", home)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn ready sergey");
    // READY:sergey is the online signal (stderr).
    let mut sergey_err = BufReader::new(sergey.stderr.take().unwrap());
    let mut line = String::new();
    sergey_err.read_line(&mut line).expect("read READY line");
    assert!(line.contains("READY:sergey"), "expected READY:sergey, got {line:?}");

    // ---- step 4: live send from lea ----
    let out = send(home, &["send", "sergey", "--from", "lea"], "hello sergey - lea here");
    assert!(
        out.contains("SENT:sergey"),
        "live delivery must report SENT:sergey, got {out:?}"
    );
    // The documented receive shape: routing header + body on sergey's stdout.
    let mut sergey_out = BufReader::new(sergey.stdout.take().unwrap());
    let mut l1 = String::new();
    let mut l2 = String::new();
    sergey_out.read_line(&mut l1).expect("read header");
    sergey_out.read_line(&mut l2).expect("read body");
    assert!(l1.contains("__REPLY_TO__:lea"), "header line, got {l1:?}");
    assert!(l2.contains("hello sergey - lea here"), "body line, got {l2:?}");

    // ---- step 5: sergey goes away; the send spools ----
    sergey.kill().expect("stop sergey");
    let _ = sergey.wait();
    let out = send(home, &["send", "sergey", "--from", "lea"], "ping while you were away");
    assert!(
        out.contains("QUEUED:sergey"),
        "offline delivery must report QUEUED:sergey, got {out:?}"
    );

    // ---- sergey comes back: the backlog drains (`ready --once`) ----
    let drained = spt()
        .args(["ready", "sergey", "--once"])
        .env("SPT_HOME", home)
        .output()
        .expect("ready --once");
    let stdout = String::from_utf8_lossy(&drained.stdout);
    assert!(
        stdout.contains("ping while you were away"),
        "backlog must drain on return, got {stdout:?}"
    );
}

/// Run a one-shot spt command expecting exit 0; return its stdout.
fn run_ok(home: &std::path::Path, args: &[&str]) -> String {
    let out = spt().args(args).env("SPT_HOME", home).output().expect("spt runs");
    assert!(out.status.success(), "`spt {}` failed: {:?}", args.join(" "), out);
    String::from_utf8_lossy(&out.stdout).into_owned()
}

/// Run a one-shot spt command with `body` on stdin, returning stdout+stderr.
fn send(home: &std::path::Path, args: &[&str], body: &str) -> String {
    let mut child = spt()
        .args(args)
        .env("SPT_HOME", home)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn spt");
    {
        use std::io::Write;
        child
            .stdin
            .take()
            .unwrap()
            .write_all(body.as_bytes())
            .expect("write body");
    }
    // Bounded read (KNOWN-HAZARDS 5.6 posture): a hang here means a detached
    // child inherited the pipe.
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let out = child.wait_with_output();
        let _ = tx.send(out);
    });
    let out = rx
        .recv_timeout(Duration::from_secs(30))
        .expect("spt call must complete")
        .expect("spt runs");
    let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
    let mut e = String::new();
    let _ = std::io::Cursor::new(out.stderr).read_to_string(&mut e);
    s.push_str(&e);
    s
}


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

//! M6-D4 quickstart rung (rewritten M7 D5) — the published human quickstart
//! (docs-site/src/quickstart/messaging.md) executed step for step against the
//! real binary: the `how-to` topics the agent prompt blocks point at name the
//! commands the guide relies on → ready brings sergey online → live send
//! delivers (`SENT`) and the body + `__REPLY_TO__` header land on sergey's
//! stdout → sergey goes away → send spools (`QUEUED`) → `ready --once` drains
//! the backlog. If the documented flow stops working, this fails — the doc
//! cannot silently rot (REQ-DOCS-2's "runnable, deterministic" promise,
//! CI-run on both platforms). The pairing `<details>` section is two-machine
//! by nature — its evidence is the twohost ladder's join rung, not this test.
//!
//! Hermetic: isolated SPT_HOME per run; not env-gated (cheap, local-only).
//
// [int->REQ-DOCS-2]

use std::io::{BufRead, BufReader, Read};
use std::process::{Command, Stdio};
use std::time::Duration;

fn spt() -> Command {
    Command::new(env!("CARGO_BIN_EXE_spt"))
}

#[test]
fn published_messaging_quickstart_runs_as_written() {
    let home = tempfile::tempdir().expect("tempdir");
    let home = home.path();

    // ---- steps 3/4 preamble: the agent prompt blocks say "run `spt how-to
    // <topic>` and follow it" — assert the in-binary guidance actually names
    // the commands the guide's captured outputs rely on (REQ-DOCS-6: the
    // binary is the single source; if these texts rot, the quickstart's
    // prompts mislead every agent that follows them).
    // [int->REQ-DOCS-6]
    let topics = run_ok(home, &["how-to"]);
    assert!(topics.contains("ready"), "topic list names ready: {topics:?}");
    assert!(topics.contains("send"), "topic list names send: {topics:?}");
    let ready_text = run_ok(home, &["how-to", "ready"]);
    assert!(ready_text.contains("spt ready <id>"), "ready how-to names the command");
    assert!(ready_text.contains("--once"), "ready how-to teaches the one-shot fallback");
    assert!(ready_text.contains("__REPLY_TO__"), "ready how-to explains reply routing");
    let send_text = run_ok(home, &["how-to", "send"]);
    assert!(send_text.contains("spt send <target>"), "send how-to names the command");
    assert!(send_text.contains("QUEUED"), "send how-to explains SENT vs QUEUED");

    // ---- step 3: the receiver agent comes online (`spt ready sergey`) ----
    let mut sergey = spt()
        .args(["ready", "sergey"])
        .env("SPT_HOME", home)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn ready sergey");
    // READY:sergey is the online signal (stderr).
    let mut sergey_err = BufReader::new(sergey.stderr.take().unwrap());
    let mut line = String::new();
    sergey_err.read_line(&mut line).expect("read READY line");
    assert!(line.contains("READY:sergey"), "expected READY:sergey, got {line:?}");

    // ---- step 4: live send from lea ----
    let out = send(home, &["send", "sergey", "--from", "lea"], "hello sergey - lea here");
    assert!(
        out.contains("SENT:sergey"),
        "live delivery must report SENT:sergey, got {out:?}"
    );
    // The documented receive shape: routing header + body on sergey's stdout.
    let mut sergey_out = BufReader::new(sergey.stdout.take().unwrap());
    let mut l1 = String::new();
    let mut l2 = String::new();
    sergey_out.read_line(&mut l1).expect("read header");
    sergey_out.read_line(&mut l2).expect("read body");
    assert!(l1.contains("__REPLY_TO__:lea"), "header line, got {l1:?}");
    assert!(l2.contains("hello sergey - lea here"), "body line, got {l2:?}");

    // ---- step 5: sergey goes away; the send spools ----
    sergey.kill().expect("stop sergey");
    let _ = sergey.wait();
    let out = send(home, &["send", "sergey", "--from", "lea"], "ping while you were away");
    assert!(
        out.contains("QUEUED:sergey"),
        "offline delivery must report QUEUED:sergey, got {out:?}"
    );

    // ---- sergey comes back: the backlog drains (`ready --once`) ----
    let drained = spt()
        .args(["ready", "sergey", "--once"])
        .env("SPT_HOME", home)
        .output()
        .expect("ready --once");
    let stdout = String::from_utf8_lossy(&drained.stdout);
    assert!(
        stdout.contains("ping while you were away"),
        "backlog must drain on return, got {stdout:?}"
    );
}

/// Run a one-shot spt command expecting exit 0; return its stdout.
fn run_ok(home: &std::path::Path, args: &[&str]) -> String {
    let out = spt().args(args).env("SPT_HOME", home).output().expect("spt runs");
    assert!(out.status.success(), "`spt {}` failed: {:?}", args.join(" "), out);
    String::from_utf8_lossy(&out.stdout).into_owned()
}

/// Run a one-shot spt command with `body` on stdin, returning stdout+stderr.
fn send(home: &std::path::Path, args: &[&str], body: &str) -> String {
    let mut child = spt()
        .args(args)
        .env("SPT_HOME", home)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn spt");
    {
        use std::io::Write;
        child
            .stdin
            .take()
            .unwrap()
            .write_all(body.as_bytes())
            .expect("write body");
    }
    // Bounded read (KNOWN-HAZARDS 5.6 posture): a hang here means a detached
    // child inherited the pipe.
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let out = child.wait_with_output();
        let _ = tx.send(out);
    });
    let out = rx
        .recv_timeout(Duration::from_secs(30))
        .expect("spt call must complete")
        .expect("spt runs");
    let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
    let mut e = String::new();
    let _ = std::io::Cursor::new(out.stderr).read_to_string(&mut e);
    s.push_str(&e);
    s
}

-//! M6-D4 quickstart rung — the published human quickstart

+//! M6-D4 quickstart rung (rewritten M7 D5) — the published human quickstart

-//! real binary: ready brings bob online → live send delivers (`SENT`) and the

-//! body + `__REPLY_TO__` header land on bob's stdout → bob goes away → send

-//! spools (`QUEUED`) → `ready --once` drains the backlog. If the documented

-//! flow stops working, this fails — the doc cannot silently rot (REQ-DOCS-2's

-//! "runnable, deterministic" promise, CI-run on both platforms).

-//! (M7 D1: `poll` merged into `ready --once`; the full lea/sergey + how-to

+//! real binary: the `how-to` topics the agent prompt blocks point at name the

+//! commands the guide relies on → ready brings sergey online → live send

+//! delivers (`SENT`) and the body + `__REPLY_TO__` header land on sergey's

+//! stdout → sergey goes away → send spools (`QUEUED`) → `ready --once` drains

+//! the backlog. If the documented flow stops working, this fails — the doc

+//! cannot silently rot (REQ-DOCS-2's "runnable, deterministic" promise,

+//! CI-run on both platforms). The pairing `<details>` section is two-machine

+//! by nature — its evidence is the twohost ladder's join rung, not this test.

let home = tempfile::tempdir().expect("tempdir");

-    // ---- step 2: bring an agent online (`spt ready bob`) ----

+    // ---- steps 3/4 preamble: the agent prompt blocks say "run `spt how-to

+    // <topic>` and follow it" — assert the in-binary guidance actually names

+    // the commands the guide's captured outputs rely on (REQ-DOCS-6: the

+    // binary is the single source; if these texts rot, the quickstart's

+    // prompts mislead every agent that follows them).

+    let topics = run_ok(home, &["how-to"]);

+    assert!(topics.contains("ready"), "topic list names ready: {topics:?}");

+    assert!(topics.contains("send"), "topic list names send: {topics:?}");

+    let ready_text = run_ok(home, &["how-to", "ready"]);

+    assert!(ready_text.contains("spt ready <id>"), "ready how-to names the command");

+    assert!(ready_text.contains("--once"), "ready how-to teaches the one-shot fallback");

+    assert!(ready_text.contains("__REPLY_TO__"), "ready how-to explains reply routing");

+    let send_text = run_ok(home, &["how-to", "send"]);

+    assert!(send_text.contains("spt send <target>"), "send how-to names the command");

+    assert!(send_text.contains("QUEUED"), "send how-to explains SENT vs QUEUED");

+    // ---- step 3: the receiver agent comes online (`spt ready sergey`) ----

-    // READY:bob is the online signal (stderr).

-    let mut bob_err = BufReader::new(bob.stderr.take().unwrap());

+    // READY:sergey is the online signal (stderr).

+    let mut sergey_err = BufReader::new(sergey.stderr.take().unwrap());

-    bob_err.read_line(&mut line).expect("read READY line");

-    assert!(line.contains("READY:bob"), "expected READY:bob, got {line:?}");

+    sergey_err.read_line(&mut line).expect("read READY line");

+    assert!(line.contains("READY:sergey"), "expected READY:sergey, got {line:?}");

-    // ---- step 3: live send from alice ----

+    // ---- step 4: live send from lea ----

+    let out = send(home, &["send", "sergey", "--from", "lea"], "hello sergey - lea here");

-        "live delivery must report SENT:bob, got {out:?}"

+        "live delivery must report SENT:sergey, got {out:?}"

-    // The documented receive shape: routing header + body on bob's stdout.

-    let mut bob_out = BufReader::new(bob.stdout.take().unwrap());

+    // The documented receive shape: routing header + body on sergey's stdout.

+    let mut sergey_out = BufReader::new(sergey.stdout.take().unwrap());

-    bob_out.read_line(&mut l1).expect("read header");

-    bob_out.read_line(&mut l2).expect("read body");

-    assert!(l1.contains("__REPLY_TO__:alice"), "header line, got {l1:?}");

-    assert!(l2.contains("hello bob - alice here"), "body line, got {l2:?}");

+    sergey_out.read_line(&mut l1).expect("read header");

+    sergey_out.read_line(&mut l2).expect("read body");

+    assert!(l1.contains("__REPLY_TO__:lea"), "header line, got {l1:?}");

+    assert!(l2.contains("hello sergey - lea here"), "body line, got {l2:?}");

-    // ---- step 4: bob goes away; the send spools ----

-    let out = send(home, &["send", "bob", "--from", "alice"], "ping while you were away");

+    // ---- step 5: sergey goes away; the send spools ----

+    sergey.kill().expect("stop sergey");

+    let out = send(home, &["send", "sergey", "--from", "lea"], "ping while you were away");

-        "offline delivery must report QUEUED:bob, got {out:?}"

+        "offline delivery must report QUEUED:sergey, got {out:?}"

-    // ---- bob comes back: the backlog drains ----

+    // ---- sergey comes back: the backlog drains (`ready --once`) ----

-        .args(["ready", "bob", "--once"])

+        .args(["ready", "sergey", "--once"])

+/// Run a one-shot spt command expecting exit 0; return its stdout.

+fn run_ok(home: &std::path::Path, args: &[&str]) -> String {

+    let out = spt().args(args).env("SPT_HOME", home).output().expect("spt runs");

+    assert!(out.status.success(), "`spt {}` failed: {:?}", args.join(" "), out);

+    String::from_utf8_lossy(&out.stdout).into_owned()

//! M6-D4 quickstart rung — the published human quickstart
//! (docs-site/src/quickstart/messaging.md) executed step for step against the
//! real binary: ready brings bob online → live send delivers (`SENT`) and the
//! body + `__REPLY_TO__` header land on bob's stdout → bob goes away → send
//! spools (`QUEUED`) → `ready --once` drains the backlog. If the documented
//! flow stops working, this fails — the doc cannot silently rot (REQ-DOCS-2's
//! "runnable, deterministic" promise, CI-run on both platforms).
//! (M7 D1: `poll` merged into `ready --once`; the full lea/sergey + how-to
//! rewrite is the D5 deliverable.)
//!
//! Hermetic: isolated SPT_HOME per run; not env-gated (cheap, local-only).
//
// [int->REQ-DOCS-2]

use std::io::{BufRead, BufReader, Read};
use std::process::{Command, Stdio};
use std::time::Duration;

fn spt() -> Command {
    Command::new(env!("CARGO_BIN_EXE_spt"))
}

#[test]
fn published_messaging_quickstart_runs_as_written() {
    let home = tempfile::tempdir().expect("tempdir");
    let home = home.path();

    // ---- step 2: bring an agent online (`spt ready bob`) ----
    let mut bob = spt()
        .args(["ready", "bob"])
        .env("SPT_HOME", home)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn ready bob");
    // READY:bob is the online signal (stderr).
    let mut bob_err = BufReader::new(bob.stderr.take().unwrap());
    let mut line = String::new();
    bob_err.read_line(&mut line).expect("read READY line");
    assert!(line.contains("READY:bob"), "expected READY:bob, got {line:?}");

    // ---- step 3: live send from alice ----
    let out = send(home, &["send", "bob", "--from", "alice"], "hello bob - alice here");
    assert!(
        out.contains("SENT:bob"),
        "live delivery must report SENT:bob, got {out:?}"
    );
    // The documented receive shape: routing header + body on bob's stdout.
    let mut bob_out = BufReader::new(bob.stdout.take().unwrap());
    let mut l1 = String::new();
    let mut l2 = String::new();
    bob_out.read_line(&mut l1).expect("read header");
    bob_out.read_line(&mut l2).expect("read body");
    assert!(l1.contains("__REPLY_TO__:alice"), "header line, got {l1:?}");
    assert!(l2.contains("hello bob - alice here"), "body line, got {l2:?}");

    // ---- step 4: bob goes away; the send spools ----
    bob.kill().expect("stop bob");
    let _ = bob.wait();
    let out = send(home, &["send", "bob", "--from", "alice"], "ping while you were away");
    assert!(
        out.contains("QUEUED:bob"),
        "offline delivery must report QUEUED:bob, got {out:?}"
    );

    // ---- bob comes back: the backlog drains ----
    let drained = spt()
        .args(["ready", "bob", "--once"])
        .env("SPT_HOME", home)
        .output()
        .expect("ready --once");
    let stdout = String::from_utf8_lossy(&drained.stdout);
    assert!(
        stdout.contains("ping while you were away"),
        "backlog must drain on return, got {stdout:?}"
    );
}

/// Run a one-shot spt command with `body` on stdin, returning stdout+stderr.
fn send(home: &std::path::Path, args: &[&str], body: &str) -> String {
    let mut child = spt()
        .args(args)
        .env("SPT_HOME", home)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn spt");
    {
        use std::io::Write;
        child
            .stdin
            .take()
            .unwrap()
            .write_all(body.as_bytes())
            .expect("write body");
    }
    // Bounded read (KNOWN-HAZARDS 5.6 posture): a hang here means a detached
    // child inherited the pipe.
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let out = child.wait_with_output();
        let _ = tx.send(out);
    });
    let out = rx
        .recv_timeout(Duration::from_secs(30))
        .expect("spt call must complete")
        .expect("spt runs");
    let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
    let mut e = String::new();
    let _ = std::io::Cursor::new(out.stderr).read_to_string(&mut e);
    s.push_str(&e);
    s
}


Edit

required_stages = ["impl", "unit"]  # activated M7 D3 (2026-06-05): pairhost responder/meet-rotation/joiner leg + pair-join IPC + guided CLI land; int (twohost product-surface join rung) added at D5. M7 D3 (plan decisions 8, 10)

required_stages = ["impl", "unit", "int"]  # activated M7 D3 (2026-06-05); int added M7 D5: the twohost ladder's pairing rung IS the product surface now — brain pair-join IPC (role A) against the daemon-hosted always-on responder (role B), replacing the test-harness-driven ceremony as the pairing evidence. M7 D3 (plan decisions 8, 10)


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)

title = "Guided join e2e: spt subnet join CLI initiator + always-on daemon pairing responder"

-required_stages = ["impl", "unit"]  # activated M7 D3 (2026-06-05): pairhost responder/meet-rotation/joiner leg + pair-join IPC + guided CLI land; int (twohost product-surface join rung) added at D5. M7 D3 (plan decisions 8, 10) — the join ceremony's PRODUCT surface (the SPAKE2 wire is rig-proven but test-only today). Every member daemon hosts the pre-trust pairing ALPN responder ALWAYS-ON (ADR-0005 one-sided-UX intent; the red-team #11 subnet-global rate limiter + one-ceremony-per-subnet is the standing-listener guard). `spt subnet join [NAME] [--code <CODE>]`: prompts for missing pieces, LAN+relay rendezvous, clean wrong-code re-prompt (bounded, limiter surfaced honestly), actionable no-seed-holder hints, seed transfer + trust pinning + REQ-INST-15 home adoption on join. int = twohost ladder gains a product-surface join rung (CLI initiator vs daemon responder) replacing the test-harness-driven ceremony as pairing evidence.

+required_stages = ["impl", "unit", "int"]  # activated M7 D3 (2026-06-05); int added M7 D5: the twohost ladder's pairing rung IS the product surface now — brain pair-join IPC (role A) against the daemon-hosted always-on responder (role B), replacing the test-harness-driven ceremony as the pairing evidence. M7 D3 (plan decisions 8, 10) — the join ceremony's PRODUCT surface (the SPAKE2 wire is rig-proven but test-only today). Every member daemon hosts the pre-trust pairing ALPN responder ALWAYS-ON (ADR-0005 one-sided-UX intent; the red-team #11 subnet-global rate limiter + one-ceremony-per-subnet is the standing-listener guard). `spt subnet join [NAME] [--code <CODE>]`: prompts for missing pieces, LAN+relay rendezvous, clean wrong-code re-prompt (bounded, limiter surfaced honestly), actionable no-seed-holder hints, seed transfer + trust pinning + REQ-INST-15 home adoption on join. int = twohost ladder gains a product-surface join rung (CLI initiator vs daemon responder) replacing the test-harness-driven ceremony as pairing evidence.

title = "Node labels: hostname-default, gossiped, addressable in @node qualifiers (refuse-on-ambiguity)"


required_stages = ["impl", "unit"]  # activated M7 D1 (2026-06-05): command + ready/send topics land; int (quickstart_e2e how-to assert) added at D5. M7 D1/D4 (plan decision 12).

required_stages = ["impl", "unit", "int"]  # activated M7 D1 (2026-06-05); int added M7 D5: quickstart_e2e asserts the how-to texts name the commands the published guide's prompt blocks rely on. M7 D1/D4 (plan decision 12).


title = "spt how-to <topic>: in-binary task-oriented agent instructions (anti-drift; quickstart prompts point agents at it)"

-required_stages = ["impl", "unit"]  # activated M7 D1 (2026-06-05): command + ready/send topics land; int (quickstart_e2e how-to assert) added at D5. M7 D1/D4 (plan decision 12). Visible command, Agent-commands help group. Topics v1: `ready` (background-task guidance, --once fallback loop, reply mechanics) + `send` (send/ring, reply-to, SENT vs QUEUED); bare `how-to` lists topics. Single source = the binary — docs-site never duplicates the content (the DOCS-STRATEGY anti-drift posture applied to agent guidance); quickstart prompt blocks instruct agents to run it and follow. int = quickstart_e2e asserts the how-to output names the commands the guide relies on.

+required_stages = ["impl", "unit", "int"]  # activated M7 D1 (2026-06-05); int added M7 D5: quickstart_e2e asserts the how-to texts name the commands the published guide's prompt blocks rely on. M7 D1/D4 (plan decision 12). Visible command, Agent-commands help group. Topics v1: `ready` (background-task guidance, --once fallback loop, reply mechanics) + `send` (send/ring, reply-to, SENT vs QUEUED); bare `how-to` lists topics. Single source = the binary — docs-site never duplicates the content (the DOCS-STRATEGY anti-drift posture applied to agent guidance); quickstart prompt blocks instruct agents to run it and follow. int = quickstart_e2e asserts the how-to output names the commands the guide relies on.

# ── Endpoint access control (ADR-0009, 2026-06-02; activate M4 node-tier) ──


Bash

cargo test -p spt --test quickstart_e2e 2>&1 | grep -E "test result|FAILED|panicked|assert" | head; traceable-reqs check >/dev/null 2>&1; echo "reqs=$?"

Run rewritten quickstart e2e + traceability


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


cargo test -p spt 2>&1 | grep -E "test result" | awk '{s+=$4; f+=$6} END {print "spt passed:"s" failed:"f}'; cargo test -p spt-daemon 2>&1 | grep -E "test result" | awk '{s+=$4; f+=$6} END {print "daemon passed:"s" failed:"f}'; cargo test -p spt-net 2>&1 | grep -E "test result" | awk '{s+=$4; f+=$6} END {print "net passed:"s" failed:"f}'; cargo run -p xtask -- check 2>&1 | tail -1

Full suites + drift gate


spt passed:97 failed:0
daemon passed:225 failed:0
net passed:99 failed:0
xtask check: OK


All green. Commit D5 with `[twohost]` tag so CI exercises the new rung:


git add -A && git commit -m "$(cat <<'EOF'
test(ci): M7 D5 [twohost] — product-surface join rung + quickstart e2e rewrite

The CI & acceptance deliverable (M7-PLAN.md D5):

- twohost ladder rung 1 is now the PRODUCT surface (int->REQ-SUBNET-2):
  role A drives the exact `spt subnet join` path — brain pair-join IPC →
  broker joiner leg → the unchanged SPAKE2 ceremony — against role B's
  daemon-hosted always-on responder (role B arms NOTHING anymore: no
  dedicated pairing endpoint, no manual run_responder loop; the rung
  passing proves a stock member daemon pairs a joiner). The explicit
  responder address is a new pair-join seam (PairJoinReq.responder_addr,
  mirroring meet_at's static-ip:port form — the rig has no relay or
  discovery for the rendezvous to resolve through); the guided CLI never
  sets it. SPT_TWO_HOST_PORT_PAIR retired — the responder rides the
  broker port.
- quickstart_e2e rewritten step for step against the M7 D4 guide:
  lea/sergey, `ready` / `ready --once`, and the how-to preamble
  (int->REQ-DOCS-6) asserting the in-binary topics name the commands the
  guide's prompt blocks rely on — if those texts rot, the quickstart
  misleads every agent that follows them, and this fails.
- REQ-SUBNET-2 + REQ-DOCS-6 stages now [impl, unit, int].
- spt 97 / spt-daemon 225 / spt-net 99 green locally; xtask check +
  traceable-reqs check green; the twohost rung runs on the [twohost] CI
  ladder (gravity + hfenduleam).

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

Commit M7 D5


warning: in the working copy of 'crates/spt-daemon/src/brain.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/broker.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/msg.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/nethost.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/pairhost.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/twohost.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/cli.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/tests/quickstart_e2e.rs', LF will be replaced by CRLF the next time Git touches it
[dev-freeform c152807] test(ci): M7 D5 [twohost] — product-surface join rung + quickstart e2e rewrite
 9 files changed, 164 insertions(+), 148 deletions(-)
c152807 test(ci): M7 D5 [twohost] — product-surface join rung + quickstart e2e rewrite
21025c8 docs(site): M7 D4 — quickstart persona rewrite, networking around subnet verbs, lea/sergey sweep
3df6e2b feat(daemon,cli): M7 D3 — always-on pairing responder + guided spt subnet join
3d909bb feat(net,daemon,cli): M7 D2 — node labels, addressable @node qualifiers, status --nodes
0f85f1d feat(cli): M7 D1 — spt subnet namespace, poll->ready --once merge, spt how-to
a6cf4e1 reqs(M7): mint REQ-SUBNET-1..4 + REQ-DOCS-6; annotate poll merge + surface moves


M7 complete — all five deliverables shipped this session. Final commune:


Write

M7 (subnet & quickstart UX) CODE-COMPLETE — all five deliverables shipped this session on dev-freeform:

- D1 0f85f1d — `spt subnet` namespace (status/create+QR/show-totp), `pair` deleted, `poll`→`ready --once`, `spt how-to` (ready/send topics).
- D2 3d909bb — node_label gossip field + hostname stamp, @node label/key-prefix qualifiers (refuse-on-ambiguity), `status --nodes` hybrid liveness (heard-map gossip-recency + probe-only-stale).
- D3 3df6e2b — always-on daemon pairing responder (ALPN-multiplexed on broker endpoint), meet rotation per TOTP step, pair-join IPC (unjournaled), guided `spt subnet join` with elevation gate; ADR-0005 amendment.
- D4 21025c8 — quickstart persona rewrite (prompt blocks → how-to), networking overview around subnet verbs, lea/sergey sweep, README. Acceptance #4 sweep clean.
- D5 c152807 [twohost] — twohost rung 1 = product surface (pair-join IPC vs daemon responder; PORT_PAIR retired; responder_addr rig seam on PairJoinReq), quickstart_e2e rewritten w/ how-to asserts. REQ-SUBNET-1..4, REQ-DOCS-6, REQ-MSG-2 all satisfied at activated stages; traceable-reqs green throughout.

Outstanding before M7 CLOSE:
1. Push dev-freeform → CI must run the [twohost] ladder (gravity + hfenduleam) to prove the product-surface join rung cross-host (the int evidence is tagged but not yet CI-proven). NOT pushed — awaiting user go.
2. Acceptance #1 (fresh two-machine setup via create/join only, elevation honored, status --nodes both sides) — real-rig manual run after CI green.
3. Acceptance #2 (human pastes prompt blocks into two agent sessions) — manual UAT.
4. M7-PLAN.md status flip + ROADMAP tick at closeout.

All local gates green: spt 97 / spt-daemon 225 / spt-net 99, xtask check, traceable-reqs check.


File created successfully at: C:\Users\decid\Documents\projects\spt-core\.claude\doyle-commune.md (file state is current in your context — no need to Read it back)

M7 (subnet & quickstart UX) CODE-COMPLETE — all five deliverables shipped this session on dev-freeform:

- D1 0f85f1d — `spt subnet` namespace (status/create+QR/show-totp), `pair` deleted, `poll`→`ready --once`, `spt how-to` (ready/send topics).
- D2 3d909bb — node_label gossip field + hostname stamp, @node label/key-prefix qualifiers (refuse-on-ambiguity), `status --nodes` hybrid liveness (heard-map gossip-recency + probe-only-stale).
- D3 3df6e2b — always-on daemon pairing responder (ALPN-multiplexed on broker endpoint), meet rotation per TOTP step, pair-join IPC (unjournaled), guided `spt subnet join` with elevation gate; ADR-0005 amendment.
- D4 21025c8 — quickstart persona rewrite (prompt blocks → how-to), networking overview around subnet verbs, lea/sergey sweep, README. Acceptance #4 sweep clean.
- D5 c152807 [twohost] — twohost rung 1 = product surface (pair-join IPC vs daemon responder; PORT_PAIR retired; responder_addr rig seam on PairJoinReq), quickstart_e2e rewritten w/ how-to asserts. REQ-SUBNET-1..4, REQ-DOCS-6, REQ-MSG-2 all satisfied at activated stages; traceable-reqs green throughout.

Outstanding before M7 CLOSE:
1. Push dev-freeform → CI must run the [twohost] ladder (gravity + hfenduleam) to prove the product-surface join rung cross-host (the int evidence is tagged but not yet CI-proven). NOT pushed — awaiting user go.
2. Acceptance #1 (fresh two-machine setup via create/join only, elevation honored, status --nodes both sides) — real-rig manual run after CI green.
3. Acceptance #2 (human pastes prompt blocks into two agent sessions) — manual UAT.
4. M7-PLAN.md status flip + ROADMAP tick at closeout.

All local gates green: spt 97 / spt-daemon 225 / spt-net 99, xtask check, traceable-reqs check.


**M7 code-complete.** One session, five commits:

| D | Commit | What |
|---|--------|------|
| D1 | 0f85f1d | `spt subnet` namespace (create+QR, show-totp, status), `pair` deleted, `poll`→`ready --once`, `spt how-to` |
| D2 | 3d909bb | node labels in gossip, `@node` label/key-prefix qualifiers (refuse-on-ambiguity), `status --nodes` hybrid liveness |
| D3 | 3df6e2b | always-on daemon pairing responder + meet rotation, `pair-join` IPC, guided `spt subnet join`, elevation gates, ADR-0005 amendment |
| D4 | 21025c8 | quickstart persona rewrite (agent prompt blocks → in-binary how-to), networking overview around subnet verbs, lea/sergey sweep |
| D5 | c152807 | twohost product-surface join rung, quickstart_e2e rewrite w/ how-to asserts, `[twohost]` tagged |

All gates green: spt 97 / spt-daemon 225 / spt-net 99 tests, xtask check, traceable-reqs check, banned-reference sweep clean. REQ-SUBNET-1..4 + REQ-DOCS-6 satisfied at activated stages.

**Not done — needs you:**
1. **Push** dev-freeform — triggers [twohost] CI on gravity+hfenduleam to prove the cross-host join rung. Say word, I push.
2. Acceptance #1 after CI green: real two-machine `create`/`join` run (elevated prompts honored).
3. Acceptance #2: paste quickstart prompt blocks into two real agent sessions.


pushed myself


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


Push done. Watching CI:


Bash

gh run list --branch dev-freeform --limit 5 2>&1

List recent CI runs on dev-freeform


in_progress		test(ci): M7 D5 [twohost] — product-surface join rung + quickstart e2…	ci	dev-freeform	push	27054437482	41s	2026-06-06T06:00:55Z
completed	success	docs-publish	docs-publish	dev-freeform	workflow_dispatch	27048259389	11m59s	2026-06-06T01:07:19Z
completed	success	fix(docs-site): theme sizing — mdBook root is 10px, not 16px	ci	dev-freeform	push	27048259521	7m37s	2026-06-06T01:07:19Z
completed	success	docs(closeout): M6 COMPLETE — v0.1.0 shipped, public, acceptance green	ci	dev-freeform	push	27048045686	7m47s	2026-06-06T00:59:31Z
completed	cancelled	feat(release): D7 acceptance rungs — release-verify tool + live-relea…	ci	dev-freeform	push	27047872992	6m59s	2026-06-06T00:52:50Z


Inspect running CI jobs


* dev-freeform ci · 27054437482
Triggered via push less than a minute ago

JOBS
✓ traceability in 9s (ID 79855957360)
* test (self-hosted, Linux, gravity) (ID 79855957365)
* test (self-hosted, Windows, hfenduleam) (ID 79855957367)

For more information about a job, try: gh run view --job=<job-id>
View this run on GitHub: https://github.com/SaberMage/spt-core/actions/runs/27054437482


Traceability ✓, both runners going. Prior runs ~8 min — watching in background:


gh run watch 27054437482 --exit-status 2>&1 | tail -20

Watch CI run to completion


Command running in background with ID: be6b1ohdn. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\be6b1ohdn.output. You will be notified when it completes. To check interim output, use Read on that file path.


Watcher armed (be6b1ohdn) — will report when ladder finishes. Twohost rung 1 (product-surface join) is the thing to watch: role A pair-join IPC against role B's daemon-hosted responder, first cross-host run.

Idle until CI notifies.


check on ci?


Check CI run status


* dev-freeform ci · 27054437482
Triggered via push about 10 minutes ago

JOBS
✓ traceability in 9s (ID 79855957360)
✓ test (self-hosted, Linux, gravity) in 7m40s (ID 79855957365)
✓ test (self-hosted, Windows, hfenduleam) in 4m59s (ID 79855957367)
* twohost-b (ID 79856448124)
* twohost-a (ID 79856448125)

For more information about a job, try: gh run view --job=<job-id>
View this run on GitHub: https://github.com/SaberMage/spt-core/actions/runs/27054437482


gh run view --job=79856448125 --log 2>&1 | grep -iE "pairing|TWOHOST|rung" | tail -15; echo ---B---; gh run view --job=79856448124 --log 2>&1 | grep -iE "pairing|TWOHOST|rung" | tail -10

Grep twohost job logs for pairing progress


---B---


Check twohost-a job steps


* dev-freeform ci · 27054437482
Triggered via push about 11 minutes ago

* twohost-a (ID 79856448125)
  ✓ Set up job
  ✓ Run actions/checkout@v5
  ✓ Checkout spt-shell-notify (toast rung)
  ✓ Build notify-shell
  ✓ Build spt binary (toast rung)
  * Two-host ladder — role A (joiner/driver)
  * Post Checkout spt-shell-notify (toast rung)
  * Post Run actions/checkout@v5

To see the full job log, try: gh run view --log --job=79856448125
View this run on GitHub: https://github.com/SaberMage/spt-core/actions/runs/27054437482


Ladder mid-run:

- traceability ✓ 9s
- gravity test ✓ 7m40s, hfenduleam test ✓ 4m59s
- **twohost-a/b running** — role A in "Two-host ladder" step (logs stream only after step ends; rig window 300s/rung)

Watcher be6b1ohdn still armed — fires on completion. New join rung verdict comes with it.


check again


Check CI run status


* dev-freeform ci · 27054437482
Triggered via push about 17 minutes ago

JOBS
✓ traceability in 9s (ID 79855957360)
✓ test (self-hosted, Linux, gravity) in 7m40s (ID 79855957365)
✓ test (self-hosted, Windows, hfenduleam) in 4m59s (ID 79855957367)
* twohost-b (ID 79856448124)
* twohost-a (ID 79856448125)

For more information about a job, try: gh run view --job=<job-id>
View this run on GitHub: https://github.com/SaberMage/spt-core/actions/runs/27054437482


gh api repos/SaberMage/spt-core/actions/jobs/79856448125/logs 2>&1 | grep -aiE "TWOHOST|pairing|panic|FAILED" | tail -15; echo "---exit $?"

Try partial log fetch for twohost-a


---exit 0


gh api repos/SaberMage/spt-core/actions/jobs/79856448125/logs 2>&1 | tail -5; echo; gh api repos/SaberMage/spt-core/actions/jobs/79856448125 --jq '.steps[] | select(.name | contains("ladder")) | {name, status, started_at}' 2>&1

Check ladder step timing


<?xml version="1.0" encoding="utf-8"?><Error>BlobNotFound<Message>The specified blob does not exist.
RequestId:ea122506-a01e-003d-577c-f54763000000
Time:2026-06-06T06:18:17.9720353Z</Message></Error>gh: HTTP 404

{"name":"Two-host ladder — role A (joiner/driver)","started_at":"2026-06-06T06:09:56Z","status":"in_progress"}


Still running — ladder step ~8.5 min in (started 06:09:56Z). Live logs not fetchable mid-step (GitHub serves them only after step ends).

Read: pairing rung stuck would panic at the 300s rig window and fail the job fast — 8.5 min still-running suggests it's past pairing, walking the longer rungs (remote-drive, notif, presence, shell, update). Not certain though.

Watcher fires on completion. Sit tight.


estimate the remaining time and schedule a /new-alarm for yourself to draw a line and troubleshoot if it hasn't concluded by then


Baseline first — last successful twohost job duration:


gh run list --limit 30 --json databaseId,displayTitle,conclusion --jq '.[] | select(.displayTitle | contains("[twohost]")) | "\(.databaseId) \(.conclusion) \(.displayTitle[0:60])"' 2>&1 | head -5

Find prior twohost runs


27054437482  test(ci): M7 D5 [twohost] — product-surface join rung + quic
26997030279 cancelled test(daemon,ci): M5-D9a — four M5 rungs on the twohost ladde
26992532561 failure docs(plan): M5-D7 JIT plan — carried infra; close D6 plan [t


gh run list --limit 60 --json databaseId,displayTitle,conclusion --jq '.[] | select(.displayTitle | contains("twohost") or contains("[twohost]")) | "\(.databaseId) \(.conclusion)"' 2>&1 | head; gh api repos/SaberMage/spt-core/actions/runs/26997030279/jobs --jq '.jobs[] | select(.name | contains("twohost")) | "\(.name) \(.conclusion) \(.started_at) -> \(.completed_at)"' 2>&1

Get prior twohost job timings


27054437482
26998058816 success
26997030279 cancelled
26992532561 failure
26958175812 success
twohost-b cancelled 2026-06-05T05:26:26Z -> 2026-06-05T05:40:56Z
twohost-a failure 2026-06-05T05:26:14Z -> 2026-06-05T05:28:58Z


gh api repos/SaberMage/spt-core/actions/runs/26998058816/jobs --jq '.jobs[] | select(.name | contains("twohost")) | "\(.name) \(.conclusion) \(.started_at) -> \(.completed_at)"' 2>&1

Timing of last successful twohost run


twohost-a success 2026-06-05T05:57:32Z -> 2026-06-05T06:00:57Z
twohost-b success 2026-06-05T05:57:33Z -> 2026-06-05T06:00:56Z


$OWL new-alarm doyle 15m -- "CI twohost line reached: run 27054437482 should be done by now (baseline 3.5m, alarm set at ~12m into ladder). If still in_progress or failed: pull twohost-a/b logs, diagnose the join rung (pair_join IPC vs daemon responder), check for hung dial in join_ceremony (no timeout on connect_with_alpn) or meet-rotation interference on Port-scoped rig endpoints." 2>&1

Set 15-minute troubleshoot alarm


Error: Exit code 1
⚠ NEW-ALARM-INVALID: unrecognized time spec '15m': expected ISO-8601 (2026-03-29T15:30:00) or relative (+30m, +2h, +1h30m)


$OWL new-alarm doyle +15m -- "CI twohost line reached: run 27054437482 should be done by now (baseline 3.5m; alarm set ~12m into ladder step). If still in_progress or failed: pull twohost-a/b logs, diagnose join rung (pair_join IPC vs daemon responder), check hung dial in join_ceremony (no timeout on connect_with_alpn) and meet-rotation interference on Port-scoped rig endpoints." 2>&1

Set 15-minute troubleshoot alarm (relative syntax)


➜ NEW-ALARM-SCHEDULED (fires at epoch 1780728063)


Alarm set — fires in 15 min via my listener.

State:
- Baseline twohost: ~3.5 min. Current: ~12 min in ladder step. Over 3× — degraded or stuck.
- No failure yet ⇒ pairing rung likely passed (its 300s window would have panicked by now).
- Two lines of suspicion queued in the alarm note: `join_ceremony` dial has no own timeout (`connect_with_alpn` hang), and meet-rotation listeners possibly interfering on the rig's Port-scoped endpoints.

Triggers now armed: CI watcher (be6b1ohdn) fires on completion; alarm fires at +15m if not. Whichever lands first, I act.


<task-notification>
<task-id>be6b1ohdn</task-id>
<tool-use-id>toolu_01NpauURLs8qeitCgmxzTns3</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\be6b1ohdn.output</output-file>
<status>completed</status>
<summary>Background command "Watch CI run to completion" completed (exit code 0)</summary>
</task-notification>


1	  ✓ Build spt binary (toast rung)
2	  X Two-host ladder — role B (seed-holder/server)
3	  ✓ Post Checkout spt-shell-notify (toast rung)
4	  ✓ Post Run actions/checkout@v5
5	  ✓ Complete job
6	X twohost-a in 16m51s (ID 79856448125)
7	  ✓ Set up job
8	  ✓ Run actions/checkout@v5
9	  ✓ Checkout spt-shell-notify (toast rung)
10	  ✓ Build notify-shell
11	  ✓ Build spt binary (toast rung)
12	  X Two-host ladder — role A (joiner/driver)
13	  ✓ Post Checkout spt-shell-notify (toast rung)
14	  ✓ Post Run actions/checkout@v5
15	  ✓ Complete job
16
17	ANNOTATIONS
18	X Process completed with exit code 1.
19	twohost-a: .github#254
20
21

  ✓ Build spt binary (toast rung)
  X Two-host ladder — role B (seed-holder/server)
  ✓ Post Checkout spt-shell-notify (toast rung)
  ✓ Post Run actions/checkout@v5
  ✓ Complete job
X twohost-a in 16m51s (ID 79856448125)
  ✓ Set up job
  ✓ Run actions/checkout@v5
  ✓ Checkout spt-shell-notify (toast rung)
  ✓ Build notify-shell
  ✓ Build spt binary (toast rung)
  X Two-host ladder — role A (joiner/driver)
  ✓ Post Checkout spt-shell-notify (toast rung)
  ✓ Post Run actions/checkout@v5
  ✓ Complete job

ANNOTATIONS
X Process completed with exit code 1.
twohost-a: .github#254


Both twohost jobs failed. Logs:


gh run view --job=79856448125 --log 2>&1 | grep -aE "TWOHOST|pairing|panicked|FAILED|test result|retrying" | tail -30

Pull twohost-a failure log


twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:23:23.3338061Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:23:28.4578838Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:23:33.5715112Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:23:38.6864365Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:23:43.8265932Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:23:48.9489862Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:23:54.1282943Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:23:59.2377734Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:24:04.3900888Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:24:09.5042146Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:24:14.6313866Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:24:19.7588766Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:24:24.8818277Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:24:29.9879975Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:24:35.0961063Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:24:40.2098422Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:24:45.3289957Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:24:50.4434251Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:24:55.5632563Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:25:00.7185365Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:25:05.8769056Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:25:10.9958740Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:25:16.1142988Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:25:21.2343669Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:25:26.3585344Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:25:31.4728881Z pairing attempt refused (other: read len: connection lost); retrying
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:25:36.4731161Z thread 'two_host_ladder_role_a' (54788) panicked at crates\spt-daemon\tests\twohost.rs:702:13:
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:25:36.4731489Z pairing never completed within the rig window
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:25:36.4744737Z test two_host_ladder_role_a ... FAILED
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:25:36.4745536Z test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 903.82s


gh run view --job=79856448124 --log 2>&1 | grep -aE "PAIRING|PAIRED|pairing|busy|TWOHOST|panicked|test result" | tail -25

Pull twohost-b failure log


twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:23:38.6849316Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:23:43.8253480Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:23:48.9477646Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:23:54.1214875Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:23:59.2384210Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:24:04.3887554Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:24:09.5027580Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:24:14.6300665Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:24:19.7514425Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:24:24.8804626Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:24:29.9867184Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:24:35.0976916Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:24:40.2083144Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:24:45.3274259Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:24:50.4434044Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:24:55.5601618Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:25:00.7186888Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:25:05.8746364Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:25:10.9946645Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:25:16.1137849Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:25:21.2308613Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:25:26.3583335Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:25:31.4720064Z PAIRED: joiner pinned (AlreadyTrusted)
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:26:46.1305811Z thread 'two_host_ladder_role_b' (2157729) panicked at crates/spt-daemon/tests/twohost.rs:247:5:
twohost-b	Two-host ladder — role B (seed-holder/server)	2026-06-06T06:26:46.1343092Z test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 900.54s


Edit

    let Ok(mut rate) = rate.try_lock() else {
        conn.close(1u32.into(), b"pairing busy");
        return;
    };
    let subnets = SubnetStore::load();
    let mut trust = TrustStore::load();
    match tokio::time::timeout(
        CEREMONY_TIMEOUT,
        run_responder(&conn, local_pub, &subnets, &mut rate, &mut trust, now_secs()),
    )
    .await
    {
        Ok(Ok(outcome)) => match trust.save() {
            Ok(()) => eprintln!("PAIRED: joiner pinned ({outcome:?})"),
            Err(e) => eprintln!("PAIRING_TRUST_SAVE_FAIL: {e}"),
        },
        // A failed ceremony is normal traffic (wrong code, impostor probe,
        // vanished peer) — logged, never fatal; the limiter charged it.
        Ok(Err(e)) => eprintln!("PAIRING_ATTEMPT_FAIL: {e}"),
        Err(_) => eprintln!("PAIRING_TIMEOUT: ceremony exceeded {CEREMONY_TIMEOUT_SECS}s"),
    }
}

    let Ok(mut rate) = rate.try_lock() else {
        conn.close(1u32.into(), b"pairing busy");
        return;
    };
    let subnets = SubnetStore::load();
    let mut trust = TrustStore::load();
    match tokio::time::timeout(
        CEREMONY_TIMEOUT,
        run_responder(&conn, local_pub, &subnets, &mut rate, &mut trust, now_secs()),
    )
    .await
    {
        Ok(Ok(outcome)) => match trust.save() {
            Ok(()) => eprintln!("PAIRED: joiner pinned ({outcome:?})"),
            Err(e) => eprintln!("PAIRING_TRUST_SAVE_FAIL: {e}"),
        },
        // A failed ceremony is normal traffic (wrong code, impostor probe,
        // vanished peer) — logged, never fatal; the limiter charged it.
        Ok(Err(e)) => eprintln!("PAIRING_ATTEMPT_FAIL: {e}"),
        Err(_) => eprintln!("PAIRING_TIMEOUT: ceremony exceeded {CEREMONY_TIMEOUT_SECS}s"),
    }
    // Let the JOINER close first (the `meet::serve_once` discipline, bounded):
    // `SendStream::finish()` only queues the final Done/Seed (or abort)
    // frames — dropping the connection here sends an immediate QUIC CLOSE
    // that discards unacked stream data, and the joiner loses the race and
    // reads "connection lost" instead of its seed (the exact failure the
    // first twohost run of the product-surface rung hit). The joiner drops
    // its side once it has read the final frame; only then (or after the
    // grace bound, for a vanished peer) does this side release the conn.
    let _ = tokio::time::timeout(CLOSE_GRACE, conn.closed()).await;
}


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

    let Ok(mut rate) = rate.try_lock() else {
        conn.close(1u32.into(), b"pairing busy");
        return;
    };
    let subnets = SubnetStore::load();
    let mut trust = TrustStore::load();
    match tokio::time::timeout(
        CEREMONY_TIMEOUT,
        run_responder(&conn, local_pub, &subnets, &mut rate, &mut trust, now_secs()),
    )
    .await
    {
        Ok(Ok(outcome)) => match trust.save() {
            Ok(()) => eprintln!("PAIRED: joiner pinned ({outcome:?})"),
            Err(e) => eprintln!("PAIRING_TRUST_SAVE_FAIL: {e}"),
        },
        // A failed ceremony is normal traffic (wrong code, impostor probe,
        // vanished peer) — logged, never fatal; the limiter charged it.
        Ok(Err(e)) => eprintln!("PAIRING_ATTEMPT_FAIL: {e}"),
        Err(_) => eprintln!("PAIRING_TIMEOUT: ceremony exceeded {CEREMONY_TIMEOUT_SECS}s"),
    }
}

    let Ok(mut rate) = rate.try_lock() else {
        conn.close(1u32.into(), b"pairing busy");
        return;
    };
    let subnets = SubnetStore::load();
    let mut trust = TrustStore::load();
    match tokio::time::timeout(
        CEREMONY_TIMEOUT,
        run_responder(&conn, local_pub, &subnets, &mut rate, &mut trust, now_secs()),
    )
    .await
    {
        Ok(Ok(outcome)) => match trust.save() {
            Ok(()) => eprintln!("PAIRED: joiner pinned ({outcome:?})"),
            Err(e) => eprintln!("PAIRING_TRUST_SAVE_FAIL: {e}"),
        },
        // A failed ceremony is normal traffic (wrong code, impostor probe,
        // vanished peer) — logged, never fatal; the limiter charged it.
        Ok(Err(e)) => eprintln!("PAIRING_ATTEMPT_FAIL: {e}"),
        Err(_) => eprintln!("PAIRING_TIMEOUT: ceremony exceeded {CEREMONY_TIMEOUT_SECS}s"),
    }
    // Let the JOINER close first (the `meet::serve_once` discipline, bounded):
    // `SendStream::finish()` only queues the final Done/Seed (or abort)
    // frames — dropping the connection here sends an immediate QUIC CLOSE
    // that discards unacked stream data, and the joiner loses the race and
    // reads "connection lost" instead of its seed (the exact failure the
    // first twohost run of the product-surface rung hit). The joiner drops
    // its side once it has read the final frame; only then (or after the
    // grace bound, for a vanished peer) does this side release the conn.
    let _ = tokio::time::timeout(CLOSE_GRACE, conn.closed()).await;
}

+    // Let the JOINER close first (the `meet::serve_once` discipline, bounded):

+    // `SendStream::finish()` only queues the final Done/Seed (or abort)

+    // frames — dropping the connection here sends an immediate QUIC CLOSE

+    // that discards unacked stream data, and the joiner loses the race and

+    // reads "connection lost" instead of its seed (the exact failure the

+    // first twohost run of the product-surface rung hit). The joiner drops

+    // its side once it has read the final frame; only then (or after the

+    // grace bound, for a vanished peer) does this side release the conn.

+    let _ = tokio::time::timeout(CLOSE_GRACE, conn.closed()).await;

/// The meet-rotation loop: for every member subnet, keep a rendezvous meet


/// The ceremony bound: aligned with the rate limiter's own stale-slot
/// reclaim so an abandoned slot and an abandoned task expire together.
const CEREMONY_TIMEOUT: Duration = Duration::from_secs(CEREMONY_TIMEOUT_SECS);

/// The ceremony bound: aligned with the rate limiter's own stale-slot
/// reclaim so an abandoned slot and an abandoned task expire together.
const CEREMONY_TIMEOUT: Duration = Duration::from_secs(CEREMONY_TIMEOUT_SECS);

/// How long the responder waits for the joiner to close the connection
/// after the ceremony's final frames — the delivery grace for Done/Seed
/// (or an abort). Generous for a WAN joiner; a vanished peer costs at most
/// this before the conn is released.
const CLOSE_GRACE: Duration = Duration::from_secs(10);


/// The ceremony bound: aligned with the rate limiter's own stale-slot
/// reclaim so an abandoned slot and an abandoned task expire together.
const CEREMONY_TIMEOUT: Duration = Duration::from_secs(CEREMONY_TIMEOUT_SECS);

/// How long the responder waits for the joiner to close the connection
/// after the ceremony's final frames — the delivery grace for Done/Seed
/// (or an abort). Generous for a WAN joiner; a vanished peer costs at most
/// this before the conn is released.
const CLOSE_GRACE: Duration = Duration::from_secs(10);

+/// How long the responder waits for the joiner to close the connection

+/// after the ceremony's final frames — the delivery grace for Done/Seed

+/// (or an abort). Generous for a WAN joiner; a vanished peer costs at most

+const CLOSE_GRACE: Duration = Duration::from_secs(10);

/// Serve one inbound pairing ceremony (the always-on responder's per-

/// connection handler). Stores are loaded fresh per ceremony — a subnet

/// created or joined a second ago is immediately pairable, and a trust edit


// Let the JOINER close first (the `meet::serve_once` discipline, bounded):

    // Release the ceremony slot before the close-grace wait — the next
    // joiner must not be shed at the door for the lame-duck duration of
    // this connection's teardown.
    drop(rate);
    // Let the JOINER close first (the `meet::serve_once` discipline, bounded):


    // Release the ceremony slot before the close-grace wait — the next
    // joiner must not be shed at the door for the lame-duck duration of
    // this connection's teardown.
    drop(rate);
    // Let the JOINER close first (the `meet::serve_once` discipline, bounded):

Ok(Err(e)) => eprintln!("PAIRING_ATTEMPT_FAIL: {e}"),

Err(_) => eprintln!("PAIRING_TIMEOUT: ceremony exceeded {CEREMONY_TIMEOUT_SECS}s"),

+    // Release the ceremony slot before the close-grace wait — the next

+    // joiner must not be shed at the door for the lame-duck duration of

// `SendStream::finish()` only queues the final Done/Seed (or abort)

// frames — dropping the connection here sends an immediate QUIC CLOSE


Regression test — loopback joiner vs daemon-hosted responder (would have caught the race locally):


Write

//! M7 D3/D5 regression — the daemon-hosted pairing responder serves a real
//! joiner end to end over loopback: NetHost binds the pairing ALPN beside
//! the trusted surface, the accept loop routes the ceremony to
//! `pairhost::respond`, and `join_ceremony` (the `pair-join` broker leg)
//! completes against it — INCLUDING reading the final Done+Seed frames.
//!
//! That last clause is the regression: the first cross-host run of the
//! product-surface rung failed because the responder dropped the connection
//! the instant the ceremony returned — the QUIC CLOSE discarded the unacked
//! Done/Seed frames and the joiner read "connection lost" instead of its
//! seed, every attempt (`pairhost` now waits out the joiner's close under
//! `CLOSE_GRACE`). A loopback joiner exercises the same frame/close ordering
//! without the rig.
//!
//! Shared-home caveat: responder and joiner run in ONE process, so they
//! share SPT_HOME stores. The seed the responder serves is therefore already
//! "held" by the joiner's store (`add_joined` → `Exists` is the documented
//! idempotent re-pair path), and the trust writes race on one file — so the
//! assertion is the ceremony VERDICT (`Ok`), which can only happen if the
//! joiner read the full final exchange.
//
// [int->REQ-SUBNET-2]

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

use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::pairhost::join_ceremony;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, NetEndpoint, RelayPolicy};
use spt_net::net::pairing::totp::TotpSeed;
use spt_net::net::pairing::wire::SPT_PAIR_ALPN;
use spt_proto::identity::Identity;
use spt_store::subnet::SubnetStore;

fn now_secs() -> u64 {
    SystemTime::now().duration_since(UNIX_EPOCH).expect("clock").as_secs()
}

#[test]
fn daemon_hosted_responder_pairs_a_loopback_joiner_end_to_end() {
    let home = tempfile::tempdir().expect("temp home");
    std::env::set_var("SPT_HOME", home.path());

    // The responder side is a stock member: the subnet record exists, the
    // NetHost is up — nothing pairing-specific is armed (decision 8).
    let mut subnets = SubnetStore::load();
    let seed = [7u8; 20];
    subnets.add_joined("pairjoin", seed, 1).expect("seed subnet");
    subnets.save().expect("save subnets");

    let id_b = Identity::from_seed(&[2u8; 32]);
    let host = NetHost::start(NetConfig {
        identity: id_b,
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
    })
    .expect("net host start");
    let addr_b = host.addr();

    // The joiner leg, exactly as the broker `pair-join` arm runs it — its
    // own node identity, the current code, the explicit responder address
    // (the same static-address seam the twohost rig uses).
    let id_a = Identity::from_seed(&[3u8; 32]);
    let rt = tokio::runtime::Runtime::new().expect("tokio rt");
    let outcome = rt.block_on(async {
        let ep_a = NetEndpoint::bind_scoped(
            &id_a,
            RelayPolicy::Disabled,
            LocalDiscovery::Off,
            vec![SPT_PAIR_ALPN.to_vec()],
            BindScope::Loopback,
        )
        .await
        .expect("joiner endpoint bind");
        let code = TotpSeed::from_bytes(seed).code_at(now_secs());
        let out = join_ceremony(&ep_a, "pairjoin", &code, Some(addr_b)).await;
        ep_a.close().await;
        out
    });
    assert!(
        outcome.is_ok(),
        "the joiner must complete the ceremony INCLUDING the final Done+Seed \
         frames (the close-race regression), got: {outcome:?}"
    );
}


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

//! M7 D3/D5 regression — the daemon-hosted pairing responder serves a real
//! joiner end to end over loopback: NetHost binds the pairing ALPN beside
//! the trusted surface, the accept loop routes the ceremony to
//! `pairhost::respond`, and `join_ceremony` (the `pair-join` broker leg)
//! completes against it — INCLUDING reading the final Done+Seed frames.
//!
//! That last clause is the regression: the first cross-host run of the
//! product-surface rung failed because the responder dropped the connection
//! the instant the ceremony returned — the QUIC CLOSE discarded the unacked
//! Done/Seed frames and the joiner read "connection lost" instead of its
//! seed, every attempt (`pairhost` now waits out the joiner's close under
//! `CLOSE_GRACE`). A loopback joiner exercises the same frame/close ordering
//! without the rig.
//!
//! Shared-home caveat: responder and joiner run in ONE process, so they
//! share SPT_HOME stores. The seed the responder serves is therefore already
//! "held" by the joiner's store (`add_joined` → `Exists` is the documented
//! idempotent re-pair path), and the trust writes race on one file — so the
//! assertion is the ceremony VERDICT (`Ok`), which can only happen if the
//! joiner read the full final exchange.
//
// [int->REQ-SUBNET-2]

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

use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::pairhost::join_ceremony;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, NetEndpoint, RelayPolicy};
use spt_net::net::pairing::totp::TotpSeed;
use spt_net::net::pairing::wire::SPT_PAIR_ALPN;
use spt_proto::identity::Identity;
use spt_store::subnet::SubnetStore;

fn now_secs() -> u64 {
    SystemTime::now().duration_since(UNIX_EPOCH).expect("clock").as_secs()
}

#[test]
fn daemon_hosted_responder_pairs_a_loopback_joiner_end_to_end() {
    let home = tempfile::tempdir().expect("temp home");
    std::env::set_var("SPT_HOME", home.path());

    // The responder side is a stock member: the subnet record exists, the
    // NetHost is up — nothing pairing-specific is armed (decision 8).
    let mut subnets = SubnetStore::load();
    let seed = [7u8; 20];
    subnets.add_joined("pairjoin", seed, 1).expect("seed subnet");
    subnets.save().expect("save subnets");

    let id_b = Identity::from_seed(&[2u8; 32]);
    let host = NetHost::start(NetConfig {
        identity: id_b,
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
    })
    .expect("net host start");
    let addr_b = host.addr();

    // The joiner leg, exactly as the broker `pair-join` arm runs it — its
    // own node identity, the current code, the explicit responder address
    // (the same static-address seam the twohost rig uses).
    let id_a = Identity::from_seed(&[3u8; 32]);
    let rt = tokio::runtime::Runtime::new().expect("tokio rt");
    let outcome = rt.block_on(async {
        let ep_a = NetEndpoint::bind_scoped(
            &id_a,
            RelayPolicy::Disabled,
            LocalDiscovery::Off,
            vec![SPT_PAIR_ALPN.to_vec()],
            BindScope::Loopback,
        )
        .await
        .expect("joiner endpoint bind");
        let code = TotpSeed::from_bytes(seed).code_at(now_secs());
        let out = join_ceremony(&ep_a, "pairjoin", &code, Some(addr_b)).await;
        ep_a.close().await;
        out
    });
    assert!(
        outcome.is_ok(),
        "the joiner must complete the ceremony INCLUDING the final Done+Seed \
         frames (the close-race regression), got: {outcome:?}"
    );
}


Bash

cargo test -p spt-daemon --test pairjoin 2>&1 | tail -8

Run the regression test


    Finished `test` profile [unoptimized + debuginfo] target(s) in 28.92s
     Running tests\pairjoin.rs (target\debug\deps\pairjoin-25783e2c6497175d.exe)

running 1 test
test daemon_hosted_responder_pairs_a_loopback_joiner_end_to_end ... ok

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


Verify the test actually bites — run it with the fix reverted:


perl -0pi -e 's/    \/\/ Release the ceremony slot before the close-grace wait/    #[cfg(any())] \/\/ TEMP-REVERT marker\n    \/\/ Release the ceremony slot before the close-grace wait/' crates/spt-daemon/src/pairhost.rs 2>/dev/null; git stash push -- crates/spt-daemon/src/pairhost.rs >/dev/null 2>&1 && cargo test -p spt-daemon --test pairjoin 2>&1 | tail -4; git stash pop >/dev/null 2>&1; git diff --stat crates/spt-daemon/src/pairhost.rs

Run regression test against pre-fix responder via stash


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

error: test failed, to rerun pass `-p spt-daemon --test pairjoin`
 crates/spt-daemon/src/pairhost.rs | 20 ++++++++++++++++++++
 1 file changed, 20 insertions(+)


1	//! M7 D3 (REQ-SUBNET-2): the join ceremony's **product surface** — the
2	//! daemon-side hosting that turns the rig-proven SPAKE2/TOTP pairing wire
3	//! (`spt_net::net::pairing`) into something a user can actually run.
4	//!
5	//! Three pieces, all riding the broker's one node-identity endpoint:
6	//!
7	//! - **Responder, always-on** (ADR-0005 amendment; M7 plan decision 8): every
8	//!   member daemon accepts the pre-trust [`SPT_PAIR_ALPN`] beside the trusted
9	//!   surface — no arming step, the one-sided UX the ADR intended. The
10	//!   subnet-global [`PairingRateLimiter`] (red-team #11) is the standing-
11	//!   listener guard; [`respond`] additionally sheds concurrent ceremonies at
12	//!   the door (`try_lock`) and bounds each admitted ceremony to the
13	//!   limiter's own [`CEREMONY_TIMEOUT_SECS`] so a stalling peer cannot wedge
14	//!   the responder (the limiter's stale-slot reclaim is the backstop).
15	//! - **Meet rotation** ([`spawn_meet_rotation`]): for each member subnet, a
16	//!   short-lived rendezvous listener bound to the token-derived identity
17	//!   (`pairing::meet`), re-bound every TOTP step — how a joiner that knows
18	//!   nothing but (name, code) finds this responder over LAN or relay.
19	//! - **Joiner leg** ([`join_ceremony`]): dial via rendezvous, run the
20	//!   unchanged initiator ceremony, persist trust + the transferred seed.
21	//!   Invoked over the broker IPC (`pair-join`) because the ceremony binds
22	//!   the REAL node pubkey — and the node-identity endpoint lives here.
23	//!
24	//! Pairing connections never touch the trusted stream table: the ALPN routes
25	//! them to [`respond`] and the connection dies with the ceremony.
26	// [impl->REQ-SUBNET-2]
27
28	use std::sync::Arc;
29	use std::time::Duration;
30
31	use spt_net::net::endpoint::{BindScope, Connection, LocalDiscovery, NetEndpoint, RelayPolicy};
32	use spt_net::net::pairing::meet::{bind_rendezvous, dial_via_rendezvous};
33	use spt_net::net::pairing::ratelimit::{PairingRateLimiter, RateLimited, CEREMONY_TIMEOUT_SECS};
34	use spt_net::net::pairing::totp::{time_step, PERIOD_SECS};
35	use spt_net::net::pairing::wire::{run_initiator, run_responder, PairWireError};
36	use spt_proto::identity::PublicKey;
37	use spt_store::subnet::SubnetStore;
38	use spt_store::trust::TrustStore;
39
40	/// The ceremony bound: aligned with the rate limiter's own stale-slot
41	/// reclaim so an abandoned slot and an abandoned task expire together.
42	const CEREMONY_TIMEOUT: Duration = Duration::from_secs(CEREMONY_TIMEOUT_SECS);
43
44	/// How long the responder waits for the joiner to close the connection
45	/// after the ceremony's final frames — the delivery grace for Done/Seed
46	/// (or an abort). Generous for a WAN joiner; a vanished peer costs at most
47	/// this before the conn is released.
48	const CLOSE_GRACE: Duration = Duration::from_secs(10);
49
50	/// Serve one inbound pairing ceremony (the always-on responder's per-
51	/// connection handler). Stores are loaded fresh per ceremony — a subnet
52	/// created or joined a second ago is immediately pairable, and a trust edit
53	/// takes effect without a daemon restart (the same reload-per-use stance as
54	/// the dispatcher gates).
55	///
56	/// Concurrency: ONE ceremony at a time per daemon, enforced at the door —
57	/// a second connection finds the limiter lock held and is closed immediately
58	/// (the joiner sees the stream end and retries; same UX as the limiter's
59	/// `Busy` abort, shed before any frame is read).
60	// [impl->REQ-SUBNET-2]
61	pub async fn respond(
62	    conn: Connection,
63	    local_pub: PublicKey,
64	    rate: Arc<tokio::sync::Mutex<PairingRateLimiter>>,
65	) {
66	    let Ok(mut rate) = rate.try_lock() else {
67	        conn.close(1u32.into(), b"pairing busy");
68	        return;
69	    };
70	    let subnets = SubnetStore::load();
71	    let mut trust = TrustStore::load();
72	    match tokio::time::timeout(
73	        CEREMONY_TIMEOUT,
74	        run_responder(&conn, local_pub, &subnets, &mut rate, &mut trust, now_secs()),
75	    )
76	    .await
77	    {
78	        Ok(Ok(outcome)) => match trust.save() {
79	            Ok(()) => eprintln!("PAIRED: joiner pinned ({outcome:?})"),
80	            Err(e) => eprintln!("PAIRING_TRUST_SAVE_FAIL: {e}"),
81	        },
82	        // A failed ceremony is normal traffic (wrong code, impostor probe,
83	        // vanished peer) — logged, never fatal; the limiter charged it.
84	        Ok(Err(e)) => eprintln!("PAIRING_ATTEMPT_FAIL: {e}"),
85	        Err(_) => eprintln!("PAIRING_TIMEOUT: ceremony exceeded {CEREMONY_TIMEOUT_SECS}s"),
86	    }
87	    #[cfg(any())] // TEMP-REVERT marker
88	    // Release the ceremony slot before the close-grace wait — the next
89	    // joiner must not be shed at the door for the lame-duck duration of
90	    // this connection's teardown.
91	    drop(rate);
92	    // Let the JOINER close first (the `meet::serve_once` discipline, bounded):
93	    // `SendStream::finish()` only queues the final Done/Seed (or abort)
94	    // frames — dropping the connection here sends an immediate QUIC CLOSE
95	    // that discards unacked stream data, and the joiner loses the race and
96	    // reads "connection lost" instead of its seed (the exact failure the
97	    // first twohost run of the product-surface rung hit). The joiner drops
98	    // its side once it has read the final frame; only then (or after the
99	    // grace bound, for a vanished peer) does this side release the conn.
100	    let _ = tokio::time::timeout(CLOSE_GRACE, conn.closed()).await;
101	}
102
103	/// The meet-rotation loop: for every member subnet, keep a rendezvous meet
104	/// listener bound under the (name, current-step)-derived identity, answering
105	/// joiner probes with `real_addr` — the responder's actual pairing-endpoint
106	/// address. Re-binds at each TOTP step boundary (window rotation = re-bind,
107	/// `pairing::meet` docs); re-reads the subnet store per window so a freshly
108	/// created/joined subnet starts meeting within one step.
109	///
110	/// Bind failures are soft (logged, that subnet skipped this window): the
111	/// daemon must keep running net-less-for-pairing rather than die. A
112	/// [`BindScope::Port`] scope is mapped to [`BindScope::All`] — the fixed
113	/// port belongs to the main endpoint; a second bind on it would always fail.
114	// [impl->REQ-SUBNET-2]
115	pub fn spawn_meet_rotation(
116	    handle: &tokio::runtime::Handle,
117	    endpoint: Arc<NetEndpoint>,
118	    relay: RelayPolicy,
119	    discovery: LocalDiscovery,
120	    scope: BindScope,
121	) {
122	    let scope = match scope {
123	        BindScope::Port(_) => BindScope::All,
124	        s => s,
125	    };
126	    handle.spawn(async move {
127	        loop {
128	            let step = time_step(now_secs());
129	            let names: Vec<String> =
130	                SubnetStore::load().subnets.iter().map(|s| s.name.clone()).collect();
131	            // One listener task per subnet for this window's lifetime.
132	            let mut window_tasks = Vec::new();
133	            for name in names {
134	                let relay = relay.clone();
135	                let real_addr = endpoint.addr();
136	                window_tasks.push(tokio::spawn(async move {
137	                    let listener =
138	                        match bind_rendezvous(&name, step, relay, discovery, scope).await {
139	                            Ok(l) => l,
140	                            Err(e) => {
141	                                eprintln!("PAIR_MEET_BIND_FAIL:{name}: {e}");
142	                                return;
143	                            }
144	                        };
145	                    // Serve probes until the window task is aborted at the
146	                    // step boundary; each failure only ends one exchange.
147	                    loop {
148	                        if listener.serve_once(&real_addr).await.is_err() {
149	                            // Endpoint closed or a malformed probe — brief
150	                            // pause so a tight error can't spin the loop.

... [155 lines truncated] ...


Test **fails pre-fix, passes post-fix** — bites correctly. Remove the temp marker leftover:


Edit

    #[cfg(any())] // TEMP-REVERT marker
    // Release the ceremony slot before the close-grace wait — the next

// Release the ceremony slot before the close-grace wait — the next


    #[cfg(any())] // TEMP-REVERT marker
    // Release the ceremony slot before the close-grace wait — the next

-    #[cfg(any())] // TEMP-REVERT marker

// joiner must not be shed at the door for the lame-duck duration of


Bash

grep -n "TEMP-REVERT" crates/spt-daemon/src/pairhost.rs; cargo test -p spt-daemon --test pairjoin 2>&1 | tail -3 && cargo test -p spt-daemon --lib 2>&1 | grep "test result" && traceable-reqs check >/dev/null 2>&1; echo "reqs=$?"

Verify clean + retest


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

test result: ok. 178 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.85s
reqs=0


git add -A && git commit -m "$(cat <<'EOF'
fix(daemon): M7 [twohost] — responder must let the joiner close first (Done/Seed close race)

First cross-host run of the product-surface join rung (run 27054437482)
failed deterministically: role B completed the ceremony every attempt
(`PAIRED: joiner pinned`) while role A read `connection lost` on the final
length prefix — `pairhost::respond` dropped the connection the instant
`run_responder` returned, and the immediate QUIC CLOSE discarded the
still-unacked Done+Seed frames (`SendStream::finish()` only queues; it
does not deliver). The joiner therefore never persisted the seed, retried
into `AlreadyTrusted` forever, and both roles burned their rig windows.

Fix: after the ceremony (success or failure), the responder waits for the
JOINER to close the connection — the same discipline `meet::serve_once`
already uses — bounded by a 10s CLOSE_GRACE so a vanished peer cannot
park the task; the rate-limiter slot is released before the wait so the
grace never sheds the next joiner.

Regression coverage: new spt-daemon/tests/pairjoin.rs drives the full
joiner leg (`join_ceremony`, explicit responder address) against a stock
NetHost-hosted responder over loopback — verified to FAIL against the
pre-fix responder and pass with the fix; the assert is the ceremony
verdict, which requires reading the full final exchange.

spt-daemon suites green; traceable-reqs check green. [twohost] re-runs
the rig.

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

Commit close-race fix


warning: in the working copy of 'crates/spt-daemon/tests/pairjoin.rs', LF will be replaced by CRLF the next time Git touches it
[dev-freeform 7a22a63] fix(daemon): M7 [twohost] — responder must let the joiner close first (Done/Seed close race)
 2 files changed, 104 insertions(+)
 create mode 100644 crates/spt-daemon/tests/pairjoin.rs
7a22a63 fix(daemon): M7 [twohost] — responder must let the joiner close first (Done/Seed close race)
c152807 test(ci): M7 D5 [twohost] — product-surface join rung + quickstart e2e rewrite


**Fixed + committed** (7a22a63, `[twohost]` tagged).

Diagnosis:
- B completed ceremony every attempt; A died reading the final length prefix.
- `respond()` dropped the conn the instant the ceremony returned → QUIC CLOSE discarded unacked **Done+Seed** frames (`finish()` queues, doesn't deliver). A never got the seed, never saved, retried into `AlreadyTrusted` forever.

Fix: responder now waits for the **joiner** to close (the `meet::serve_once` discipline), 10s grace bound, limiter slot released before the wait.

Proof: new `pairjoin.rs` loopback regression — full joiner leg vs stock daemon responder. Verified **FAILS pre-fix, passes post-fix**. All daemon suites + traceable-reqs green.

Push to re-run the rig — me or you? (Alarm from earlier will fire shortly; it's stale, I'll ignore it.)


push done


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


gh run list --branch dev-freeform --limit 2 2>&1

Find new CI run


queued		fix(daemon): M7 [twohost] — responder must let the joiner close first…	ci	dev-freeform	push	27055141161	15s	2026-06-06T06:35:11Z
completed	failure	test(ci): M7 D5 [twohost] — product-surface join rung + quickstart e2…	ci	dev-freeform	push	27054437482	25m56s	2026-06-06T06:00:55Z


gh run watch 27055141161 --exit-status 2>&1 | tail -25

Watch fix run to completion


Command running in background with ID: bodx1uxzx. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bodx1uxzx.output. You will be notified when it completes. To check interim output, use Read on that file path.


Run 27055141161 queued, watcher armed (bodx1uxzx).

Expected timeline: single-host tests ~5-8 min → twohost ladder ~3.5 min if fix holds. Done ~15 min.

Note: earlier alarm fires mid-run (~6 min from now) — stale by then, will use it as a checkpoint instead. Watcher reports the verdict.


<task-notification>
<task-id>bi7aiqnpv</task-id>
<summary>Monitor event: "« spt event »"</summary>
<event><EVENT-PART seq="1/2" id="cbd4a91c" type="alarm" target-time="2026-06-05T23:41:03-07:00" current-time="2026-06-05T23:41:03-07:00">CI twohost line reached: run 27054437482 should be done by now (baseline 3.5m; alarm set ~12m into ladder step). If still in_progress or failed: pull twohost-a/b logs, diagnose join rung (pair_join IPC vs daemon responder), check hung dial in join_cer</EVENT-PART>
<EVENT-PART seq="2/2" id="cbd4a91c">emony (no timeout on connect_with_alpn) and meet-rotation interference on Port-scoped rig endpoints.</EVENT-PART></event>
</task-notification>


Alarm fired — stale (old run already diagnosed: Done/Seed close race, fixed in 7a22a63). Using it as checkpoint on the new run:


Checkpoint new CI run


* dev-freeform ci · 27055141161
Triggered via push about 6 minutes ago

JOBS
* traceability (ID 79857989099)
✓ test (self-hosted, Windows, hfenduleam) in 4m55s (ID 79857989100)
* test (self-hosted, Linux, gravity) (ID 79857989102)

For more information about a job, try: gh run view --job=<job-id>
View this run on GitHub: https://github.com/SaberMage/spt-core/actions/runs/27055141161


$OWL new-alarm doyle +15m -- "CI line: fix run 27055141161 (close-race fix 7a22a63) should be fully done incl. twohost ladder (~3.5m baseline post-fix). If twohost-a/b failed or still running: pull logs, check whether Done/Seed close race is truly gone (PAIRED once + role A 'product surface' OK line) or a second failure mode surfaced (meet rotation, CLOSE_GRACE shed, register rung)." 2>&1

Re-arm 15-minute line for fix run


➜ NEW-ALARM-SCHEDULED (fires at epoch 1780728986)


Line re-armed (+15m). Watcher bodx1uxzx still primary — fires first if run completes. Idle.


while we wait for that, a question: i notice there's no `spt live` command. what is the equivalent? (talking about parity with claude_skill_owl)


grep -n "live" CONTEXT.md | head -20; echo ---; grep -rn "spt live\|live agent\|LiveAgent" ROADMAP.md PRD.md | head -15

Survey live-agent surface story


7:Harness-independent core for the SPT ecosystem. Provides inter-agent messaging, live-agent lifecycle, terminal wrapping, self-update, and networking primitives — as both a Rust library workspace and a canonical reference binary. Designed so any agent runtime (Claude Code, Codex, Cursor, headless, future harnesses) can interface with the SPT ecosystem either by shelling out to the binary or by linking the crates directly.
22:**spt plugin** (separate downstream project — NOT an spt-core deliverable):
23:A rebuilt version of today's Claude Code `spt` plugin. It is the **first consumer** built *atop* spt-core and the **acceptance proof** of spt-core v1 (it reaches feature parity with modern SPT while delegating all core functionality to spt-core, primarily via `spt.exe`, with deeper hooks where useful) — but it **lives and builds in its own repository, outside spt-core**. It is a Claude-Code-specific *adapter*: it holds the Claude Code conventions (hooks, slash-commands, skill/plugin layout, `claude` session-invocation). **spt-core itself contains zero Claude Code conventions** — only the harness-agnostic contract the plugin binds to. The only adapter-shaped artifact ever in this repo is a generic mock/test adapter exercising the manifest + `api` contract (PRD R-DOCS-2). The M2 milestone delivers that contract + the lifecycle primitives; building the plugin is downstream work, not M2.
30:The single always-on, one-per-machine logical supervisor. Owns the PTYs for all hosted sessions, the node's network identity + WAN endpoint, the subnet registry, all spools, **all poll-listener logic, and all Psyche/pulse loops** — everything is consolidated here (no separate poll-listener or Psyche-wrapper processes; listeners already touch sessions directly under capsule/idle, and Psyche wrappers already invoke harness binaries directly, so they belong in the one supervisor). Collapses what the sister project planned as a separate `spt-node` daemon into one process — see Networking. The `spt-node` separate-deliverable concept is retired.
39:A thin, stateless `spt.exe` task that exists only in **harness-hosted** sessions (where the agent harness is the parent process and spt cannot reach into its process tree — today's Monitor model). It streams the daemon brain's events into the session's stdout. All *stateful* listener logic lives in the daemon; the relay is a dumb pipe, freely killable and respawnable. **spt-hosted** sessions need no relay — the daemon injects events straight into the PTY.
41:### Deliverable shape
63:Example shape (illustrative): `spt.exe --manifest spt-plugin.toml live start <id>`. A harness like the planned spt plugin wraps this invocation into the `$LIVE` / `$OWL` environment variables it injects into its sessions, so harness-internal callers continue to invoke `$LIVE` / `$OWL` unchanged.
66:The stable set of `spt.exe <subcommand>` entry points that harnesses bind their own hook systems to. When the harness's runtime emits an event (subagent started, tool just invoked, user typed `/clear`, session crashed), the harness's hook fires a short-lived `spt.exe <subcommand>` invocation that mutates on-disk SPT state (perch registry, spool, etc.). spt-core publishes this surface; harnesses author the bindings.
73:Every manifest declares a unified **`adapter_name`** (e.g. `claude-spt`), carried on every `api` invocation too. It is load-bearing: one daemon hosts endpoints from multiple adapters, so it resolves an endpoint's manifest + seams by `adapter_name`; adapter-update ripples target by it; capability/manifest lookup and telemetry key on it. The header also declares the adapter's own version and a **`min_spt_core_version`** — the minimum spt-core the adapter requires. This declaration must be **readable before an adapter update is applied** (it lives in the manifest header / a small metadata file fetched first), so spt-core can verify compatibility / expected supported features *before* committing the update. If installed spt-core < the adapter's `min_spt_core_version`, surface the incompatibility rather than silently breaking; when spt-core self-updates, re-verify adapters still satisfy (coordinate core + adapter updates when needed). This is a distinct compatibility axis from the node↔node/library wire-protocol version (see Workspace & versioning).
86:- **id resolution:** `id` is optional. With no id, spt-core reproduces today's no-id `/spt:live` behavior — run the lone live agent if that's all the project has; show a picker with proposed default IDs if the project has none; let the user choose if there are several.
88:**post-spawn seam** — the just-launched binary calls an spt-core command on boot (via the adapter's SessionStart-equivalent hook) to bind itself. Needed because the harness's own session id usually isn't known until after the binary runs. Payload: the harness `session_id` (when binary-generated rather than spt-core-injected); the `parent_pid` (the stable session-binding anchor — see KNOWN-HAZARDS 2.1); an endpoint identity/type confirmation; optionally a local HTTP port the binary listens on (for HTTP-mode input delivery, below); and a **boot nonce** (a generation/boot discriminator so a respawn-after-crash bind can't be confused with a stale duplicate — guards KNOWN-HAZARDS 2.4). The call flips the perch from skeleton → live.
100:**activity/idle detection** — **not** PTY-quiescence (insufficient: e.g. CC's AskUserQuestion stalls the PTY while holding stdin and needing nuanced input) and **not** a manifest-declared idle signal. Instead, the adapter calls spt-core activity/idle commands at the right moments (from its hooks); those commands manage activity/idle **sentinels inside the session perch**. The idle state lives in the perch, owned by spt-core via the commands API exposed to adapter devs.
102:**inject-input seam** — message delivery into a running session. Configurable per activity-state (activity / idle / both); multiple methods, any combination:
106:- adapter manifest requesting HTTP POST delivery to the endpoint binary on a local port (shared via the post-spawn seam).
117:There is no separate "model/billing" seam — those live inside the adapter's spawn/psyche/echo command templates. SPT never selects a model. The full manifest schema is `docs/MANIFEST.md`; key model-level facts from it:
130:- **`api bind`** — post-spawn boot bind (payload above). Skeleton→live.
131:- **`api listen`** — *long-running* relay/poll listener that an adapter-owned (harness-hosted) session owns as a child process; streams the daemon's events to the session's stdout. Distinct from the short-lived `api poll`. This is the heir to today's Monitor-bound `$LIVE start` poll loop.
132:- **`api poll`** — short-lived drain of queued messages for a session (the hook-injection delivery path). `--include-deferred` optionally also drains deferred rows, for adapter flexibility (default excludes them — KNOWN-HAZARDS 1.4/4.4).
136:- **`api boundary <clear|compact>`** — context-boundary report; **carries the new `session_id`** (it rotates on `/clear` or `/compact`), so the daemon rebinds the perch to the new session id while keeping the stable identity + `parent_pid` anchor. Authors a **Self-resume commune** (resume the Self session → commune file-drop) rather than a background echo — strong live-context signal at the boundary (see `docs/CONTEXT-MEMORY.md`).
148:**Harness-hosted (e.g. spt-plugin; the harness binary is user-launched, harness is the parent).** Key constraint: the SPT *live agent* does not exist until the agent invokes start — the `live_id` isn't chosen at session boot, and `$LIVE start` is itself invoked *behind the Monitor tool*, so it becomes the long-running relay. So binding cannot happen at SessionStart directly. A **seed record** (daemon-held, in-memory — not a file) bridges the gap:
---
ROADMAP.md:31:  - **M2b — Live-agent lifecycle. ✅ delivered (2026-06-01).** `spt-live` (fifth layer): history subsystem (fetcher/locate-normalize/native) + spawn-psyche (nested perch, spt-core-owned `$psyche_prompt`) + echo-commune (history brief → single-writer commune drop) + drop-file ingest (single-writer 6.4 + direct-write precedence 6.5) + interim pulse + signoff/boundary ordering invariants (echo-before-signoff 3.3, grace-before-signoff 1.1, stale-signoff sweep 3.2) + resume seam (continue-existing / fresh-with-preload). Hosted by the `api listen` runner + `api shutdown`; mock adapter gained the lifecycle templates; LiveAgent E2E proves spawn→Psyche-perch→commune-ingest→graceful-signoff harness-agnostically. All M2b reqs activated + green. Task breakdown: [`M2b-PLAN.md`](./M2b-PLAN.md).
PRD.md:97:- **R-EP-1** Day-one types: **ReadyAgent, LiveAgent, Psyche, Worker, SptNode** (agent/infra), plus the **Shell** family and **PresenceChannel** (seams only — impl deferred). The type system is **open**.
PRD.md:196:**In v1:** harness-independent core (library + binary); manifest + `api` harness contract; both startup topologies; one-daemon broker/brain; LiveAgent/ReadyAgent/Psyche/Worker/SptNode endpoints + Shell-vs-agent seam + typed/binary payloads; multi-instance data model + subnet registry + resolution policy + dormant/suspended + two-tier context sync + remote-drive of running instances + off-node file transfer; first-class WAN networking + TOTP-SPAKE2 pairing + subnet naming; cross-node Psyche sync (replaces gh-sync); peer-propagated gated signed self-update + adapter ripple; terminal wrapper + launcher frontend (byte-stream remote streaming); one-line install + OS-service + legacy auto-migration; rebuilt spt-plugin to parity.


grep -n "enum ApiCmd" -A60 crates/spt/src/api/mod.rs | grep -E "^\s+[A-Z][a-zA-Z]+ \{|^\s+[A-Z][a-zA-Z]+,|///" | grep -E "^\s+[A-Z]" | head -30

List api subcommands


(Bash completed with no output)


./target/debug/spt.exe api --help 2>&1 | head -40

List api surface from binary