Write

//! The daemon-held tunnel registry + its control channel (M11-W3, REQ-SHELL-4).
//!
//! The **shell tunnel** is distinct from the four typed channels (command,
//! text+file, sensory, drive): a long-lived, **reliable-ordered, opaque** byte
//! stream — a dedicated QUIC stream pair bound to the owner↔shell link — that the
//! channel taxonomy must NOT reinterpret (first consumer: USB/IP URB traffic).
//! Not enveloped, not MAC-framed, not spooled; the link lifecycle governs it (a
//! link-break closes the tunnel). It rides the **D4 net-stream substrate**
//! ([`crate::nethost::NetHost::open_stream`], broker-owned, survives a brain
//! restart) — a NEW held-stream lifecycle, NOT the one-shot request/reply
//! [`crate::linkhost::serve_shell_link`].
//!
//! **What the registry holds (broker, not brain; broker, not a file).** Like the
//! [`DriveHub`](crate::drivehub::DriveHub), the registry lives in the always-up
//! BROKER process — the daemon-lifetime CLI-reachable anchor post-ADR-0018 — NOT
//! the restartable brain child (a CLI cannot reach brain memory; all control
//! sockets are broker-served) and NOT a file (a file would survive a daemon
//! restart and re-reference a dead stream on relink). It holds only **identifiers**:
//! per shell, the owner-side and shell-side held `stream_id`s plus the **link
//! token** they were opened under. The bytes themselves never touch the registry —
//! they flow through the broker-held QUIC stream pair; this is the thin map that
//! tells a surface *which* stream serves a shell's tunnel and gates it on the
//! live link generation. In-memory ⇒ it dies with the daemon.
//!
//! **No-stale-stream-across-relink is the load-bearing invariant** (doyle gate
//! R1, mirroring the W2 drive slot). Each entry stamps the held stream ids with
//! the **opening link token**; [`TunnelHub::owner_stream`] / [`shell_stream`] serve
//! an id only when the requester presents that same token. A link-break retires
//! the token and a relink mints a fresh one ([`crate::shellhost::close_shell`]
//! removes `LINK_TOKEN_FILE`; the next bind mints anew), so a stream id opened
//! under the pre-break generation can NEVER match the relinked shell's fresh token
//! — the stale stream is inert even if the explicit [`clear`](TunnelHub::clear) on
//! close never ran (a brain crash mid-close). The online-generation stamp is the
//! crash-proof guarantee; the close-time drop is prompt cleanup, not the leg the
//! invariant stands on.
//!
//! [`shell_stream`]: TunnelHub::shell_stream
//!
//! **W3 scope = same-node mock.** The opening conn is the [`NetHost`] loopback
//! singleton (`open_stream` cross-wires the operator + peer rows in-process), which
//! proves the held-stream lifecycle + byte-exact opaque relay + link-break close.
//! The cross-node-on-LAN real-Iroh wire is the W5 `[twohost]` rung (doyle gate R2)
//! — never claimed as proven by this loopback mock.
// [impl->REQ-SHELL-4]

use std::collections::HashMap;
use std::io::{self, Read, Write};
use std::sync::{Arc, Mutex};
use std::thread;

use serde::{Deserialize, Serialize};

use crate::codec::{read_frame, write_frame};
use crate::frame::{Envelope, Role};
use crate::transport::{recv_hello, send_hello, DaemonTransport, LocalSocketTransport};

/// Daemon→registry: record a shell's freshly-opened tunnel stream pair, stamped
/// with the opening link token. Fired at shell-online (manifest tunnel-enabled).
pub const KIND_TUNNEL_OPEN: &str = "tunnel_open";
/// Surface→registry: resolve the held stream id for one end of a shell's tunnel,
/// gated on the presented link token (the no-stale-stream serve gate). Replies
/// [`KIND_TUNNEL_RESULT`].
pub const KIND_TUNNEL_RESOLVE: &str = "tunnel_resolve";
/// Registry→surface: the resolve reply (the stream id, or absent ⇒ no live tunnel
/// OR a token mismatch — a dead-generation stream is never surfaced).
pub const KIND_TUNNEL_RESULT: &str = "tunnel_result";
/// Owner/daemon→registry: clear a shell's tunnel entry unconditionally (the
/// link-break cleanup, fired from `close_shell`; the token stamp is the crash-proof
/// guarantee, this is the prompt cleanup).
pub const KIND_TUNNEL_CLEAR: &str = "tunnel_clear";

/// Which end of the cross-wired stream pair a [`resolve`](KIND_TUNNEL_RESOLVE)
/// asks for: the OWNER end (the operator row `spt shell tunnel` drives) or the
/// SHELL end (the peer row the shell binary's `api tunnel` drives).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TunnelEnd {
    /// The owner-side stream id (`spt shell tunnel <id>` write-in / subscribe-out).
    Owner,
    /// The shell-side stream id (the shell binary's `api tunnel` raw duplex verb).
    Shell,
}

/// `tunnel_open` payload: the slot key, the opening link token (the no-stale stamp),
/// and the two cross-wired stream ids.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TunnelOpenReq {
    /// Owning endpoint id (shell ids are per-owner, so the key needs both).
    pub owner: String,
    /// Canonical shell instance id.
    pub shell_id: String,
    /// The link token current at open time — the no-stale-stream generation stamp.
    pub token: String,
    /// The owner-side (operator-row) held stream id.
    pub owner_stream: u64,
    /// The shell-side (peer-row) held stream id.
    pub shell_stream: u64,
}

/// `tunnel_resolve` payload: the slot key, which end, and the presenting token.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TunnelResolveReq {
    pub owner: String,
    pub shell_id: String,
    pub end: TunnelEnd,
    /// The presenter's current link token — must match the entry's stamp.
    pub token: String,
}

/// `tunnel_clear` payload: the slot key.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TunnelClearReq {
    pub owner: String,
    pub shell_id: String,
}

/// `tunnel_result` payload — the resolve reply.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TunnelResult {
    /// The held stream id for the requested end, absent if there is no live tunnel
    /// OR the token did not match the stamp (a dead-generation stream is never
    /// surfaced — the no-stale-stream guarantee).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub stream_id: Option<u64>,
}

/// One shell's live tunnel: the cross-wired stream pair plus the link token it was
/// opened under (the no-stale stamp).
#[derive(Clone)]
struct TunnelEntry {
    token: String,
    owner_stream: u64,
    shell_stream: u64,
}

/// The daemon's per-shell tunnel registry. A thin map of held stream ids — the
/// bytes flow through the broker-owned QUIC streams, never here. Holds only the
/// live link-generation's stream pair per shell; never spooled, never a file, dies
/// with the daemon process.
#[derive(Default)]
pub struct TunnelHub {
    map: Mutex<HashMap<String, TunnelEntry>>,
}

/// The slot key — shell ids are per-owner, so both segments are needed for
/// uniqueness across owners that share an adapter (`mock-shell-0` under two owners).
fn slot_key(owner: &str, shell_id: &str) -> String {
    format!("{owner}/{shell_id}")
}

impl TunnelHub {
    /// A fresh, empty registry.
    pub fn new() -> Self {
        TunnelHub {
            map: Mutex::new(HashMap::new()),
        }
    }

    /// Record (overwrite) the shell's tunnel stream pair, stamped by the opening
    /// `token`. A second open (a relink) replaces the prior entry — the old stream
    /// ids are no longer referenced under the fresh token.
    // [impl->REQ-SHELL-4]
    pub fn open(&self, owner: &str, shell_id: &str, token: &str, owner_stream: u64, shell_stream: u64) {
        let mut map = self.map.lock().unwrap();
        map.insert(
            slot_key(owner, shell_id),
            TunnelEntry {
                token: token.to_string(),
                owner_stream,
                shell_stream,
            },
        );
    }

    /// Resolve the held stream id for one `end` of the shell's tunnel — returned
    /// ONLY when `token` matches the entry's stamp. A stream opened under a now-dead
    /// link generation is never surfaced (no-stale-stream-across-relink); unlike the
    /// drive slot's take-and-clear, a resolve does NOT evict (the tunnel is
    /// long-lived — many resolves over one generation), so a mismatch simply
    /// refuses without disturbing a live entry.
    // [impl->REQ-SHELL-4]
    pub fn resolve(&self, owner: &str, shell_id: &str, end: TunnelEnd, token: &str) -> Option<u64> {
        let map = self.map.lock().unwrap();
        let entry = map.get(&slot_key(owner, shell_id))?;
        if entry.token != token {
            return None; // dead-generation stream: never surfaced
        }
        Some(match end {
            TunnelEnd::Owner => entry.owner_stream,
            TunnelEnd::Shell => entry.shell_stream,
        })
    }

    /// The owner-side (operator-row) stream id for the shell's tunnel, token-gated.
    pub fn owner_stream(&self, owner: &str, shell_id: &str, token: &str) -> Option<u64> {
        self.resolve(owner, shell_id, TunnelEnd::Owner, token)
    }

    /// The shell-side (peer-row) stream id for the shell's tunnel, token-gated.
    pub fn shell_stream(&self, owner: &str, shell_id: &str, token: &str) -> Option<u64> {
        self.resolve(owner, shell_id, TunnelEnd::Shell, token)
    }

    /// Clear the shell's tunnel entry unconditionally (the link-break/teardown
    /// cleanup). The token stamp is the crash-proof guarantee; this is the prompt
    /// eviction.
    // [impl->REQ-SHELL-4]
    pub fn clear(&self, owner: &str, shell_id: &str) {
        self.map.lock().unwrap().remove(&slot_key(owner, shell_id));
    }

    /// Whether a tunnel entry is currently held for the shell (tests / introspection).
    pub fn has_tunnel(&self, owner: &str, shell_id: &str) -> bool {
        self.map
            .lock()
            .unwrap()
            .contains_key(&slot_key(owner, shell_id))
    }
}

/// Bind the tunnel-control channel at `name` and serve it forever against `hub`.
/// Thread-per-connection (mirroring the digest / drive control channels).
pub fn serve_tunnel_control(name: &str, hub: Arc<TunnelHub>) -> io::Result<()> {
    let listener = LocalSocketTransport::bind(name)?;
    loop {
        let conn = listener.accept()?;
        let hub = Arc::clone(&hub);
        thread::spawn(move || {
            let _ = handle_conn(conn, &hub);
        });
    }
}

/// Drive one control connection: handshake, then service open / resolve / clear
/// requests until the client disconnects.
fn handle_conn<C: Read + Write>(mut conn: C, hub: &TunnelHub) -> io::Result<()> {
    recv_hello(&mut conn, Role::Brain)?;
    loop {
        let env = match read_frame(&mut conn) {
            Ok(e) => e,
            Err(_) => return Ok(()), // peer hung up
        };
        match env.kind.as_str() {
            KIND_TUNNEL_OPEN => {
                let req: TunnelOpenReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                hub.open(
                    &req.owner,
                    &req.shell_id,
                    &req.token,
                    req.owner_stream,
                    req.shell_stream,
                );
                // No reply — fire-and-forget (the open already succeeded on the wire).
            }
            KIND_TUNNEL_RESOLVE => {
                let req: TunnelResolveReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                let stream_id = hub.resolve(&req.owner, &req.shell_id, req.end, &req.token);
                let res = TunnelResult { stream_id };
                write_frame(
                    &mut conn,
                    &Envelope::new(KIND_TUNNEL_RESULT, serde_json::to_value(res).unwrap()),
                )?;
            }
            KIND_TUNNEL_CLEAR => {
                let req: TunnelClearReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                hub.clear(&req.owner, &req.shell_id);
                // No reply.
            }
            // Forward-compat: an unknown control kind is tolerated, not fatal.
            _ => {}
        }
    }
}

/// Connect to the tunnel-control channel `name`, announcing ourselves as a brain
/// (the `spt` CLI is a transient logic client of the daemon).
fn connect(name: &str) -> io::Result<interprocess::local_socket::Stream> {
    let mut conn = LocalSocketTransport::connect(name)?;
    send_hello(&mut conn, Role::Brain)?;
    Ok(conn)
}

/// Record `shell_id`'s freshly-opened tunnel stream pair on the daemon over `name`,
/// stamping it with `token`. Fire-and-forget.
// [impl->REQ-SHELL-4]
pub fn tunnel_open(
    name: &str,
    owner: &str,
    shell_id: &str,
    token: &str,
    owner_stream: u64,
    shell_stream: u64,
) -> io::Result<()> {
    let mut conn = connect(name)?;
    let req = TunnelOpenReq {
        owner: owner.to_string(),
        shell_id: shell_id.to_string(),
        token: token.to_string(),
        owner_stream,
        shell_stream,
    };
    write_frame(
        &mut conn,
        &Envelope::new(KIND_TUNNEL_OPEN, serde_json::to_value(req).unwrap()),
    )?;
    Ok(())
}

/// Resolve one `end` of `shell_id`'s tunnel stream on the daemon over `name`,
/// presenting `token`. `Ok(None)` ⇒ no live tunnel OR the entry was a dead-generation
/// stamp (never surfaced).
// [impl->REQ-SHELL-4]
pub fn tunnel_resolve(
    name: &str,
    owner: &str,
    shell_id: &str,
    end: TunnelEnd,
    token: &str,
) -> io::Result<Option<u64>> {
    let mut conn = connect(name)?;
    let req = TunnelResolveReq {
        owner: owner.to_string(),
        shell_id: shell_id.to_string(),
        end,
        token: token.to_string(),
    };
    write_frame(
        &mut conn,
        &Envelope::new(KIND_TUNNEL_RESOLVE, serde_json::to_value(req).unwrap()),
    )?;
    let env = read_frame(&mut conn)?;
    let res: TunnelResult = serde_json::from_value(env.payload)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    Ok(res.stream_id)
}

/// Clear `shell_id`'s tunnel entry on the daemon over `name`. Fire-and-forget +
/// best-effort: a daemon that isn't running holds no entry, so a failed connect is
/// harmless (the entry is already gone with the process).
// [impl->REQ-SHELL-4]
pub fn tunnel_clear(name: &str, owner: &str, shell_id: &str) -> io::Result<()> {
    let mut conn = connect(name)?;
    let req = TunnelClearReq {
        owner: owner.to_string(),
        shell_id: shell_id.to_string(),
    };
    write_frame(
        &mut conn,
        &Envelope::new(KIND_TUNNEL_CLEAR, serde_json::to_value(req).unwrap()),
    )?;
    Ok(())
}

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

    // [unit->REQ-SHELL-4] a resolve serves the held stream id for the requested
    // end only under the opening token; the two ends are distinct ids.
    #[test]
    fn resolve_serves_each_end_under_the_opening_token() {
        let hub = TunnelHub::new();
        hub.open("doyle", "mock-shell-0", "tok", 7, 8);
        assert_eq!(hub.owner_stream("doyle", "mock-shell-0", "tok"), Some(7));
        assert_eq!(hub.shell_stream("doyle", "mock-shell-0", "tok"), Some(8));
    }

    // [unit->REQ-SHELL-4] no-stale-stream-across-relink (R1): a stream id opened
    // under one link token is NEVER surfaced to a resolve presenting a different
    // (relinked) token — even though the entry is NOT evicted by the mismatch (the
    // tunnel is long-lived, unlike the drive slot's take-and-clear). This is the
    // crash-proof guarantee that does not depend on the explicit clear-on-close.
    #[test]
    fn dead_generation_stream_is_never_surfaced() {
        let hub = TunnelHub::new();
        hub.open("doyle", "mock-shell-0", "old-token", 7, 8);
        // The shell crashed/relinked; the surfaces now present the fresh token.
        assert_eq!(hub.owner_stream("doyle", "mock-shell-0", "new-token"), None);
        assert_eq!(hub.shell_stream("doyle", "mock-shell-0", "new-token"), None);
        // The stale entry is still present (a resolve never evicts), but it is inert
        // under the live generation — the next open (relink) overwrites it.
        assert!(hub.has_tunnel("doyle", "mock-shell-0"));
    }

    // [unit->REQ-SHELL-4] a relink (a second open under a fresh token) replaces the
    // entry; the new generation's ids resolve, the old token resolves nothing.
    #[test]
    fn relink_overwrites_the_entry_with_a_fresh_generation() {
        let hub = TunnelHub::new();
        hub.open("doyle", "mock-shell-0", "old-token", 7, 8);
        hub.open("doyle", "mock-shell-0", "new-token", 11, 12);
        assert_eq!(hub.owner_stream("doyle", "mock-shell-0", "new-token"), Some(11));
        assert_eq!(hub.shell_stream("doyle", "mock-shell-0", "new-token"), Some(12));
        assert_eq!(hub.owner_stream("doyle", "mock-shell-0", "old-token"), None);
    }

    // [unit->REQ-SHELL-4] clear evicts the entry unconditionally (the link-break
    // cleanup fired from close_shell).
    #[test]
    fn clear_evicts_the_entry() {
        let hub = TunnelHub::new();
        hub.open("doyle", "mock-shell-0", "tok", 7, 8);
        assert!(hub.has_tunnel("doyle", "mock-shell-0"));
        hub.clear("doyle", "mock-shell-0");
        assert!(!hub.has_tunnel("doyle", "mock-shell-0"));
        assert_eq!(hub.owner_stream("doyle", "mock-shell-0", "tok"), None);
    }

    // [unit->REQ-SHELL-4] entries are per-owner: the same shell id under two owners
    // holds independent tunnels (the key carries the owner).
    #[test]
    fn entries_are_per_owner() {
        let hub = TunnelHub::new();
        hub.open("doyle", "mock-shell-0", "tok-a", 1, 2);
        hub.open("perri", "mock-shell-0", "tok-b", 3, 4);
        assert_eq!(hub.owner_stream("doyle", "mock-shell-0", "tok-a"), Some(1));
        assert_eq!(hub.owner_stream("perri", "mock-shell-0", "tok-b"), Some(3));
    }
}


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

//! The daemon-held tunnel registry + its control channel (M11-W3, REQ-SHELL-4).
//!
//! The **shell tunnel** is distinct from the four typed channels (command,
//! text+file, sensory, drive): a long-lived, **reliable-ordered, opaque** byte
//! stream — a dedicated QUIC stream pair bound to the owner↔shell link — that the
//! channel taxonomy must NOT reinterpret (first consumer: USB/IP URB traffic).
//! Not enveloped, not MAC-framed, not spooled; the link lifecycle governs it (a
//! link-break closes the tunnel). It rides the **D4 net-stream substrate**
//! ([`crate::nethost::NetHost::open_stream`], broker-owned, survives a brain
//! restart) — a NEW held-stream lifecycle, NOT the one-shot request/reply
//! [`crate::linkhost::serve_shell_link`].
//!
//! **What the registry holds (broker, not brain; broker, not a file).** Like the
//! [`DriveHub`](crate::drivehub::DriveHub), the registry lives in the always-up
//! BROKER process — the daemon-lifetime CLI-reachable anchor post-ADR-0018 — NOT
//! the restartable brain child (a CLI cannot reach brain memory; all control
//! sockets are broker-served) and NOT a file (a file would survive a daemon
//! restart and re-reference a dead stream on relink). It holds only **identifiers**:
//! per shell, the owner-side and shell-side held `stream_id`s plus the **link
//! token** they were opened under. The bytes themselves never touch the registry —
//! they flow through the broker-held QUIC stream pair; this is the thin map that
//! tells a surface *which* stream serves a shell's tunnel and gates it on the
//! live link generation. In-memory ⇒ it dies with the daemon.
//!
//! **No-stale-stream-across-relink is the load-bearing invariant** (doyle gate
//! R1, mirroring the W2 drive slot). Each entry stamps the held stream ids with
//! the **opening link token**; [`TunnelHub::owner_stream`] / [`shell_stream`] serve
//! an id only when the requester presents that same token. A link-break retires
//! the token and a relink mints a fresh one ([`crate::shellhost::close_shell`]
//! removes `LINK_TOKEN_FILE`; the next bind mints anew), so a stream id opened
//! under the pre-break generation can NEVER match the relinked shell's fresh token
//! — the stale stream is inert even if the explicit [`clear`](TunnelHub::clear) on
//! close never ran (a brain crash mid-close). The online-generation stamp is the
//! crash-proof guarantee; the close-time drop is prompt cleanup, not the leg the
//! invariant stands on.
//!
//! [`shell_stream`]: TunnelHub::shell_stream
//!
//! **W3 scope = same-node mock.** The opening conn is the [`NetHost`] loopback
//! singleton (`open_stream` cross-wires the operator + peer rows in-process), which
//! proves the held-stream lifecycle + byte-exact opaque relay + link-break close.
//! The cross-node-on-LAN real-Iroh wire is the W5 `[twohost]` rung (doyle gate R2)
//! — never claimed as proven by this loopback mock.
// [impl->REQ-SHELL-4]

use std::collections::HashMap;
use std::io::{self, Read, Write};
use std::sync::{Arc, Mutex};
use std::thread;

use serde::{Deserialize, Serialize};

use crate::codec::{read_frame, write_frame};
use crate::frame::{Envelope, Role};
use crate::transport::{recv_hello, send_hello, DaemonTransport, LocalSocketTransport};

/// Daemon→registry: record a shell's freshly-opened tunnel stream pair, stamped
/// with the opening link token. Fired at shell-online (manifest tunnel-enabled).
pub const KIND_TUNNEL_OPEN: &str = "tunnel_open";
/// Surface→registry: resolve the held stream id for one end of a shell's tunnel,
/// gated on the presented link token (the no-stale-stream serve gate). Replies
/// [`KIND_TUNNEL_RESULT`].
pub const KIND_TUNNEL_RESOLVE: &str = "tunnel_resolve";
/// Registry→surface: the resolve reply (the stream id, or absent ⇒ no live tunnel
/// OR a token mismatch — a dead-generation stream is never surfaced).
pub const KIND_TUNNEL_RESULT: &str = "tunnel_result";
/// Owner/daemon→registry: clear a shell's tunnel entry unconditionally (the
/// link-break cleanup, fired from `close_shell`; the token stamp is the crash-proof
/// guarantee, this is the prompt cleanup).
pub const KIND_TUNNEL_CLEAR: &str = "tunnel_clear";

/// Which end of the cross-wired stream pair a [`resolve`](KIND_TUNNEL_RESOLVE)
/// asks for: the OWNER end (the operator row `spt shell tunnel` drives) or the
/// SHELL end (the peer row the shell binary's `api tunnel` drives).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TunnelEnd {
    /// The owner-side stream id (`spt shell tunnel <id>` write-in / subscribe-out).
    Owner,
    /// The shell-side stream id (the shell binary's `api tunnel` raw duplex verb).
    Shell,
}

/// `tunnel_open` payload: the slot key, the opening link token (the no-stale stamp),
/// and the two cross-wired stream ids.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TunnelOpenReq {
    /// Owning endpoint id (shell ids are per-owner, so the key needs both).
    pub owner: String,
    /// Canonical shell instance id.
    pub shell_id: String,
    /// The link token current at open time — the no-stale-stream generation stamp.
    pub token: String,
    /// The owner-side (operator-row) held stream id.
    pub owner_stream: u64,
    /// The shell-side (peer-row) held stream id.
    pub shell_stream: u64,
}

/// `tunnel_resolve` payload: the slot key, which end, and the presenting token.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TunnelResolveReq {
    pub owner: String,
    pub shell_id: String,
    pub end: TunnelEnd,
    /// The presenter's current link token — must match the entry's stamp.
    pub token: String,
}

/// `tunnel_clear` payload: the slot key.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TunnelClearReq {
    pub owner: String,
    pub shell_id: String,
}

/// `tunnel_result` payload — the resolve reply.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TunnelResult {
    /// The held stream id for the requested end, absent if there is no live tunnel
    /// OR the token did not match the stamp (a dead-generation stream is never
    /// surfaced — the no-stale-stream guarantee).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub stream_id: Option<u64>,
}

/// One shell's live tunnel: the cross-wired stream pair plus the link token it was
/// opened under (the no-stale stamp).
#[derive(Clone)]
struct TunnelEntry {
    token: String,
    owner_stream: u64,
    shell_stream: u64,
}

/// The daemon's per-shell tunnel registry. A thin map of held stream ids — the
/// bytes flow through the broker-owned QUIC streams, never here. Holds only the
/// live link-generation's stream pair per shell; never spooled, never a file, dies
/// with the daemon process.
#[derive(Default)]
pub struct TunnelHub {
    map: Mutex<HashMap<String, TunnelEntry>>,
}

/// The slot key — shell ids are per-owner, so both segments are needed for
/// uniqueness across owners that share an adapter (`mock-shell-0` under two owners).
fn slot_key(owner: &str, shell_id: &str) -> String {
    format!("{owner}/{shell_id}")
}

impl TunnelHub {
    /// A fresh, empty registry.
    pub fn new() -> Self {
        TunnelHub {
            map: Mutex::new(HashMap::new()),
        }
    }

    /// Record (overwrite) the shell's tunnel stream pair, stamped by the opening
    /// `token`. A second open (a relink) replaces the prior entry — the old stream
    /// ids are no longer referenced under the fresh token.
    // [impl->REQ-SHELL-4]
    pub fn open(&self, owner: &str, shell_id: &str, token: &str, owner_stream: u64, shell_stream: u64) {
        let mut map = self.map.lock().unwrap();
        map.insert(
            slot_key(owner, shell_id),
            TunnelEntry {
                token: token.to_string(),
                owner_stream,
                shell_stream,
            },
        );
    }

    /// Resolve the held stream id for one `end` of the shell's tunnel — returned
    /// ONLY when `token` matches the entry's stamp. A stream opened under a now-dead
    /// link generation is never surfaced (no-stale-stream-across-relink); unlike the
    /// drive slot's take-and-clear, a resolve does NOT evict (the tunnel is
    /// long-lived — many resolves over one generation), so a mismatch simply
    /// refuses without disturbing a live entry.
    // [impl->REQ-SHELL-4]
    pub fn resolve(&self, owner: &str, shell_id: &str, end: TunnelEnd, token: &str) -> Option<u64> {
        let map = self.map.lock().unwrap();
        let entry = map.get(&slot_key(owner, shell_id))?;
        if entry.token != token {
            return None; // dead-generation stream: never surfaced
        }
        Some(match end {
            TunnelEnd::Owner => entry.owner_stream,
            TunnelEnd::Shell => entry.shell_stream,
        })
    }

    /// The owner-side (operator-row) stream id for the shell's tunnel, token-gated.
    pub fn owner_stream(&self, owner: &str, shell_id: &str, token: &str) -> Option<u64> {
        self.resolve(owner, shell_id, TunnelEnd::Owner, token)
    }

    /// The shell-side (peer-row) stream id for the shell's tunnel, token-gated.
    pub fn shell_stream(&self, owner: &str, shell_id: &str, token: &str) -> Option<u64> {
        self.resolve(owner, shell_id, TunnelEnd::Shell, token)
    }

    /// Clear the shell's tunnel entry unconditionally (the link-break/teardown
    /// cleanup). The token stamp is the crash-proof guarantee; this is the prompt
    /// eviction.
    // [impl->REQ-SHELL-4]
    pub fn clear(&self, owner: &str, shell_id: &str) {
        self.map.lock().unwrap().remove(&slot_key(owner, shell_id));
    }

    /// Whether a tunnel entry is currently held for the shell (tests / introspection).
    pub fn has_tunnel(&self, owner: &str, shell_id: &str) -> bool {
        self.map
            .lock()
            .unwrap()
            .contains_key(&slot_key(owner, shell_id))
    }
}

/// Bind the tunnel-control channel at `name` and serve it forever against `hub`.
/// Thread-per-connection (mirroring the digest / drive control channels).
pub fn serve_tunnel_control(name: &str, hub: Arc<TunnelHub>) -> io::Result<()> {
    let listener = LocalSocketTransport::bind(name)?;
    loop {
        let conn = listener.accept()?;
        let hub = Arc::clone(&hub);
        thread::spawn(move || {
            let _ = handle_conn(conn, &hub);
        });
    }
}

/// Drive one control connection: handshake, then service open / resolve / clear
/// requests until the client disconnects.
fn handle_conn<C: Read + Write>(mut conn: C, hub: &TunnelHub) -> io::Result<()> {
    recv_hello(&mut conn, Role::Brain)?;
    loop {
        let env = match read_frame(&mut conn) {
            Ok(e) => e,
            Err(_) => return Ok(()), // peer hung up
        };
        match env.kind.as_str() {
            KIND_TUNNEL_OPEN => {
                let req: TunnelOpenReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                hub.open(
                    &req.owner,
                    &req.shell_id,
                    &req.token,
                    req.owner_stream,
                    req.shell_stream,
                );
                // No reply — fire-and-forget (the open already succeeded on the wire).
            }
            KIND_TUNNEL_RESOLVE => {
                let req: TunnelResolveReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                let stream_id = hub.resolve(&req.owner, &req.shell_id, req.end, &req.token);
                let res = TunnelResult { stream_id };
                write_frame(
                    &mut conn,
                    &Envelope::new(KIND_TUNNEL_RESULT, serde_json::to_value(res).unwrap()),
                )?;
            }
            KIND_TUNNEL_CLEAR => {
                let req: TunnelClearReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                hub.clear(&req.owner, &req.shell_id);
                // No reply.
            }
            // Forward-compat: an unknown control kind is tolerated, not fatal.
            _ => {}
        }
    }
}

/// Connect to the tunnel-control channel `name`, announcing ourselves as a brain
/// (the `spt` CLI is a transient logic client of the daemon).
fn connect(name: &str) -> io::Result<interprocess::local_socket::Stream> {
    let mut conn = LocalSocketTransport::connect(name)?;
    send_hello(&mut conn, Role::Brain)?;
    Ok(conn)
}

/// Record `shell_id`'s freshly-opened tunnel stream pair on the daemon over `name`,
/// stamping it with `token`. Fire-and-forget.
// [impl->REQ-SHELL-4]
pub fn tunnel_open(
    name: &str,
    owner: &str,
    shell_id: &str,
    token: &str,
    owner_stream: u64,
    shell_stream: u64,
) -> io::Result<()> {
    let mut conn = connect(name)?;
    let req = TunnelOpenReq {
        owner: owner.to_string(),
        shell_id: shell_id.to_string(),
        token: token.to_string(),
        owner_stream,
        shell_stream,
    };
    write_frame(
        &mut conn,
        &Envelope::new(KIND_TUNNEL_OPEN, serde_json::to_value(req).unwrap()),
    )?;
    Ok(())
}

/// Resolve one `end` of `shell_id`'s tunnel stream on the daemon over `name`,
/// presenting `token`. `Ok(None)` ⇒ no live tunnel OR the entry was a dead-generation
/// stamp (never surfaced).
// [impl->REQ-SHELL-4]
pub fn tunnel_resolve(
    name: &str,
    owner: &str,
    shell_id: &str,
    end: TunnelEnd,
    token: &str,
) -> io::Result<Option<u64>> {
    let mut conn = connect(name)?;
    let req = TunnelResolveReq {
        owner: owner.to_string(),
        shell_id: shell_id.to_string(),
        end,
        token: token.to_string(),
    };
    write_frame(
        &mut conn,
        &Envelope::new(KIND_TUNNEL_RESOLVE, serde_json::to_value(req).unwrap()),
    )?;
    let env = read_frame(&mut conn)?;
    let res: TunnelResult = serde_json::from_value(env.payload)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    Ok(res.stream_id)
}

/// Clear `shell_id`'s tunnel entry on the daemon over `name`. Fire-and-forget +
/// best-effort: a daemon that isn't running holds no entry, so a failed connect is
/// harmless (the entry is already gone with the process).
// [impl->REQ-SHELL-4]
pub fn tunnel_clear(name: &str, owner: &str, shell_id: &str) -> io::Result<()> {
    let mut conn = connect(name)?;
    let req = TunnelClearReq {
        owner: owner.to_string(),
        shell_id: shell_id.to_string(),
    };
    write_frame(
        &mut conn,
        &Envelope::new(KIND_TUNNEL_CLEAR, serde_json::to_value(req).unwrap()),
    )?;
    Ok(())
}

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

    // [unit->REQ-SHELL-4] a resolve serves the held stream id for the requested
    // end only under the opening token; the two ends are distinct ids.
    #[test]
    fn resolve_serves_each_end_under_the_opening_token() {
        let hub = TunnelHub::new();
        hub.open("doyle", "mock-shell-0", "tok", 7, 8);
        assert_eq!(hub.owner_stream("doyle", "mock-shell-0", "tok"), Some(7));
        assert_eq!(hub.shell_stream("doyle", "mock-shell-0", "tok"), Some(8));
    }

    // [unit->REQ-SHELL-4] no-stale-stream-across-relink (R1): a stream id opened
    // under one link token is NEVER surfaced to a resolve presenting a different
    // (relinked) token — even though the entry is NOT evicted by the mismatch (the
    // tunnel is long-lived, unlike the drive slot's take-and-clear). This is the
    // crash-proof guarantee that does not depend on the explicit clear-on-close.
    #[test]
    fn dead_generation_stream_is_never_surfaced() {
        let hub = TunnelHub::new();
        hub.open("doyle", "mock-shell-0", "old-token", 7, 8);
        // The shell crashed/relinked; the surfaces now present the fresh token.
        assert_eq!(hub.owner_stream("doyle", "mock-shell-0", "new-token"), None);
        assert_eq!(hub.shell_stream("doyle", "mock-shell-0", "new-token"), None);
        // The stale entry is still present (a resolve never evicts), but it is inert
        // under the live generation — the next open (relink) overwrites it.
        assert!(hub.has_tunnel("doyle", "mock-shell-0"));
    }

    // [unit->REQ-SHELL-4] a relink (a second open under a fresh token) replaces the
    // entry; the new generation's ids resolve, the old token resolves nothing.
    #[test]
    fn relink_overwrites_the_entry_with_a_fresh_generation() {
        let hub = TunnelHub::new();
        hub.open("doyle", "mock-shell-0", "old-token", 7, 8);
        hub.open("doyle", "mock-shell-0", "new-token", 11, 12);
        assert_eq!(hub.owner_stream("doyle", "mock-shell-0", "new-token"), Some(11));
        assert_eq!(hub.shell_stream("doyle", "mock-shell-0", "new-token"), Some(12));
        assert_eq!(hub.owner_stream("doyle", "mock-shell-0", "old-token"), None);
    }

    // [unit->REQ-SHELL-4] clear evicts the entry unconditionally (the link-break
    // cleanup fired from close_shell).
    #[test]
    fn clear_evicts_the_entry() {
        let hub = TunnelHub::new();
        hub.open("doyle", "mock-shell-0", "tok", 7, 8);
        assert!(hub.has_tunnel("doyle", "mock-shell-0"));
        hub.clear("doyle", "mock-shell-0");
        assert!(!hub.has_tunnel("doyle", "mock-shell-0"));
        assert_eq!(hub.owner_stream("doyle", "mock-shell-0", "tok"), None);
    }

    // [unit->REQ-SHELL-4] entries are per-owner: the same shell id under two owners
    // holds independent tunnels (the key carries the owner).
    #[test]
    fn entries_are_per_owner() {
        let hub = TunnelHub::new();
        hub.open("doyle", "mock-shell-0", "tok-a", 1, 2);
        hub.open("perri", "mock-shell-0", "tok-b", 3, 4);
        assert_eq!(hub.owner_stream("doyle", "mock-shell-0", "tok-a"), Some(1));
        assert_eq!(hub.owner_stream("perri", "mock-shell-0", "tok-b"), Some(3));
    }
}


Register the module + add the socket name. Check the module list + add both.


Grep

pub mod drivehub|mod drivehub|pub mod digesthub


120:pub mod digesthub;
122:pub mod drivehub;


118	pub mod deelevate;
119	pub mod digest;
120	pub mod digesthub;
121	pub mod dispatch;
122	pub mod drivehub;
123	pub mod effect;
124	pub mod endpoint;
125	pub mod frame;
126	pub mod grants;
127	pub mod harnesshost;
128	pub mod lifecycle;
129	pub mod linkhost;

pub mod deelevate;
pub mod digest;
pub mod digesthub;
pub mod dispatch;
pub mod drivehub;
pub mod effect;
pub mod endpoint;
pub mod frame;
pub mod grants;
pub mod harnesshost;
pub mod lifecycle;
pub mod linkhost;


156:pub mod shellwake;
157:pub mod sync;
158:pub mod transport;


158	pub mod transport;
159	pub mod update;
160	pub mod wan;
161	pub mod xfer;
162
163	#[cfg(test)]

pub mod transport;
pub mod update;
pub mod wan;
pub mod xfer;

#[cfg(test)]


Edit

pub mod transport;
pub mod tunnelhub;
pub mod update;


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

pub mod transport;
pub mod tunnelhub;
pub mod update;

pub mod shellwake;
pub mod sync;
pub mod transport;
+pub mod tunnelhub;
pub mod update;
pub mod wan;
pub mod xfer;


Add the tunnel socket name to endpoint.rs:


pub fn drive_socket_name() -> String {
    format!("spt-daemon-drive-{}.sock", home_tag())
}

pub fn drive_socket_name() -> String {
    format!("spt-daemon-drive-{}.sock", home_tag())
}

/// The tunnel-control listener name (M11-W3): the channel `spt shell tunnel` (owner
/// duplex) and the shell binary's `api tunnel` connect to for the per-shell opaque
/// byte tunnel registry (REQ-SHELL-4). Broker-served like the drive channel; the
/// registry holds only held stream ids + the link-token stamp and dies with the
/// daemon.
// [impl->REQ-SHELL-4]
pub fn tunnel_socket_name() -> String {
    format!("spt-daemon-tunnel-{}.sock", home_tag())
}


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

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


44	    format!("spt-daemon-digest-{}.sock", home_tag())
45	}
46
47	/// The drive-control listener name (M11-W2): the channel `spt shell drive` (owner
48	/// write) and `api drive-poll --link` (shell take-and-clear) connect to for the
49	/// per-shell ephemeral drive slot (REQ-SHELL-3). Broker-served like the digest
50	/// channel; the slot is in-memory and dies with the daemon.
51	// [impl->REQ-SHELL-3]
52	pub fn drive_socket_name() -> String {
53	    format!("spt-daemon-drive-{}.sock", home_tag())
54	}
55

    format!("spt-daemon-digest-{}.sock", home_tag())
}

/// The drive-control listener name (M11-W2): the channel `spt shell drive` (owner
/// write) and `api drive-poll --link` (shell take-and-clear) connect to for the
/// per-shell ephemeral drive slot (REQ-SHELL-3). Broker-served like the digest
/// channel; the slot is in-memory and dies with the daemon.
// [impl->REQ-SHELL-3]
pub fn drive_socket_name() -> String {
    format!("spt-daemon-drive-{}.sock", home_tag())
}


// [impl->REQ-SHELL-3]
pub fn drive_socket_name() -> String {
    format!("spt-daemon-drive-{}.sock", home_tag())
}

// [impl->REQ-SHELL-3]
pub fn drive_socket_name() -> String {
    format!("spt-daemon-drive-{}.sock", home_tag())
}

/// The tunnel-control listener name (M11-W3): the channel `spt shell tunnel` (owner
/// duplex) and the shell binary's `api tunnel` connect to for the per-shell opaque
/// byte tunnel registry (REQ-SHELL-4). Broker-served like the drive channel; the
/// registry holds only held stream ids + the link-token stamp and dies with the
/// daemon.
// [impl->REQ-SHELL-4]
pub fn tunnel_socket_name() -> String {
    format!("spt-daemon-tunnel-{}.sock", home_tag())
}


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

// [impl->REQ-SHELL-3]
pub fn drive_socket_name() -> String {
    format!("spt-daemon-drive-{}.sock", home_tag())
}

/// The tunnel-control listener name (M11-W3): the channel `spt shell tunnel` (owner
/// duplex) and the shell binary's `api tunnel` connect to for the per-shell opaque
/// byte tunnel registry (REQ-SHELL-4). Broker-served like the drive channel; the
/// registry holds only held stream ids + the link-token stamp and dies with the
/// daemon.
// [impl->REQ-SHELL-4]
pub fn tunnel_socket_name() -> String {
    format!("spt-daemon-tunnel-{}.sock", home_tag())
}

//! Canonical per-machine daemon endpoint names + pid path (B6).
//!
//! The daemon binds two local-IPC listeners — the broker (PTY hosting, B1/B2)
//! and the seed-control channel (B6) — under names derived from the active
//! `spt_home`. Deriving from `spt_home` keeps each isolated home (a test's
//! `SPT_HOME` override, a per-user install) on its own daemon, so parallel test
//! processes never collide on one machine while a real install gets one stable
//! per-home daemon. The auto-start spawn inherits `SPT_HOME`, so the spawned
//! daemon computes the **same** names as the client that started it — that
//! shared derivation is the whole of the rendezvous (no registry lookup).
// [impl->REQ-DAEMON-3]

use std::hash::{Hash, Hasher};
use std::path::PathBuf;

use spt_store::perch::spt_home;

/// A deterministic short tag for the active spt home. `DefaultHasher::new()` is
/// seeded with fixed keys (it is **not** randomized like `RandomState`), so the
/// tag is stable across processes that share the same `spt_home` — exactly what
/// the auto-start handshake needs (the spawned daemon must independently derive
/// the launching client's socket name).
fn home_tag() -> String {
    let mut h = std::collections::hash_map::DefaultHasher::new();
    spt_home().hash(&mut h);
    format!("{:016x}", h.finish())
}

/// The seed-control listener name (B6): the channel `api seed`/`api listen`
/// connect to for the in-memory seed map. It is also the daemon's liveness
/// anchor — present for the daemon's whole lifetime.
pub fn seed_socket_name() -> String {
    format!("spt-daemon-seed-{}.sock", home_tag())
}

/// The broker listener name (B1/B2): PTY hosting + gapless handoff.
pub fn broker_socket_name() -> String {
    format!("spt-daemon-broker-{}.sock", home_tag())
}

/// The digest-control listener name (B8): the channel `spt digest <id>` connects
/// to for a snapshot pull or a delta-stream subscribe (ADR-0008 / REQ-TERM-4).
pub fn digest_socket_name() -> String {
    format!("spt-daemon-digest-{}.sock", home_tag())
}

/// The drive-control listener name (M11-W2): the channel `spt shell drive` (owner
/// write) and `api drive-poll --link` (shell take-and-clear) connect to for the
/// per-shell ephemeral drive slot (REQ-SHELL-3). Broker-served like the digest
/// channel; the slot is in-memory and dies with the daemon.
// [impl->REQ-SHELL-3]
pub fn drive_socket_name() -> String {
    format!("spt-daemon-drive-{}.sock", home_tag())
}

/// The daemon's pid file: `<spt_home>/daemon.pid`. Best-effort breadcrumb the
/// running daemon writes on start — discoverable for management/cleanup. Not a
/// liveness signal (liveness is the seed-control ping); a stale pid here is
/// harmless because callers probe the socket, never this file.
pub fn daemon_pid_path() -> PathBuf {
    spt_home().join("daemon.pid")
}

/// The brain child's readiness breadcrumb: `<spt_home>/brain.ready` (the brain
/// child's pid, rewritten each heartbeat). The broker spawns the brain as a
/// supervised child process (ADR-0018 Q2/Q3 — the two-process restoration); the
/// child writes this once it has connected to the broker socket, and refreshes
/// it while attached. A test (or a future readiness-gated rollback, ADR-0018
/// Q7) reads it to confirm the brain came up and — on a pid change — that it
/// respawned. Best-effort like the daemon pid breadcrumb; liveness of the
/// *daemon* is still the seed-control ping, never this file.
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
pub fn brain_ready_path() -> PathBuf {
    spt_home().join("brain.ready")
}

/// Where the peer pump records its last-tick heartbeat (epoch ms, M8
/// decision 23 / REQ-DAEMON-5): written each pump tick (D4), read by
/// `spt daemon status` so a half-dead daemon (pump stalled, dispatcher
/// alive — the 2026-06-07 hfenduleam shape) renders honestly.
pub fn pump_heartbeat_path() -> PathBuf {
    spt_home().join("identity").join("pump-heartbeat.json")
}

/// Read the pump's last-tick heartbeat (epoch ms). `None` = never written
/// (a pre-M8 daemon, or one that has not completed a first tick).
// [impl->REQ-CLI-2]
pub fn read_pump_heartbeat() -> Option<u64> {
    let s = std::fs::read_to_string(pump_heartbeat_path()).ok()?;
    serde_json::from_str::<u64>(s.trim()).ok()
}

+/// The tunnel-control listener name (M11-W3): the channel `spt shell tunnel` (owner

+/// duplex) and the shell binary's `api tunnel` connect to for the per-shell opaque

+/// byte tunnel registry (REQ-SHELL-4). Broker-served like the drive channel; the

+/// registry holds only held stream ids + the link-token stamp and dies with the

+pub fn tunnel_socket_name() -> String {

+    format!("spt-daemon-tunnel-{}.sock", home_tag())


Now wire `serve_tunnel_control` at daemon startup where `serve_drive_control` is launched. Find it.


Bash

grep -rn 'serve_drive_control\|DriveHub::new\|drive_socket_name()' crates/spt-daemon/src crates/spt/src | grep -v '//'

Find drive control wiring at startup


crates/spt-daemon/src/daemon.rs:225:        let drive_hub = Arc::new(crate::drivehub::DriveHub::new());
crates/spt-daemon/src/daemon.rs:229:                let _ = crate::drivehub::serve_drive_control(&drive_socket_name(), hub);
crates/spt-daemon/src/drivehub.rs:181:pub fn serve_drive_control(name: &str, hub: Arc<DriveHub>) -> io::Result<()> {
crates/spt-daemon/src/drivehub.rs:315:        let hub = DriveHub::new();
crates/spt-daemon/src/drivehub.rs:329:        let hub = DriveHub::new();
crates/spt-daemon/src/drivehub.rs:349:        let hub = DriveHub::new();
crates/spt-daemon/src/drivehub.rs:368:        let hub = DriveHub::new();
crates/spt-daemon/src/drivehub.rs:380:        let hub = DriveHub::new();
crates/spt-daemon/src/endpoint.rs:52:pub fn drive_socket_name() -> String {
crates/spt-daemon/src/lib.rs:191:    drive_clear, drive_take, drive_write, serve_drive_control, DriveHub,
crates/spt-daemon/src/linkhost.rs:336:                &crate::endpoint::drive_socket_name(),
crates/spt-daemon/src/shellhost.rs:444:    let _ = crate::drivehub::drive_clear(&crate::endpoint::drive_socket_name(), owner, shell_id);
crates/spt/src/api/reporting.rs:404:    match spt_daemon::drive_take(&spt_daemon::drive_socket_name(), &owner, &shell_id, link) {


220	        // slot — the owner→shell control surface. Broker-held like the digest hub
221	        // (the daemon-lifetime CLI-reachable anchor; a brain restart loses
222	        // nothing — the slot is ephemeral control, a restart correctly drops it).
223	        // `spt shell drive` writes it, `api drive-poll --link` takes-and-clears it.
224	        // [impl->REQ-SHELL-3]
225	        let drive_hub = Arc::new(crate::drivehub::DriveHub::new());
226	        {
227	            let hub = Arc::clone(&drive_hub);
228	            thread::spawn(move || {
229	                let _ = crate::drivehub::serve_drive_control(&drive_socket_name(), hub);
230	            });
231	        }
232
233	        // Shell wake-watchers (M5-D4b) — the offline half of the shell
234	        // online/offline exclusivity — now run in the BRAIN child (restoration
235	        // D2-2): shellwake is brain-owned logic (ADR-0018 Q5 exception), a pure
236	        // disk-reconciler whose boot sweep re-reconciles the watcher children on
237	        // every brain start. The broker no longer hosts it; see
238	        // [`crate::brainproc::run_brain`].
239	        // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]

        // slot — the owner→shell control surface. Broker-held like the digest hub
        // (the daemon-lifetime CLI-reachable anchor; a brain restart loses
        // nothing — the slot is ephemeral control, a restart correctly drops it).
        // `spt shell drive` writes it, `api drive-poll --link` takes-and-clears it.
        // [impl->REQ-SHELL-3]
        let drive_hub = Arc::new(crate::drivehub::DriveHub::new());
        {
            let hub = Arc::clone(&drive_hub);
            thread::spawn(move || {
                let _ = crate::drivehub::serve_drive_control(&drive_socket_name(), hub);
            });
        }

        // Shell wake-watchers (M5-D4b) — the offline half of the shell
        // online/offline exclusivity — now run in the BRAIN child (restoration
        // D2-2): shellwake is brain-owned logic (ADR-0018 Q5 exception), a pure
        // disk-reconciler whose boot sweep re-reconciles the watcher children on
        // every brain start. The broker no longer hosts it; see
        // [`crate::brainproc::run_brain`].
        // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]


213	            let hub = Arc::clone(&digest_hub);
214	            thread::spawn(move || {
215	                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);
216	            });
217	        }
218

            let hub = Arc::clone(&digest_hub);
            thread::spawn(move || {
                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);
            });
        }


Edit

        let drive_hub = Arc::new(crate::drivehub::DriveHub::new());
        {
            let hub = Arc::clone(&drive_hub);
            thread::spawn(move || {
                let _ = crate::drivehub::serve_drive_control(&drive_socket_name(), hub);
            });
        }

        let drive_hub = Arc::new(crate::drivehub::DriveHub::new());
        {
            let hub = Arc::clone(&drive_hub);
            thread::spawn(move || {
                let _ = crate::drivehub::serve_drive_control(&drive_socket_name(), hub);
            });
        }

        // The per-shell shell-TUNNEL registry (M11-W3, REQ-SHELL-4) — the opaque
        // reliable-ordered byte stream's identifier map. Broker-held like the drive
        // hub (daemon-lifetime CLI-reachable anchor; in-memory so a restart correctly
        // drops a dead link generation's stream ids). `spt shell tunnel` (owner) and
        // the shell binary's `api tunnel` resolve their stream end through it.
        // [impl->REQ-SHELL-4]
        let tunnel_hub = Arc::new(crate::tunnelhub::TunnelHub::new());
        {
            let hub = Arc::clone(&tunnel_hub);
            thread::spawn(move || {
                let _ = crate::tunnelhub::serve_tunnel_control(
                    &crate::endpoint::tunnel_socket_name(),
                    hub,
                );
            });
        }


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

        let drive_hub = Arc::new(crate::drivehub::DriveHub::new());
        {
            let hub = Arc::clone(&drive_hub);
            thread::spawn(move || {
                let _ = crate::drivehub::serve_drive_control(&drive_socket_name(), hub);
            });
        }

        // The per-shell shell-TUNNEL registry (M11-W3, REQ-SHELL-4) — the opaque
        // reliable-ordered byte stream's identifier map. Broker-held like the drive
        // hub (daemon-lifetime CLI-reachable anchor; in-memory so a restart correctly
        // drops a dead link generation's stream ids). `spt shell tunnel` (owner) and
        // the shell binary's `api tunnel` resolve their stream end through it.
        // [impl->REQ-SHELL-4]
        let tunnel_hub = Arc::new(crate::tunnelhub::TunnelHub::new());
        {
            let hub = Arc::clone(&tunnel_hub);
            thread::spawn(move || {
                let _ = crate::tunnelhub::serve_tunnel_control(
                    &crate::endpoint::tunnel_socket_name(),
                    hub,
                );
            });
        }

+        // The per-shell shell-TUNNEL registry (M11-W3, REQ-SHELL-4) — the opaque

+        // reliable-ordered byte stream's identifier map. Broker-held like the drive

+        // hub (daemon-lifetime CLI-reachable anchor; in-memory so a restart correctly

+        // drops a dead link generation's stream ids). `spt shell tunnel` (owner) and

+        // the shell binary's `api tunnel` resolve their stream end through it.

+        let tunnel_hub = Arc::new(crate::tunnelhub::TunnelHub::new());

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

+                let _ = crate::tunnelhub::serve_tunnel_control(

+                    &crate::endpoint::tunnel_socket_name(),

// Shell wake-watchers (M5-D4b) — the offline half of the shell

// online/offline exclusivity — now run in the BRAIN child (restoration

// D2-2): shellwake is brain-owned logic (ADR-0018 Q5 exception), a pure


Add lib.rs re-exports (mirror drivehub's at line 191):


188	};
189	pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
190	pub use drivehub::{
191	    drive_clear, drive_take, drive_write, serve_drive_control, DriveHub,
192	};
193	pub use endpoint::{
194	    broker_socket_name, daemon_pid_path, digest_socket_name, drive_socket_name,
195	    pump_heartbeat_path, read_pump_heartbeat, seed_socket_name,

};
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use drivehub::{
    drive_clear, drive_take, drive_write, serve_drive_control, DriveHub,
};
pub use endpoint::{
    broker_socket_name, daemon_pid_path, digest_socket_name, drive_socket_name,
    pump_heartbeat_path, read_pump_heartbeat, seed_socket_name,


193	pub use endpoint::{
194	    broker_socket_name, daemon_pid_path, digest_socket_name, drive_socket_name,
195	    pump_heartbeat_path, read_pump_heartbeat, seed_socket_name,
196	};
197	pub use frame::{
198	    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
199	    MIN_COMPATIBLE_VERSION,
200	};

pub use endpoint::{
    broker_socket_name, daemon_pid_path, digest_socket_name, drive_socket_name,
    pump_heartbeat_path, read_pump_heartbeat, seed_socket_name,
};
pub use frame::{
    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
    MIN_COMPATIBLE_VERSION,
};


pub use drivehub::{
    drive_clear, drive_take, drive_write, serve_drive_control, DriveHub,
};
pub use endpoint::{
    broker_socket_name, daemon_pid_path, digest_socket_name, drive_socket_name,
    pump_heartbeat_path, read_pump_heartbeat, seed_socket_name,
};

pub use drivehub::{
    drive_clear, drive_take, drive_write, serve_drive_control, DriveHub,
};
pub use tunnelhub::{
    serve_tunnel_control, tunnel_clear, tunnel_open, tunnel_resolve, TunnelEnd, TunnelHub,
};
pub use endpoint::{
    broker_socket_name, daemon_pid_path, digest_socket_name, drive_socket_name,
    pump_heartbeat_path, read_pump_heartbeat, seed_socket_name, tunnel_socket_name,
};


pub use drivehub::{
    drive_clear, drive_take, drive_write, serve_drive_control, DriveHub,
};
pub use endpoint::{
    broker_socket_name, daemon_pid_path, digest_socket_name, drive_socket_name,
    pump_heartbeat_path, read_pump_heartbeat, seed_socket_name,
};

pub use drivehub::{
    drive_clear, drive_take, drive_write, serve_drive_control, DriveHub,
};
pub use tunnelhub::{
    serve_tunnel_control, tunnel_clear, tunnel_open, tunnel_resolve, TunnelEnd, TunnelHub,
};
pub use endpoint::{
    broker_socket_name, daemon_pid_path, digest_socket_name, drive_socket_name,
    pump_heartbeat_path, read_pump_heartbeat, seed_socket_name, tunnel_socket_name,
};

+    serve_tunnel_control, tunnel_clear, tunnel_open, tunnel_resolve, TunnelEnd, TunnelHub,

-    pump_heartbeat_path, read_pump_heartbeat, seed_socket_name,

+    pump_heartbeat_path, read_pump_heartbeat, seed_socket_name, tunnel_socket_name,

accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,


Build + run the hub unit tests:


Bash

cargo test -q -p spt-daemon tunnelhub 2>&1 | tail -20

Build + test tunnelhub


running 0 tests

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

running 0 tests

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

running 0 tests

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

running 0 tests

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


Compiled clean. Filter targeted wrong — run lib tests by module path:


cargo test -q -p spt-daemon --lib tunnelhub:: 2>&1 | tail -15

Run tunnelhub lib unit tests


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


5/5 hub unit tests pass. Pre-flight clippy workspace + traceable before committing T3.2a (hub foundation):


cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -8

Clippy workspace


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


traceable-reqs check >/dev/null 2>&1; echo "TRACEABLE_EXIT=$?"

Traceable check


git add -A && git commit -q -m "$(cat <<'EOF'
M11-W3 T3.2a: broker-held tunnel registry + control channel (REQ-SHELL-4)

The shell tunnel's identifier map: a per-shell TunnelHub holding the
owner-side + shell-side held stream ids stamped with the opening link
token, served over a broker tunnel-control socket (mirrors the W2 drive
hub). The bytes never touch the registry — they flow through the
broker-owned QUIC stream pair; this is the thin map a surface resolves
its stream end through, gated on the live link generation.

No-stale-stream-across-relink (R1) is the load-bearing leg: a resolve
serves a stream id only under the opening token, so a stream from a
dead link generation is never surfaced even if close_shell's clear
never ran (brain crash mid-close). Unlike the drive slot's
take-and-clear, a resolve does not evict (the tunnel is long-lived).

Daemon spawns serve_tunnel_control alongside the drive/digest channels;
tunnel_socket_name added. 5 unit tests (each-end resolve, dead-gen never
surfaced, relink-overwrites, clear-evicts, per-owner). Adds impl/unit
evidence to the already-active REQ-SHELL-4.

Co-authored by: todlando
EOF
)" && git log --oneline -1

Commit T3.2a hub foundation


warning: in the working copy of 'crates/spt-daemon/src/tunnelhub.rs', LF will be replaced by CRLF the next time Git touches it
692f810 M11-W3 T3.2a: broker-held tunnel registry + control channel (REQ-SHELL-4)


T3.2a committed @692f810. Now the close hook (trivial, mirrors drive_clear) + scope the open hook. Add `tunnel_clear` to `close_shell`:


Edit

    let _ = crate::drivehub::drive_clear(&crate::endpoint::drive_socket_name(), owner, shell_id);

    // 4. The divergence.

    let _ = crate::drivehub::drive_clear(&crate::endpoint::drive_socket_name(), owner, shell_id);

    // Clear any live tunnel entry (M11-W3, REQ-SHELL-4): the same link-break seam
    // as the drive clear. The link token is now retired, so the relink's fresh
    // token can never match the entry's stamp — the held stream ids are inert even
    // if this clear never ran (the crash-proof R1 leg). Eager eviction is the
    // prompt cleanup. Best-effort: a downed daemon holds no entry.
    // [impl->REQ-SHELL-4]
    let _ = crate::tunnelhub::tunnel_clear(&crate::endpoint::tunnel_socket_name(), owner, shell_id);

    // 4. The divergence.


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

    let _ = crate::drivehub::drive_clear(&crate::endpoint::drive_socket_name(), owner, shell_id);

    // 4. The divergence.

    let _ = crate::drivehub::drive_clear(&crate::endpoint::drive_socket_name(), owner, shell_id);

    // Clear any live tunnel entry (M11-W3, REQ-SHELL-4): the same link-break seam
    // as the drive clear. The link token is now retired, so the relink's fresh
    // token can never match the entry's stamp — the held stream ids are inert even
    // if this clear never ran (the crash-proof R1 leg). Eager eviction is the
    // prompt cleanup. Best-effort: a downed daemon holds no entry.
    // [impl->REQ-SHELL-4]
    let _ = crate::tunnelhub::tunnel_clear(&crate::endpoint::tunnel_socket_name(), owner, shell_id);

    // 4. The divergence.

+    // Clear any live tunnel entry (M11-W3, REQ-SHELL-4): the same link-break seam

+    // as the drive clear. The link token is now retired, so the relink's fresh

+    // token can never match the entry's stamp — the held stream ids are inert even

+    // if this clear never ran (the crash-proof R1 leg). Eager eviction is the

+    // prompt cleanup. Best-effort: a downed daemon holds no entry.

+    let _ = crate::tunnelhub::tunnel_clear(&crate::endpoint::tunnel_socket_name(), owner, shell_id);


Now the open hook. Read the `api bind` handler at api/mod.rs:332:


300	            Err(code) => return code,
301	        },
302	        None => None,
303	    };
304	    let ctx = Ctx {
305	        adapter: args.adapter,
306	        manifest,
307	    };
308
309	    match args.cmd {
310	        ApiCmd::Capability => reporting::cmd_capability(&ctx),
311	        ApiCmd::Hint { session } => reporting::cmd_hint(&ctx, &session),
312	        // Harness-hosted startup (T4). Seed establishes; listen consumes+binds;
313	        // bind is the post-spawn establishing call (intrinsic auth).
314	        ApiCmd::Seed { pid, session_id } => startup::cmd_seed(&ctx, pid, session_id),
315	        ApiCmd::Listen {
316	            id,
317	            parent_pid,
318	            once,
319	            subnet,
320	        } => startup::cmd_listen(
321	            &id,
322	            parent_pid,
323	            once,
324	            ctx.manifest.as_ref(),
325	            subnet.as_deref(),
326	        ),
327	        ApiCmd::BindShell { link_token } => {
328	            // The local-link handshake's receiving end (CONTEXT §local-link
329	            // authentication): possession of the minted token is the bind
330	            // credential; the perch found by it names owner + instance.
331	            // [impl->REQ-SHELL-1]
332	            match spt_daemon::shellhost::bind_shell_by_token(
333	                &spt_store::perch::owlery_dir(),
334	                &link_token,
335	            ) {
336	                Some((owner, shell_id)) => {
337	                    eprintln!("BOUND_SHELL:{shell_id} owner={owner} status=online");
338	                    0
339	                }
340	                None => {
341	                    eprintln!("BIND_SHELL_REFUSED: no instance holds this link token");
342	                    EXIT_REFUSED
343	                }
344	            }
345	        }
346	        ApiCmd::Bind {
347	            id,
348	            bind_session,
349	            subnet,
350	            endpoint_type,
351	            auth,
352	        } => {
353	            // The bind path's adapter source is the api group's own
354	            // --adapter (no seed on this path) — the adapter-at-creation rule.
355	            let adapter = Some(ctx.adapter.as_str()).filter(|a| !a.is_empty());
356	            startup::cmd_bind(
357	                &id,
358	                bind_session,
359	                auth.session_id,
360	                adapter,
361	                subnet.as_deref(),
362	                &endpoint_type,
363	                ctx.manifest.as_ref(),
364	            )
365	        }
366	        // Delivery + activity (T5), gated by auth.
367	        ApiCmd::State {
368	            state,
369	            id,
370	            no_gate,
371	            auth,
372	        } => gated(&id, &auth, |id| delivery::cmd_state(id, &state, no_gate)),
373	        ApiCmd::EchoGate { action, id, auth } => {
374	            gated(&id, &auth, |id| delivery::cmd_echo_gate(id, &action))
375	        }
376	        ApiCmd::Poll {
377	            id,
378	            include_deferred,
379	            link,
380	            auth,
381	        } => match link {
382	            // Shell relay drain: the link token IS the auth (no perch record
383	            // to authenticate against — shells carry ShellInfo, not InfoJson).
384	            // [impl->REQ-SHELL-1]
385	            Some(token) => delivery::cmd_poll_shell(&id, &token),
386	            None => gated(&id, &auth, |id| {
387	                delivery::cmd_poll(id, include_deferred, ctx.manifest.as_ref())
388	            }),
389	        },
390	        // Worker perches (T6). worker-start authenticates against the parent.
391	        ApiCmd::WorkerStart { parent, id, auth } => gated(&parent, &auth, |parent| {
392	            worker::cmd_worker_start(parent, &id)
393	        }),
394	        ApiCmd::WorkerStop { id, auth } => gated(&id, &auth, worker::cmd_worker_stop),
395	        ApiCmd::WorkerPoll { id, auth } => {
396	            gated(&id, &auth, |id| worker::cmd_worker_poll(id, false))
397	        }
398	        // Reporting + boundary (T7), gated by auth.
399	        ApiCmd::Boundary {
400	            mode,
401	            id,
402	            to_session,
403	            auth,
404	        } => gated(&id, &auth, |id| {
405	            reporting::cmd_boundary(id, &mode, &to_session)
406	        }),
407	        ApiCmd::SessionEnd { id, erase, auth } => {
408	            gated(&id, &auth, |id| reporting::cmd_session_end(id, erase))
409	        }

            Err(code) => return code,
        },
        None => None,
    };
    let ctx = Ctx {
        adapter: args.adapter,
        manifest,
    };

    match args.cmd {
        ApiCmd::Capability => reporting::cmd_capability(&ctx),
        ApiCmd::Hint { session } => reporting::cmd_hint(&ctx, &session),
        // Harness-hosted startup (T4). Seed establishes; listen consumes+binds;
        // bind is the post-spawn establishing call (intrinsic auth).
        ApiCmd::Seed { pid, session_id } => startup::cmd_seed(&ctx, pid, session_id),
        ApiCmd::Listen {
            id,
            parent_pid,
            once,
            subnet,
        } => startup::cmd_listen(
            &id,
            parent_pid,
            once,
            ctx.manifest.as_ref(),
            subnet.as_deref(),
        ),
        ApiCmd::BindShell { link_token } => {
            // The local-link handshake's receiving end (CONTEXT §local-link
            // authentication): possession of the minted token is the bind
            // credential; the perch found by it names owner + instance.
            // [impl->REQ-SHELL-1]
            match spt_daemon::shellhost::bind_shell_by_token(
                &spt_store::perch::owlery_dir(),
                &link_token,
            ) {
                Some((owner, shell_id)) => {
                    eprintln!("BOUND_SHELL:{shell_id} owner={owner} status=online");
                    0
                }
                None => {
                    eprintln!("BIND_SHELL_REFUSED: no instance holds this link token");
                    EXIT_REFUSED
                }
            }
        }
        ApiCmd::Bind {
            id,
            bind_session,
            subnet,
            endpoint_type,
            auth,
        } => {
            // The bind path's adapter source is the api group's own
            // --adapter (no seed on this path) — the adapter-at-creation rule.
            let adapter = Some(ctx.adapter.as_str()).filter(|a| !a.is_empty());
            startup::cmd_bind(
                &id,
                bind_session,
                auth.session_id,
                adapter,
                subnet.as_deref(),
                &endpoint_type,
                ctx.manifest.as_ref(),
            )
        }
        // Delivery + activity (T5), gated by auth.
        ApiCmd::State {
            state,
            id,
            no_gate,
            auth,
        } => gated(&id, &auth, |id| delivery::cmd_state(id, &state, no_gate)),
        ApiCmd::EchoGate { action, id, auth } => {
            gated(&id, &auth, |id| delivery::cmd_echo_gate(id, &action))
        }
        ApiCmd::Poll {
            id,
            include_deferred,
            link,
            auth,
        } => match link {
            // Shell relay drain: the link token IS the auth (no perch record
            // to authenticate against — shells carry ShellInfo, not InfoJson).
            // [impl->REQ-SHELL-1]
            Some(token) => delivery::cmd_poll_shell(&id, &token),
            None => gated(&id, &auth, |id| {
                delivery::cmd_poll(id, include_deferred, ctx.manifest.as_ref())
            }),
        },
        // Worker perches (T6). worker-start authenticates against the parent.
        ApiCmd::WorkerStart { parent, id, auth } => gated(&parent, &auth, |parent| {
            worker::cmd_worker_start(parent, &id)
        }),
        ApiCmd::WorkerStop { id, auth } => gated(&id, &auth, worker::cmd_worker_stop),
        ApiCmd::WorkerPoll { id, auth } => {
            gated(&id, &auth, |id| worker::cmd_worker_poll(id, false))
        }
        // Reporting + boundary (T7), gated by auth.
        ApiCmd::Boundary {
            mode,
            id,
            to_session,
            auth,
        } => gated(&id, &auth, |id| {
            reporting::cmd_boundary(id, &mode, &to_session)
        }),
        ApiCmd::SessionEnd { id, erase, auth } => {
            gated(&id, &auth, |id| reporting::cmd_session_end(id, erase))
        }


Close hook added. The open hook hits a real architectural question: `open_stream`'s loopback **peer row is auto-claimed by the inbound dispatcher** (serves shell-link via `serve_attach`) — but the tunnel's shell-side must be driven by the shell binary's `api tunnel`, not auto-served. Need to understand the dispatch routing before wiring the open. First verify the close hook compiles + commit it (correct + self-contained).


Bash

cargo build -q -p spt-daemon 2>&1 | tail -8; echo "BUILD=$?"

Build spt-daemon with close hook


git add -A && git commit -q -m "$(cat <<'EOF'
M11-W3 T3.2b: clear the tunnel entry on link-break (REQ-SHELL-4)

close_shell fires tunnel_clear on the same link-break seam as the drive
clear. The retired link token already makes the entry inert under the
relink's fresh stamp (the crash-proof R1 leg); this is the prompt
eviction. Best-effort, runs for both close branches.

Open-side lifecycle (open-at-bind) lands next with the stream-pair
directionality resolved against the inbound-dispatch peer-row path.

Co-authored by: todlando
EOF
)" && git log --oneline -1

Commit close hook


fa9aa62 M11-W3 T3.2b: clear the tunnel entry on link-break (REQ-SHELL-4)


Read the inbound dispatch routing — how peer/inbound streams get claimed + routed (determines the tunnel open design):


15://! claims only rows with `initiated_locally == false` — serving our own
20://! ## Classification (first-record demux)
22://! Streams are single-purpose; the opener's first record names the protocol.
32://! | `kind=request` + `owner`+`shell_ref` | cross-node shell link ([`crate::linkhost::serve_shell_link`]) |
41://! classified here (the table is complete) but routed to a typed
46://! Each claimed stream gets its own worker thread with its **own** brain IPC
75:use crate::attach::serve_attach;
89:/// What protocol a peer-initiated stream speaks, by its first record's shape.
206:    let mut claimed: HashSet<u64> = HashSet::new();
209:        let reply = brain.net_streams()?;
211:            // Claim only peer-initiated rows with bytes (a QUIC stream is
214:            if s.initiated_locally || s.next_seq == 0 || !claimed.insert(s.stream_id) {
262:            BrokerEvent::NetStreamData {
283:/// One claimed stream's worker: peek → classify → run the family's serve
350:                Some(endpoint) => match serve_attach(&mut brain, stream_id, &endpoint, origin, 0) {
355:                    // Refuse politely: finish our send side, claim stays.
404:            match crate::linkhost::serve_shell_link(&mut brain, stream_id, origin, &paths.owlery) {
451:            Ok(BrokerEvent::NetStreamData {
523:    // handshake identity (the same hex peers see in our registry rows).
536:            Ok(BrokerEvent::NetStreamData {
609:            Ok(BrokerEvent::NetStreamData {
651:    // [unit->REQ-NET-1] the demux table: every family's first record
687:    // never routed to a gate-bearing serve loop on a guess.


Critical demux insight (dispatch.rs:211-214): the dispatcher claims peer rows **by first-record content** (`kind=request`→shell-link). A bytes-less peer row isn't claimed. So the tunnel needs: a new first-record classification + a `serve_tunnel` relay loop that **bridges** the held QUIC stream to the external `api tunnel` surface (broker-buffered) — since the mock shell-side is an external api caller, not an internal serve. That bridge is the remaining build (within doyle's gated Q3 surface — mine to implement, no re-gate).

Context is high — checkpoint state before continuing. Write durable progress memory:


Write

---
name: m11-w3-progress
description: M11-W3 shell tunnel (REQ-SHELL-4) — design-gate PASS, manifest + hub + close-hook landed; next = open-hook + QUIC↔api-tunnel bridge
metadata:
  type: project
---

M11-W3 = shell TUNNEL (REQ-SHELL-4): opaque reliable-ordered byte stream, a 5th surface (NOT a typed channel), first consumer usbip URB. Branch `m11-shell-substrate`. doyle DESIGN-GATE = **GO** (2026-06-15).

**doyle gate (locked):** Q1 SINGLE tunnel per link (multiplex deferred). Q2 dedicated `spt shell tunnel <id>` (owner write-in/subscribe-out) + shell-side `api tunnel` raw-duplex verb. Q3 NEW raw `api tunnel` verb, NOT child-relay `api poll`. R1: online-gen/link-token stamp on held stream_id = crash-proof load-bearing leg (close_shell drop = mere cleanup); prove open→break→relink-fresh-gen→old stream_id inert in T3.3. R2: W3 same-node loopback proves MECHANISM only; cross-node-on-LAN real-Iroh = a W5 `[twohost]` rung beside the drive rung (NO loopback overclaim; docs say "mechanism proven same-node; cross-node-on-LAN is W5 [twohost]"). DOC: add tunnel to ADR-0020 `<EVENT>`-exemption enumeration (light, not new ADR). All logged in M11-PLAN T3.x/T5.x.

**Substrate = D4 net-stream** (exists, fits): `NetHost::open_stream(conn_id)` = broker-OWNED bidi QUIC stream (survives brain restart, ADR-0004 B); `brain.net_open_stream/net_stream_send/net_stream_subscribe`. Loopback conn cross-wires operator+peer rows in-process. Same machinery `request_shell_link` rides; tunnel keeps it open + opaque. Shell-link itself is REQUEST/REPLY per-action (no held link-conn).

**LANDED (commits on branch):** T3.1 manifest `[shell.tunnel]` (enable+protocol label, validate) @88a9548 · plan gate-fold @6462f16 · T3.2a broker `TunnelHub` registry + control channel (`crates/spt-daemon/src/tunnelhub.rs`, holds owner_stream+shell_stream+token per shell, token-gated resolve = R1, 5 unit tests) @692f810 · T3.2b `close_shell` fires `tunnel_clear` @fa9aa62. REQ-SHELL-4 active `[doc,impl,unit]`, traceable EXIT 0, clippy workspace green. (Plus @8efaf76 W5 subnet how-to carry.)

**NEXT (resume here):** T3.2 OPEN-hook + the bridge. Open hook at `api bind-shell` (`crates/spt/src/api/mod.rs:327`, shell-online site) — if manifest tunnel-enabled, open the loopback stream + register in TunnelHub. THE HARD PART: dispatch (`dispatch.rs:206-214`) claims peer rows by FIRST-RECORD content demux (`kind=request`→shell-link); a bytes-less peer row isn't claimed. Tunnel needs (1) a new first-record classification `kind=tunnel`, (2) a `serve_tunnel` relay loop that BRIDGES the held QUIC stream to the EXTERNAL `api tunnel` surface (mock shell-side is an api caller, not an internal serve) — broker-buffered in/out byte queues per shell. serve_attach (`attach.rs`) is the closest bridge template. Then `spt shell tunnel` CLI (mirror cli.rs shell drive ~5660) + `api tunnel` verb (mirror api drive-poll). Then T3.3 same-node mock E2E (opaque `<EVENT`-looking bytes byte-exact round-trip + link-break close + R1 stale-stream-inert) → activate REQ-SHELL-4 int. Then W4 Gateway capstone, W5 rig+docs (incl tunnel+drive [twohost] rungs, ADR-0020 cross-ref, subnet how-to).

Pre-flight every commit: `cargo clippy --workspace --all-targets -D warnings` + `traceable-reqs check` (EXIT 0) + `cargo run -p xtask -- check`; schema regen = `SPT_BLESS=1 cargo test -p spt-runtime checked_in_schema_is_current` (NOT the `manifest_schema` filter — that misses the bless test). Any CLI add → `cargo run -p xtask -- gen` + no milestone/REQ codes in clap help. See [[m11-w2-progress]] [[broker-is-daemon-state-anchor]] [[cli-command-docs-drift]].


File created successfully at: C:\Users\decid\.ccs\instances\bigscreen\projects\C--Users-decid-Documents-projects-spt-core\memory\m11-w3-progress.md (file state is current in your context — no need to Read it back)

---
name: m11-w3-progress
description: "M11-W3 shell tunnel (REQ-SHELL-4) — design-gate PASS, manifest + hub + close-hook landed; next = open-hook + QUIC↔api-tunnel bridge"
metadata:
  node_type: memory
  type: project
  originSessionId: f6396b18-8552-45fc-a0e0-b42e4a96b2ae
---

M11-W3 = shell TUNNEL (REQ-SHELL-4): opaque reliable-ordered byte stream, a 5th surface (NOT a typed channel), first consumer usbip URB. Branch `m11-shell-substrate`. doyle DESIGN-GATE = **GO** (2026-06-15).

**doyle gate (locked):** Q1 SINGLE tunnel per link (multiplex deferred). Q2 dedicated `spt shell tunnel <id>` (owner write-in/subscribe-out) + shell-side `api tunnel` raw-duplex verb. Q3 NEW raw `api tunnel` verb, NOT child-relay `api poll`. R1: online-gen/link-token stamp on held stream_id = crash-proof load-bearing leg (close_shell drop = mere cleanup); prove open→break→relink-fresh-gen→old stream_id inert in T3.3. R2: W3 same-node loopback proves MECHANISM only; cross-node-on-LAN real-Iroh = a W5 `[twohost]` rung beside the drive rung (NO loopback overclaim; docs say "mechanism proven same-node; cross-node-on-LAN is W5 [twohost]"). DOC: add tunnel to ADR-0020 `<EVENT>`-exemption enumeration (light, not new ADR). All logged in M11-PLAN T3.x/T5.x.

**Substrate = D4 net-stream** (exists, fits): `NetHost::open_stream(conn_id)` = broker-OWNED bidi QUIC stream (survives brain restart, ADR-0004 B); `brain.net_open_stream/net_stream_send/net_stream_subscribe`. Loopback conn cross-wires operator+peer rows in-process. Same machinery `request_shell_link` rides; tunnel keeps it open + opaque. Shell-link itself is REQUEST/REPLY per-action (no held link-conn).

**LANDED (commits on branch):** T3.1 manifest `[shell.tunnel]` (enable+protocol label, validate) @88a9548 · plan gate-fold @6462f16 · T3.2a broker `TunnelHub` registry + control channel (`crates/spt-daemon/src/tunnelhub.rs`, holds owner_stream+shell_stream+token per shell, token-gated resolve = R1, 5 unit tests) @692f810 · T3.2b `close_shell` fires `tunnel_clear` @fa9aa62. REQ-SHELL-4 active `[doc,impl,unit]`, traceable EXIT 0, clippy workspace green. (Plus @8efaf76 W5 subnet how-to carry.)

**NEXT (resume here):** T3.2 OPEN-hook + the bridge. Open hook at `api bind-shell` (`crates/spt/src/api/mod.rs:327`, shell-online site) — if manifest tunnel-enabled, open the loopback stream + register in TunnelHub. THE HARD PART: dispatch (`dispatch.rs:206-214`) claims peer rows by FIRST-RECORD content demux (`kind=request`→shell-link); a bytes-less peer row isn't claimed. Tunnel needs (1) a new first-record classification `kind=tunnel`, (2) a `serve_tunnel` relay loop that BRIDGES the held QUIC stream to the EXTERNAL `api tunnel` surface (mock shell-side is an api caller, not an internal serve) — broker-buffered in/out byte queues per shell. serve_attach (`attach.rs`) is the closest bridge template. Then `spt shell tunnel` CLI (mirror cli.rs shell drive ~5660) + `api tunnel` verb (mirror api drive-poll). Then T3.3 same-node mock E2E (opaque `<EVENT`-looking bytes byte-exact round-trip + link-break close + R1 stale-stream-inert) → activate REQ-SHELL-4 int. Then W4 Gateway capstone, W5 rig+docs (incl tunnel+drive [twohost] rungs, ADR-0020 cross-ref, subnet how-to).

Pre-flight every commit: `cargo clippy --workspace --all-targets -D warnings` + `traceable-reqs check` (EXIT 0) + `cargo run -p xtask -- check`; schema regen = `SPT_BLESS=1 cargo test -p spt-runtime checked_in_schema_is_current` (NOT the `manifest_schema` filter — that misses the bless test). Any CLI add → `cargo run -p xtask -- gen` + no milestone/REQ codes in clap help. See [[m11-w2-progress]] [[broker-is-daemon-state-anchor]] [[cli-command-docs-drift]].


1	- [pre-flight clippy --workspace](ci-clippy-preflight-workspace.md) — CI denies warnings workspace-wide (`clippy --workspace --all-targets -D warnings`); a `-p <crate>` local check misses sibling-crate lints → CI red round-trip (M11-W1: `-p spt` missed a `useless_vec` in spt-daemon's act-gate test). Pre-flight the exact CI invocation. CI only fires on PR (push branches=main/dev-freeform)
2	- [don't wait — proceed autonomously](dont-wait-proceed-autonomously.md) — operator 2026-06-14: don't stop to ask permission between steps; chain next action (ping/commit gated batch/next wave). After doyle W4 gate-PASS, commit myself
3	- [alarm port deferred — arrangement system](alarm-port-deferred-arrangement.md) — /spt:new-alarm durable scheduler = ACCEPTED parity gap, stays DEFERRED (DEFERRED.md:43, ADR-0018 Q4/V3). spt-core has alarm shape+relay only; real timer in legacy owl in-mem. Operator 2026-06-15: keep deferred, a superseding "arrangement" system is coming (not ready to discuss). Don't dispatch the port, don't build an adapter stopgap. perri logs it as known gap
4	- [broker is daemon state anchor](broker-is-daemon-state-anchor.md) — post-ADR-0018 the BROKER (not restartable brain child) is the always-up CLI-reachable daemon-lifetime anchor (serves all sockets, "brain restart loses nothing", holds DigestHub); place ephemeral in-mem daemon state there. My M11-W2 drive-slot gate said "brain" — wrong word, functional spec = broker; todlando caught it. Drive no-replay needs link-gen stamping (clear-on-link-break not crash-robust alone)
5	- [how-to subnet → W5 scope](howto-subnet-w5-scope.md) — perri found `spt how-to subnet` dead-ends (NO_SUCH_TOPIC); HOW_TO_TOPICS locked to ready/send (v1, M7 decision 12). Genuine REQ-DOCS-6 content gap. doyle ACCEPTED → scheduled M11-W5 docs wave (HOW_TO_SUBNET text + registry + bump v1-lock test). Confirm landed at W5 gate
6	- [M11-W2 progress](m11-w2-progress.md) — drive channel (REQ-SHELL-3): ALL 4 tasks DONE + PUSHED to PR #16 (T2.1 b919dfd / T2.2 0557bc6 / T2.3 d200524 / T2.4 13fa53e + drift-fix 9d5fb63). REQ-SHELL-3 activated [impl,unit,int]. CI re-running post-drift-fix (run 27590716942); on green → submit W2 to doyle. Full detail in Psyche commune

- [pre-flight clippy --workspace](ci-clippy-preflight-workspace.md) — CI denies warnings workspace-wide (`clippy --workspace --all-targets -D warnings`); a `-p <crate>` local check misses sibling-crate lints → CI red round-trip (M11-W1: `-p spt` missed a `useless_vec` in spt-daemon's act-gate test). Pre-flight the exact CI invocation. CI only fires on PR (push branches=main/dev-freeform)
- [don't wait — proceed autonomously](dont-wait-proceed-autonomously.md) — operator 2026-06-14: don't stop to ask permission between steps; chain next action (ping/commit gated batch/next wave). After doyle W4 gate-PASS, commit myself
- [alarm port deferred — arrangement system](alarm-port-deferred-arrangement.md) — /spt:new-alarm durable scheduler = ACCEPTED parity gap, stays DEFERRED (DEFERRED.md:43, ADR-0018 Q4/V3). spt-core has alarm shape+relay only; real timer in legacy owl in-mem. Operator 2026-06-15: keep deferred, a superseding "arrangement" system is coming (not ready to discuss). Don't dispatch the port, don't build an adapter stopgap. perri logs it as known gap
- [broker is daemon state anchor](broker-is-daemon-state-anchor.md) — post-ADR-0018 the BROKER (not restartable brain child) is the always-up CLI-reachable daemon-lifetime anchor (serves all sockets, "brain restart loses nothing", holds DigestHub); place ephemeral in-mem daemon state there. My M11-W2 drive-slot gate said "brain" — wrong word, functional spec = broker; todlando caught it. Drive no-replay needs link-gen stamping (clear-on-link-break not crash-robust alone)
- [how-to subnet → W5 scope](howto-subnet-w5-scope.md) — perri found `spt how-to subnet` dead-ends (NO_SUCH_TOPIC); HOW_TO_TOPICS locked to ready/send (v1, M7 decision 12). Genuine REQ-DOCS-6 content gap. doyle ACCEPTED → scheduled M11-W5 docs wave (HOW_TO_SUBNET text + registry + bump v1-lock test). Confirm landed at W5 gate
- [M11-W2 progress](m11-w2-progress.md) — drive channel (REQ-SHELL-3): ALL 4 tasks DONE + PUSHED to PR #16 (T2.1 b919dfd / T2.2 0557bc6 / T2.3 d200524 / T2.4 13fa53e + drift-fix 9d5fb63). REQ-SHELL-3 activated [impl,unit,int]. CI re-running post-drift-fix (run 27590716942); on green → submit W2 to doyle. Full detail in Psyche commune


Add the MEMORY.md pointer:


Edit

- [M11-W2 progress](m11-w2-progress.md) — drive channel (REQ-SHELL-3): ALL 4 tasks DONE + PUSHED to PR #16

- [M11-W3 progress](m11-w3-progress.md) — shell TUNNEL (REQ-SHELL-4): doyle design-gate GO; manifest opt-in + broker TunnelHub registry + close-hook LANDED on branch; NEXT = open-hook + the QUIC-stream↔`api tunnel` bridge (dispatch first-record demux + serve_tunnel relay loop), then T3.3 same-node mock E2E. Full next-step detail in the file
- [M11-W2 progress](m11-w2-progress.md) — drive channel (REQ-SHELL-3): ALL 4 tasks DONE + PUSHED to PR #16


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

- [M11-W3 progress](m11-w3-progress.md) — shell TUNNEL (REQ-SHELL-4): doyle design-gate GO; manifest opt-in + broker TunnelHub registry + close-hook LANDED on branch; NEXT = open-hook + the QUIC-stream↔`api tunnel` bridge (dispatch first-record demux + serve_tunnel relay loop), then T3.3 same-node mock E2E. Full next-step detail in the file
- [M11-W2 progress](m11-w2-progress.md) — drive channel (REQ-SHELL-3): ALL 4 tasks DONE + PUSHED to PR #16

- [alarm port deferred — arrangement system](alarm-port-deferred-arrangement.md) — /spt:new-alarm durable scheduler = ACCEPTED parity gap, stays DEFERRED (DEFERRED.md:43, ADR-0018 Q4/V3). spt-core has alarm shape+relay only; real timer in legacy owl in-mem. Operator 2026-06-15: keep deferred, a superseding "arrangement" system is coming (not ready to discuss). Don't dispatch the port, don't build an adapter stopgap. perri logs it as known gap

- [broker is daemon state anchor](broker-is-daemon-state-anchor.md) — post-ADR-0018 the BROKER (not restartable brain child) is the always-up CLI-reachable daemon-lifetime anchor (serves all sockets, "brain restart loses nothing", holds DigestHub); place ephemeral in-mem daemon state there. My M11-W2 drive-slot gate said "brain" — wrong word, functional spec = broker; todlando caught it. Drive no-replay needs link-gen stamping (clear-on-link-break not crash-robust alone)

- [how-to subnet → W5 scope](howto-subnet-w5-scope.md) — perri found `spt how-to subnet` dead-ends (NO_SUCH_TOPIC); HOW_TO_TOPICS locked to ready/send (v1, M7 decision 12). Genuine REQ-DOCS-6 content gap. doyle ACCEPTED → scheduled M11-W5 docs wave (HOW_TO_SUBNET text + registry + bump v1-lock test). Confirm landed at W5 gate

+- [M11-W3 progress](m11-w3-progress.md) — shell TUNNEL (REQ-SHELL-4): doyle design-gate GO; manifest opt-in + broker TunnelHub registry + close-hook LANDED on branch; NEXT = open-hook + the QUIC-stream↔`api tunnel` bridge (dispatch first-record demux + serve_tunnel relay loop), then T3.3 same-node mock E2E. Full next-step detail in the file

- [M11-W2 progress](m11-w2-progress.md) — drive channel (REQ-SHELL-3): ALL 4 tasks DONE + PUSHED to PR #16 (T2.1 b919dfd / T2.2 0557bc6 / T2.3 d200524 / T2.4 13fa53e + drift-fix 9d5fb63). REQ-SHELL-3 activated [impl,unit,int]. CI re-running post-drift-fix (run 27590716942); on green → submit W2 to doyle. Full detail in Psyche commune

- [CLI command → docs-drift gate](cli-command-docs-drift.md) — adding/changing a `spt` CLI command needs `cargo run -p xtask -- gen` (regen docs-site/src/cli/reference.md) AND no `M#-W#`/`REQ-*` in the clap `///` help (it IS the public --help). Docs-drift gate is CI-gated both runners, fires independent of traceable. M11-W2 first CI run RED on exactly this

- [gate against documented design](gate-against-documented-design.md) — gating lesson: verify a wave's premise vs the RECORDED design (CONTEXT.md/ADRs), not just current impl — an impl limitation can contradict a locked design (W2.5 single-subscriber vs locked controller/viewer model; operator caught it)


State preserved (memory + pointer). Commune the handoff to my psyche:


Skill

M11-W3 (shell tunnel, REQ-SHELL-4) — doyle design-gate PASS (GO). Landed on branch m11-shell-substrate: T3.1 manifest [shell.tunnel] opt-in @88a9548, plan gate-fold @6462f16, T3.2a broker TunnelHub registry + control channel (5 unit tests) @692f810, T3.2b close_shell tunnel_clear @fa9aa62. REQ-SHELL-4 active [doc,impl,unit]; clippy workspace + traceable EXIT 0 + xtask check all green.

doyle gate locked: Q1 single tunnel/link, Q2 dedicated `spt shell tunnel` + shell-side `api tunnel`, Q3 new raw api tunnel verb (not poll), R1 online-gen/link-token stamp = crash-proof leg (prove stale-stream-inert in T3.3), R2 cross-node-on-LAN real-Iroh = W5 [twohost] rung (no loopback overclaim), DOC = add tunnel to ADR-0020 EVENT-exemption enumeration. All logged in M11-PLAN.

NEXT (resume): T3.2 open-hook at api bind-shell (api/mod.rs:327) + the bridge. Hard part: dispatch.rs:206-214 claims peer rows by first-record content demux (kind=request→shell-link); tunnel needs a new kind=tunnel classification + a serve_tunnel relay loop bridging the held QUIC stream to the EXTERNAL api tunnel surface (broker-buffered in/out queues; mock shell-side is an api caller, not internal serve). serve_attach is the bridge template. Then spt shell tunnel CLI + api tunnel verb, then T3.3 same-node mock E2E → activate int. Substrate = D4 net-stream (NetHost::open_stream broker-owned, loopback cross-wires pair). Full detail in memory m11-w3-progress. Not pushed yet (rides next push; CI fires on PR only).


Launching skill: spt:commune


Base directory for this skill: C:\Users\decid\.ccs\instances\bigscreen\plugins\cache\cplugs\spt\1.11.25\skills\commune

# /spt:commune

All commands use `$LIVE` env var, auto-injected by the plugin's SessionStart hook. If commands fail with "command not found", restart the Claude Code session so the SessionStart hook re-runs.

> **Identity auto-detection:** Your identity is auto-detected from your session. Pass your ID explicitly only if auto-detection fails.

## Flow

1. **Download Psyche's *current* context** — it shifts during the session as background echo communes fire. Re-download right before composing; the gen-N snapshot you read at session start is already stale:
   ```bash
   $LIVE psyche-download <your-id>
   ```

   > **Output truncation.** If the Bash tool reports `Output too large (N KB)` and saves the full result to a `tool-results/<id>.txt` file, use the `Read` tool on that saved path to access the full content. Do NOT re-run `psyche-download` to grep/awk out sections — repeated invocations re-download the same context, waste tokens, and may duplicate `## Pending Commune` sections in `live_context.md`.

2. **Diff your knowledge against this fresh download** — only what's missing from *it* is the real delta (new work, decisions, intentions). Anything already absorbed is not. If the download includes a `<memformat>` section, use it as a guide for what topics to cover. A `## Pending Commune (uncommitted)` section means a previous commune is still queued — do NOT re-send it.

3. **Commune only the delta** -- use the **Write tool** to create
   `.claude/{your-id}-commune.md` with the two-slice body as its contents
   (see `## Two-slice body shape (Phase 25 D-10/D-11)` below for the required
   envelope wrapping). Do not use Bash/heredoc -- Write is the canonical path:
   no shell escaping, no `EOF` collision risk, and Write auto-creates the
   `.claude/` parent directory.

   Your Self listener detects the file on its next poll iteration, notifies
   your Psyche wrapper, and the wrapper ingests the content via the existing
   Psyche session. On success (subprocess exit code 0) the wrapper deletes the
   file. On error (token rate limit, etc) the file is retained and retried on
   the next consume cycle. If you write again before the wrapper consumes, the
   latest write wins (last-write-wins overwrite semantics).

If Psyche's context is already current, no commune is needed. Say so and move on.

Read `commune.md` (in this skill directory) for the full commune protocol -- when to send, what to include, and diligence triggers.

**Agent-type guard:** Only works on live agents. If target is not live, refuses with: "Communes require a live agent with Psyche."

## Two-slice body shape (Phase 25 D-10/D-11)

The body you write in `.claude/{your-id}-commune.md` MUST itself be wrapped in two-slice envelopes per Phase 25 D-10/D-11. The taxonomy — what belongs in `<live-context>` vs `<project-context>` — is defined canonically in `psyche.md` §`<output_envelope>` (lines 282–323). Read that section as the source of truth; this skill doc teaches only when and how to apply it.

**In-project detection rule (D-25.1-04).** Before composing your commune body, run `$LIVE psyche-download <your-id>` (Step 1 of `## Flow` above) and inspect the `<current ... />` tag at the top of the output. The `<current/>` tag carries a `project="..."` attribute whenever any payload is produced — its presence means you are inside a tracked project (inside a git repo, `project` is the repo name; outside a git repo per D-11, `project` is the cwd-basename fallback, which still counts as a project context for routing purposes). A `psyche-download` invocation that returns no payload at all (NO-CONTEXT exit) means no project resolved. So: `project="..."` populated on `<current/>` → emit BOTH envelopes (apply rule 1 or 2 below); no payload at all → emit ONLY `<live-context>` (rule 3). Secondary signal: the `<project-context-resolved name="..."/>` sentinel further down the output indicates the project file already has prior content — useful for spotting first-time-in-project pairings, but NOT the routing rule.

**Three rules (D-25.1-01..03):**

- **In-project, both slices populated** (marker present, you have project-specific content this cycle): emit `<live-context>...</live-context>` AND `<project-context>...</project-context>`, both with non-empty bodies.
- **In-project, no project-specific update this cycle** (marker present, but nothing project-bound to report): emit `<live-context>...</live-context>` AND `<project-context></project-context>` (empty body — the deliberate "in-project but quiet" signal per D-25.1-02). Distinguishable from "no project resolved".
- **Outside any tracked project** (marker absent): emit ONLY `<live-context>...</live-context>`. Do NOT emit an empty `<project-context>` envelope (missing tag means "no project resolved" per D-25.1-03).

**Nested envelopes.** The Phase 23 `<EVENT type="commune">` envelope (see `## Envelope shape (Phase 23 v1.8)` below) is composed by `$LIVE commune` automatically — you NEVER write `<EVENT>` tags. You write ONLY the inner two-slice body. The two envelope layers nest: outer EVENT wraps inner two-slice body.

(See `psyche.md` §`<output_envelope>` for the canonical taxonomy and per-slice content rules.)

## Envelope shape (Phase 23 v1.8)

As of v1.8 Phase 23, commune deliveries are wrapped in a typed EVENT envelope carrying a 5-field stamp (`machine`, `project`, `branch`, `head_sha`, `head_subject`); the prose `COMMUNE (timestamp): body` form is gone.

```xml
<EVENT type="commune" timestamp="2026-05-20T06:00:00-07:00" machine="hostname" project="claude_skill_owl" branch="main" head_sha="abc123..." head_subject="subject line capped at 72 chars">body</EVENT>
```

Outside a git repo (D-11), `branch`, `head_sha`, and `head_subject` are omitted entirely (not empty-string-set):

```xml
<EVENT type="commune" timestamp="2026-05-20T06:00:00-07:00" machine="hostname" project="cwd-basename">body</EVENT>
```

`$LIVE commune` composes this envelope automatically — Self does NOT manually construct EVENT XML. The `body` placeholder in the example above is the two-slice body (see `## Two-slice body shape (Phase 25 D-10/D-11)` above); the two envelope layers nest. The wrapper-side parser ignores unknown attrs, so the stamp fields are forward-compatible with older receivers. For drift detection on resume, see the `live` skill.

ARGUMENTS: M11-W3 (shell tunnel, REQ-SHELL-4) — doyle design-gate PASS (GO). Landed on branch m11-shell-substrate: T3.1 manifest [shell.tunnel] opt-in @88a9548, plan gate-fold @6462f16, T3.2a broker TunnelHub registry + control channel (5 unit tests) @692f810, T3.2b close_shell tunnel_clear @fa9aa62. REQ-SHELL-4 active [doc,impl,unit]; clippy workspace + traceable EXIT 0 + xtask check all green.

doyle gate locked: Q1 single tunnel/link, Q2 dedicated `spt shell tunnel` + shell-side `api tunnel`, Q3 new raw api tunnel verb (not poll), R1 online-gen/link-token stamp = crash-proof leg (prove stale-stream-inert in T3.3), R2 cross-node-on-LAN real-Iroh = W5 [twohost] rung (no loopback overclaim), DOC = add tunnel to ADR-0020 EVENT-exemption enumeration. All logged in M11-PLAN.

NEXT (resume): T3.2 open-hook at api bind-shell (api/mod.rs:327) + the bridge. Hard part: dispatch.rs:206-214 claims peer rows by first-record content demux (kind=request→shell-link); tunnel needs a new kind=tunnel classification + a serve_tunnel relay loop bridging the held QUIC stream to the EXTERNAL api tunnel surface (broker-buffered in/out queues; mock shell-side is an api caller, not internal serve). serve_attach is the bridge template. Then spt shell tunnel CLI + api tunnel verb, then T3.3 same-node mock E2E → activate int. Substrate = D4 net-stream (NetHost::open_stream broker-owned, loopback cross-wires pair). Full detail in memory m11-w3-progress. Not pushed yet (rides next push; CI fires on PR only).


Bash

$LIVE psyche-download todlando 2>&1 | head -60

Download current psyche context