Base directory for this skill: C:\Users\decid\.claude\plugins\cache\cplugs\spt\1.11.25\skills\send

# /spt:send

All commands use `$OWL` 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.

## Messaging Command Reference

Both commands read the message body from **stdin** (pipe or heredoc).

```bash
# send -- fire-and-forget to a target (use when you have your own perch)
$OWL send <target> <<'EOF'
message body
EOF

# send --reply-to -- respond to whoever messaged you
$OWL send --reply-to <sender> [your-id] <<'EOF'
response body
EOF

# ask -- deliver + create ephemeral reply perch + poll for reply (use when you have NO perch)
$OWL ask <target> [from] <<'EOF'
message body
EOF
```

If auto-detection fails, pass your ID explicitly: `$OWL send <target> <your-id>`, `$OWL send --reply-to <sender> <your-id>`, `$OWL ask <target> <impromptu-id>`.

**When to use which:**
- **`send`**: You already have a listener. Fire-and-forget, no reply perch created.
- **`send --reply-to`**: You received a message and want to respond. `from` is auto-set from the original sender.
- **`ask`**: You have no listener. Creates a temporary perch, delivers, then polls for the reply.

## Pre-checks

- If `<target>` matches this agent's own listener ID, refuse. Self-sends create loops.
- All send/reply-to/ask commands verify the target is alive automatically.

## If you already have a listener

Just send -- no reply perch needed. Run **not** in background:

```bash
$OWL send <target> <<'EOF'
<msg>
EOF
```

## If you have no listener

The `ask` subcommand creates an ephemeral reply perch, delivers, and polls for the reply. Run with `run_in_background: true` (or `false` for `--block`):

```bash
$OWL ask <target> <<'EOF'
<msg>
EOF
```

## On response

**Important:** If you have an active listener perch, always launch Agent tool calls with `run_in_background: true`. Foreground agents block your poll loop -- no messages (including replies to your send) can be delivered until the agent finishes.

When the response arrives, present it to the user. Responses arrive via one of three paths:
- **Background interject** -- the `$OWL ask` background task completes with the reply.
- **Inline** -- if you used `--block`, the reply prints directly.
- **Hook-injected XML** -- if you are mid-tool-call, the reply appears as `<owl_messages>` XML in your tool call context. The `from` attribute is the sender.

ARGUMENTS: doyle ACK keystone dispatch. Branch synced @0d8ed2e (discarded my WT dup of your REQ). Starting Wave 2 L0 NOW — STEP 1 path-vs-program isolation: swap endpoint-run [session.self] to attach.rs's known-delivering child, run via endpoint run, attach. Will ping with the isolate result before the fix (your offered check-in). Fixture = committed dummy_harness_e2e.rs (flip rc_saw_tick diagnostic→hard assert once fixed).


Bash

$OWL send doyle <<'EOF'
ACK keystone dispatch. Branch synced @0d8ed2e (discarded my WT dup of your REQ). Starting Wave 2 L0 NOW.
STEP 1 path-vs-program isolation: swap endpoint-run [session.self] to attach.rs's known-delivering child, run via endpoint run, attach.
Will ping with the isolate result before the fix (your offered check-in). Fixture = committed dummy_harness_e2e.rs (flip rc_saw_tick diagnostic->hard assert once fixed).
EOF

Send ack to doyle


➜ SENT:doyle


Acked. Begin L0. Read the two spawn paths + rc subscribe path. Locate files first.


Found 18 files
V0.12.1-LIFECYCLE-JIT.md
traceable-reqs.toml
crates\spt\tests\dummy_harness_e2e.rs
adapters\mock\src\main.rs
crates\spt\src\cli.rs
crates\spt-runtime\src\runtime.rs
crates\spt\src\rc.rs
crates\spt-daemon\tests\attach.rs
crates\spt-daemon\tests\budget.rs
crates\spt-daemon\src\harnesshost.rs
crates\spt-daemon\src\shellhost.rs
crates\spt-daemon\src\brain.rs
docs\KNOWN-HAZARDS.md
M12-W2-PLAN.md
M12-W1-PLAN.md
M12-PLAN.md
M12-CANDIDATES.md
docs\DORMANCY-BUDGET.md


Grep

fn spawn_session_pid|fn spawn_session\b|fn launch_harness_brokered_in


crates\spt-daemon\src\harnesshost.rs:144:pub fn launch_harness_brokered_in(
crates\spt-runtime\src\runtime.rs:235:    fn spawn_session(
crates\spt-runtime\src\runtime.rs:367:    fn spawn_session(
crates\spt-daemon\src\brain.rs:384:    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
crates\spt-daemon\src\brain.rs:403:    pub fn spawn_session_pid(&mut self, req: SpawnReq) -> io::Result<(u64, Option<u32>)> {


380
381	    /// Spawn a session under a broker PTY; the broker auto-subscribes this
382	    /// connection. Reads until the `spawned` reply (consuming any output that
383	    /// races ahead).
384	    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
385	        self.spawn_session_pid(req)
386	            .map(|(session_id, _)| session_id)
387	    }
388
389	    /// [`spawn_session`](Self::spawn_session) that also surfaces the child's
390	    /// OS pid (when the backend exposes one) — measurement/observability
391	    /// callers (the D9-3 dormancy-budget harness) sample the seat by pid.
392	    ///
393	    /// **Resume-mode caveat (KNOWN-HAZARDS 6.9).** The `Spawned`-wait loop below
394	    /// calls [`Brain::read_event`] on every interleaved frame, so on a *resume-mode*
395	    /// brain (multiple sessions in `session_cursors`) an OUTPUT frame for a
396	    /// **different** session read here is cursor-processed (its cursor snaps; the
397	    /// broker already counted it delivered) and then **discarded** by `_ => continue`
398	    /// — that chunk is lost to its consumer and the broker will not re-send it.
399	    /// Unreachable today (the supervised brain spawns nothing; a legacy single-session
400	    /// seat has no other session); the daemon-hosted live-agent event pump must make
401	    /// this non-blocking (one demux owns `read_event`, routing each frame to its
402	    /// session) or route the frames it reads for other sessions, not drop them.
403	    pub fn spawn_session_pid(&mut self, req: SpawnReq) -> io::Result<(u64, Option<u32>)> {
404	        self.send(
405	            KIND_SPAWN,
406	            serde_json::to_value(req).expect("SpawnReq serializes"),
407	        )?;
408	        loop {
409	            match self.read_event()? {
410	                BrokerEvent::Spawned { session_id, pid } => {
411	                    self.session_id = Some(session_id);
412	                    // A resume-mode brain tracks this fresh session per-session via
413	                    // `read_event`'s first-sight `or_insert` seed (D4-2b) — no seed
414	                    // needed here; a legacy brain (empty map) uses `next_seq`.
415	                    return Ok((session_id, pid));
416	                }
417	                BrokerEvent::Error { message } => {
418	                    return Err(io::Error::other(message));
419	                }
420	                _ => continue,
421	            }
422	        }
423	    }
424
425	    /// Inject input bytes into the current session (send-keys), the **legacy
426	    /// direct** path: no durable id, no dedup, no ack. For exactly-once delivery
427	    /// of an effect that originates from a durable source (a spool row, a
428	    /// sequenced injection), use [`Brain::send_effect`].
429	    pub fn send_input(&mut self, bytes: &[u8]) -> io::Result<()> {
430	        let sid = self.require_session()?;
431	        self.send(
432	            KIND_INPUT,
433	            serde_json::to_value(InputReq {
434	                session_id: sid,
435	                data_b64: encode_bytes(bytes),
436	                op_id: None,
437	            })
438	            .expect("InputReq serializes"),
439	        )
440	    }
441
442	    /// Inject input **idempotently**: the write carries the durable `op_id` so a
443	    /// replay after a brain crash is deduped at the broker (exactly-once, B5).
444	    /// `op_id` must come from a durable source and be reused verbatim on replay —
445	    /// the same logical effect must always carry the same id, so the broker's
446	    /// applied-set recognizes the re-drive. The broker answers with a
447	    /// [`BrokerEvent::Applied`] for `(session, op_id)`.
448	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
449	    pub fn send_effect(&mut self, op_id: u64, bytes: &[u8]) -> io::Result<()> {
450	        let sid = self.require_session()?;
451	        self.send(
452	            KIND_INPUT,
453	            serde_json::to_value(InputReq {
454	                session_id: sid,
455	                data_b64: encode_bytes(bytes),
456	                op_id: Some(op_id),
457	            })
458	            .expect("InputReq serializes"),
459	        )
460	    }
461
462	    /// Resize the current session's PTY.
463	    pub fn resize(&mut self, rows: u16, cols: u16) -> io::Result<()> {
464	        let sid = self.require_session()?;
465	        self.send(
466	            KIND_RESIZE,
467	            serde_json::to_value(ResizeReq {
468	                session_id: sid,
469	                rows,

    /// Spawn a session under a broker PTY; the broker auto-subscribes this
    /// connection. Reads until the `spawned` reply (consuming any output that
    /// races ahead).
    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
        self.spawn_session_pid(req)
            .map(|(session_id, _)| session_id)
    }

    /// [`spawn_session`](Self::spawn_session) that also surfaces the child's
    /// OS pid (when the backend exposes one) — measurement/observability
    /// callers (the D9-3 dormancy-budget harness) sample the seat by pid.
    ///
    /// **Resume-mode caveat (KNOWN-HAZARDS 6.9).** The `Spawned`-wait loop below
    /// calls [`Brain::read_event`] on every interleaved frame, so on a *resume-mode*
    /// brain (multiple sessions in `session_cursors`) an OUTPUT frame for a
    /// **different** session read here is cursor-processed (its cursor snaps; the
    /// broker already counted it delivered) and then **discarded** by `_ => continue`
    /// — that chunk is lost to its consumer and the broker will not re-send it.
    /// Unreachable today (the supervised brain spawns nothing; a legacy single-session
    /// seat has no other session); the daemon-hosted live-agent event pump must make
    /// this non-blocking (one demux owns `read_event`, routing each frame to its
    /// session) or route the frames it reads for other sessions, not drop them.
    pub fn spawn_session_pid(&mut self, req: SpawnReq) -> io::Result<(u64, Option<u32>)> {
        self.send(
            KIND_SPAWN,
            serde_json::to_value(req).expect("SpawnReq serializes"),
        )?;
        loop {
            match self.read_event()? {
                BrokerEvent::Spawned { session_id, pid } => {
                    self.session_id = Some(session_id);
                    // A resume-mode brain tracks this fresh session per-session via
                    // `read_event`'s first-sight `or_insert` seed (D4-2b) — no seed
                    // needed here; a legacy brain (empty map) uses `next_seq`.
                    return Ok((session_id, pid));
                }
                BrokerEvent::Error { message } => {
                    return Err(io::Error::other(message));
                }
                _ => continue,
            }
        }
    }

    /// Inject input bytes into the current session (send-keys), the **legacy
    /// direct** path: no durable id, no dedup, no ack. For exactly-once delivery
    /// of an effect that originates from a durable source (a spool row, a
    /// sequenced injection), use [`Brain::send_effect`].
    pub fn send_input(&mut self, bytes: &[u8]) -> io::Result<()> {
        let sid = self.require_session()?;
        self.send(
            KIND_INPUT,
            serde_json::to_value(InputReq {
                session_id: sid,
                data_b64: encode_bytes(bytes),
                op_id: None,
            })
            .expect("InputReq serializes"),
        )
    }

    /// Inject input **idempotently**: the write carries the durable `op_id` so a
    /// replay after a brain crash is deduped at the broker (exactly-once, B5).
    /// `op_id` must come from a durable source and be reused verbatim on replay —
    /// the same logical effect must always carry the same id, so the broker's
    /// applied-set recognizes the re-drive. The broker answers with a
    /// [`BrokerEvent::Applied`] for `(session, op_id)`.
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    pub fn send_effect(&mut self, op_id: u64, bytes: &[u8]) -> io::Result<()> {
        let sid = self.require_session()?;
        self.send(
            KIND_INPUT,
            serde_json::to_value(InputReq {
                session_id: sid,
                data_b64: encode_bytes(bytes),
                op_id: Some(op_id),
            })
            .expect("InputReq serializes"),
        )
    }

    /// Resize the current session's PTY.
    pub fn resize(&mut self, rows: u16, cols: u16) -> io::Result<()> {
        let sid = self.require_session()?;
        self.send(
            KIND_RESIZE,
            serde_json::to_value(ResizeReq {
                session_id: sid,
                rows,


`spawn_session` just calls `spawn_session_pid` — same KIND_SPAWN → same dispatch_spawn drain. Difference is in CALLER's SpawnReq construction + the rc subscribe path. Read `launch_harness_brokered_in`.


144	pub fn launch_harness_brokered_in(
145	    broker_name: &str,
146	    endpoint_id: &str,
147	    adapter_name: &str,
148	    session_id: &str,
149	    manifest: &Manifest,
150	    cwd: Option<&str>,
151	) -> Result<HarnessSpawned, String> {
152	    let PreparedSpawn { tokens, env } =
153	        prepare_harness_spawn(endpoint_id, adapter_name, session_id, manifest)?;
154	    let (program, args) = tokens.split_first().expect("non-empty checked");
155	    let mut brain =
156	        Brain::cold_start(broker_name, now_ms()).map_err(|e| format!("broker connect: {e}"))?;
157	    let (session_id, pid) = brain
158	        .spawn_session_pid(SpawnReq {
159	            program: program.clone(),
160	            args: args.to_vec(),
161	            rows: DEFAULT_HARNESS_ROWS,
162	            cols: DEFAULT_HARNESS_COLS,
163	            endpoint: endpoint_id.to_string(),
164	            cwd: cwd.map(str::to_string),
165	            env,
166	        })
167	        .map_err(|e| format!("broker spawn: {e}"))?;
168	    Ok(HarnessSpawned { session_id, pid })
169	}
170
171	#[cfg(test)]
172	mod tests {
173	    use super::*;
174
175	    fn harness_manifest(self_command: &str) -> Manifest {
176	        let toml_src = format!(
177	            "[adapter]\nname = \"mock\"\nkind = \"harness\"\nversion = \"1\"\n\
178	             min_spt_core_version = \"0\"\n\n[session.self]\ncommand = '{self_command}'\n\
179	             keys = [\"id\", \"session_id\"]\n"
180	        );
181	        spt_runtime::Manifest::from_toml_str(&toml_src).unwrap()
182	    }
183
184	    // [unit->REQ-HOST-RUN-1] the spt-hosted bringup is template-driven: the
185	    // endpoint id and the spawn-time session id ride argv via {id}/{session_id}
186	    // substitution into [session.self] (so the harness binds to exactly this
187	    // endpoint), tokenized program-first.
188	    #[test]
189	    fn prepare_fills_id_and_session_into_self_command() {
190	        let m = harness_manifest("mock-session --id {id} --session-id {session_id}");
191	        let prepared = prepare_harness_spawn("doyle", "mock", "sess-abc", &m).unwrap();
192	        assert_eq!(
193	            prepared.tokens,
194	            vec![
195	                "mock-session".to_string(),
196	                "--id".to_string(),
197	                "doyle".to_string(),
198	                "--session-id".to_string(),
199	                "sess-abc".to_string(),
200	            ]
201	        );
202	        assert!(prepared.env.is_empty(), "no [env] declared → no inject");
203	    }
204
205	    // [unit->REQ-HAZARD-ENV-SUBST] a manifest [env.<VAR>] direction=inject value
206	    // is {key}-substituted from the same catalog and returned for the spawn — so a
207	    // flagless harness gets SPT_ENDPOINT_ID populated (not empty → seed-by-PPID).
208	    #[test]
209	    fn prepare_fills_env_inject_values_with_substitution() {
210	        let m = spt_runtime::Manifest::from_toml_str(
211	            "[adapter]\nname = \"mock\"\nkind = \"harness\"\nversion = \"1\"\n\
212	             min_spt_core_version = \"0\"\n\n[session.self]\ncommand = 'claude'\n\n\
213	             [env.SPT_ENDPOINT_ID]\ndirection = \"inject\"\nvalue = \"{id}\"\n\n\
214	             [env.SPT_SESSION]\ndirection = \"inject\"\nvalue = \"sess-{session_id}\"\n\n\
215	             [env.SPT_READ_ONLY]\ndirection = \"read\"\n",
216	        )
217	        .unwrap();
218	        let prepared = prepare_harness_spawn("wall-b", "mock", "abc", &m).unwrap();
219	        assert_eq!(prepared.env.get("SPT_ENDPOINT_ID").map(String::as_str), Some("wall-b"));
220	        assert_eq!(prepared.env.get("SPT_SESSION").map(String::as_str), Some("sess-abc"));
221	        // A `read` directive injects nothing.
222	        assert!(!prepared.env.contains_key("SPT_READ_ONLY"));
223	    }
224
225	    // [unit->REQ-HOST-RUN-1] fails closed before any spawn: a shell-kind adapter
226	    // is refused (endpoint run hosts harnesses), a manifest with no [session.self]
227	    // is refused (nothing to bring up), and an unresolved {placeholder} errs
228	    // naming the missing key.
229	    #[test]
230	    fn prepare_fails_closed_on_wrong_kind_missing_role_and_bad_template() {
231	        // Shell-kind adapter refused.
232	        let shell = spt_runtime::Manifest::from_toml_str(
233	            "[adapter]\nname = \"sh\"\nkind = \"shell\"\nversion = \"1\"\n\
234	             min_spt_core_version = \"0\"\n\n[shell]\nspawn = 'sh'\n",
235	        )
236	        .unwrap();
237	        assert!(prepare_harness_spawn("e", "sh", "s", &shell)
238	            .unwrap_err()
239	            .contains("not a harness"));
240
241	        // Harness with no [session.self] refused.
242	        let no_self = spt_runtime::Manifest::from_toml_str(
243	            "[adapter]\nname = \"h\"\nkind = \"harness\"\nversion = \"1\"\n\
244	             min_spt_core_version = \"0\"\n",
245	        )
246	        .unwrap();
247	        assert!(prepare_harness_spawn("e", "h", "s", &no_self)
248	            .unwrap_err()
249	            .contains("no [session.self]"));
250
251	        // Unknown {placeholder} errs naming the key.
252	        let bad = harness_manifest("mock-session --id {id} --boom {not_a_key}");
253	        assert!(prepare_harness_spawn("e", "mock", "s", &bad)
254	            .unwrap_err()
255	            .contains("not_a_key"));
256	    }
257
258	    // [unit->REQ-HOST-RUN-1] a provisional session id is fresh, hex, and unique
259	    // per mint (it only fills the spawn-time {session_id}; the harness reports the
260	    // real one post-spawn).
261	    #[test]
262	    fn mint_session_id_is_fresh_hex() {
263	        let a = mint_session_id();

pub fn launch_harness_brokered_in(
    broker_name: &str,
    endpoint_id: &str,
    adapter_name: &str,
    session_id: &str,
    manifest: &Manifest,
    cwd: Option<&str>,
) -> Result<HarnessSpawned, String> {
    let PreparedSpawn { tokens, env } =
        prepare_harness_spawn(endpoint_id, adapter_name, session_id, manifest)?;
    let (program, args) = tokens.split_first().expect("non-empty checked");
    let mut brain =
        Brain::cold_start(broker_name, now_ms()).map_err(|e| format!("broker connect: {e}"))?;
    let (session_id, pid) = brain
        .spawn_session_pid(SpawnReq {
            program: program.clone(),
            args: args.to_vec(),
            rows: DEFAULT_HARNESS_ROWS,
            cols: DEFAULT_HARNESS_COLS,
            endpoint: endpoint_id.to_string(),
            cwd: cwd.map(str::to_string),
            env,
        })
        .map_err(|e| format!("broker spawn: {e}"))?;
    Ok(HarnessSpawned { session_id, pid })
}

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

    fn harness_manifest(self_command: &str) -> Manifest {
        let toml_src = format!(
            "[adapter]\nname = \"mock\"\nkind = \"harness\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n[session.self]\ncommand = '{self_command}'\n\
             keys = [\"id\", \"session_id\"]\n"
        );
        spt_runtime::Manifest::from_toml_str(&toml_src).unwrap()
    }

    // [unit->REQ-HOST-RUN-1] the spt-hosted bringup is template-driven: the
    // endpoint id and the spawn-time session id ride argv via {id}/{session_id}
    // substitution into [session.self] (so the harness binds to exactly this
    // endpoint), tokenized program-first.
    #[test]
    fn prepare_fills_id_and_session_into_self_command() {
        let m = harness_manifest("mock-session --id {id} --session-id {session_id}");
        let prepared = prepare_harness_spawn("doyle", "mock", "sess-abc", &m).unwrap();
        assert_eq!(
            prepared.tokens,
            vec![
                "mock-session".to_string(),
                "--id".to_string(),
                "doyle".to_string(),
                "--session-id".to_string(),
                "sess-abc".to_string(),
            ]
        );
        assert!(prepared.env.is_empty(), "no [env] declared → no inject");
    }

    // [unit->REQ-HAZARD-ENV-SUBST] a manifest [env.<VAR>] direction=inject value
    // is {key}-substituted from the same catalog and returned for the spawn — so a
    // flagless harness gets SPT_ENDPOINT_ID populated (not empty → seed-by-PPID).
    #[test]
    fn prepare_fills_env_inject_values_with_substitution() {
        let m = spt_runtime::Manifest::from_toml_str(
            "[adapter]\nname = \"mock\"\nkind = \"harness\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n[session.self]\ncommand = 'claude'\n\n\
             [env.SPT_ENDPOINT_ID]\ndirection = \"inject\"\nvalue = \"{id}\"\n\n\
             [env.SPT_SESSION]\ndirection = \"inject\"\nvalue = \"sess-{session_id}\"\n\n\
             [env.SPT_READ_ONLY]\ndirection = \"read\"\n",
        )
        .unwrap();
        let prepared = prepare_harness_spawn("wall-b", "mock", "abc", &m).unwrap();
        assert_eq!(prepared.env.get("SPT_ENDPOINT_ID").map(String::as_str), Some("wall-b"));
        assert_eq!(prepared.env.get("SPT_SESSION").map(String::as_str), Some("sess-abc"));
        // A `read` directive injects nothing.
        assert!(!prepared.env.contains_key("SPT_READ_ONLY"));
    }

    // [unit->REQ-HOST-RUN-1] fails closed before any spawn: a shell-kind adapter
    // is refused (endpoint run hosts harnesses), a manifest with no [session.self]
    // is refused (nothing to bring up), and an unresolved {placeholder} errs
    // naming the missing key.
    #[test]
    fn prepare_fails_closed_on_wrong_kind_missing_role_and_bad_template() {
        // Shell-kind adapter refused.
        let shell = spt_runtime::Manifest::from_toml_str(
            "[adapter]\nname = \"sh\"\nkind = \"shell\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n[shell]\nspawn = 'sh'\n",
        )
        .unwrap();
        assert!(prepare_harness_spawn("e", "sh", "s", &shell)
            .unwrap_err()
            .contains("not a harness"));

        // Harness with no [session.self] refused.
        let no_self = spt_runtime::Manifest::from_toml_str(
            "[adapter]\nname = \"h\"\nkind = \"harness\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n",
        )
        .unwrap();
        assert!(prepare_harness_spawn("e", "h", "s", &no_self)
            .unwrap_err()
            .contains("no [session.self]"));

        // Unknown {placeholder} errs naming the key.
        let bad = harness_manifest("mock-session --id {id} --boom {not_a_key}");
        assert!(prepare_harness_spawn("e", "mock", "s", &bad)
            .unwrap_err()
            .contains("not_a_key"));
    }

    // [unit->REQ-HOST-RUN-1] a provisional session id is fresh, hex, and unique
    // per mint (it only fills the spawn-time {session_id}; the harness reports the
    // real one post-spawn).
    #[test]
    fn mint_session_id_is_fresh_hex() {
        let a = mint_session_id();


`launch_harness_brokered_in` connects via transient `Brain::cold_start`, spawns, RETURNS — the spawning connection **drops** after Spawned (brain out of scope L168). Later `spt rc` makes a SEPARATE connection + subscribes via resolve_session. Known-good attach.rs "rides the SAME pump." Key suspect: fresh-subscribe fan-out to existing session (candidate d). Read rc.rs attach + broker subscribe/OutputLog.


1	//! `spt rc <id>` — the operator-side attach pump (M12-W1, REQ-RC-1): attach a
2	//! local terminal to a broker-held PTY session.
3	//!
4	//! **One pump, swappable transport** (the doyle invariant). The attach protocol
5	//! is `serve_attach` on the target side ⇄ this pump on the operator side, both
6	//! speaking `AttachRecord` over a broker net-stream. *Local* attach is the
7	//! degenerate single-node case: the operator dials its OWN node (loopback peer)
8	//! and the broker's inbound-stream dispatch (`dispatch.rs`) runs `serve_attach`
9	//! against the same broker — the exact cross-node accept+serve path, no local
10	//! special-case branch. The one-path invariant lives at this pump, not the
11	//! transport under the stream.
12	//!
13	//! **Detach never kills the session.** The broker owns the PTY; closing our send
14	//! side ends the viewport with the session still running (`serve_attach` →
15	//! `Detached`). PTY ownership stays with the broker (the KNOWN-HAZARDS
16	//! attach-lifecycle invariant).
17	//!
18	//! **Single-Brain pump.** A `Brain` is `&mut`-single-threaded, so the main thread
19	//! owns it: a stdin reader thread only *produces* raw bytes onto an mpsc channel
20	//! (and watches for the detach keybind); the main loop interleaves draining that
21	//! channel into `send_attach_input` with `read_event_until` for inbound `Output`.
22	// [impl->REQ-RC-1]
23
24	use std::io::{Read, Write};
25	use std::sync::atomic::{AtomicU64, Ordering};
26	use std::sync::mpsc;
27	use std::time::{Duration, Instant};
28
29	use spt_daemon::attach::{request_attach, send_attach_input, send_attach_resize};
30	use spt_daemon::brain::{now_ms, Brain, BrokerEvent};
31	use spt_daemon::msg::decode_bytes;
32	use spt_net::net::attach::{AttachDecoder, AttachIntent, AttachRecord};
33
34	/// The detach prefix: ctrl-b (0x02), matching the legacy capsule's prefix so the
35	/// muscle memory carries over. `ctrl-b d` detaches; `ctrl-b ctrl-b` sends one
36	/// literal ctrl-b to the session; any other byte after the prefix is sent
37	/// through verbatim (prefix + byte). Documented in the CLI help.
38	const DETACH_PREFIX: u8 = 0x02; // ctrl-b
39	const DETACH_KEY: u8 = b'd';
40
41	/// How long the main loop blocks for an inbound event before looping back to
42	/// drain stdin — the full-duplex interleave granularity.
43	const POLL_SLICE: Duration = Duration::from_millis(40);
44
45	/// A message from the stdin reader thread to the pump.
46	enum StdinMsg {
47	    /// Raw bytes to forward to the session as `AttachRecord::Input`.
48	    Bytes(Vec<u8>),
49	    /// The operator hit the detach keybind — end the viewport, leave the session.
50	    Detach,
51	}
52
53	/// Mint operator op ids from a random 64-bit base so two `spt rc` sessions never
54	/// collide in the broker's net-op journal (a journaled `send_attach_input` op is
55	/// exactly-once at the PTY — a reused id from a prior session would be deduped,
56	/// dropping the keystroke). Random base + monotonic counter.
57	struct OpMinter(AtomicU64);
58	impl OpMinter {
59	    fn new() -> Self {
60	        let seed = spt_proto::identity::Identity::generate().seed();
61	        let base = u64::from_le_bytes(seed[..8].try_into().expect("8 bytes"));
62	        // Keep it well below u64::MAX so the counter can't wrap into another
63	        // session's base within any realistic session length.
64	        OpMinter(AtomicU64::new(base >> 1))
65	    }
66	    fn next(&self) -> u64 {
67	        self.0.fetch_add(1, Ordering::Relaxed)
68	    }
69	}
70
71	/// Restore the terminal out of raw mode no matter how the pump returns.
72	struct RawGuard(bool);
73	impl RawGuard {
74	    fn enable() -> Self {
75	        RawGuard(crossterm::terminal::enable_raw_mode().is_ok())
76	    }
77	}
78	impl Drop for RawGuard {
79	    fn drop(&mut self) {
80	        if self.0 {
81	            let _ = crossterm::terminal::disable_raw_mode();
82	        }
83	    }
84	}
85
86	/// Resolve a live endpoint id to its broker session id via the broker's hosted
87	/// session table (the spawn's endpoint label, brain.sessions()). `None` ⇒ no
88	/// live session under that endpoint.
89	fn resolve_session(brain: &mut Brain, endpoint_id: &str) -> std::io::Result<Option<u64>> {
90	    let reply = brain.sessions()?;
91	    Ok(reply
92	        .sessions
93	        .into_iter()
94	        .find(|s| s.endpoint == endpoint_id)
95	        .map(|s| s.session_id))
96	}
97
98	/// The node currently driving `endpoint_id`, if any — read off the perch's
99	/// broker-owned `driven_by` marker (REQ-RCVIEW-1). Drives the client-side
100	/// busy-refuse guidance. `None` ⇒ free (or no perch).
101	fn current_driver(endpoint_id: &str) -> Option<String> {
102	    let perch = spt_store::perch::resolve_perch_path(
103	        endpoint_id,
104	        spt_store::perch::ParentHint::Infer,
105	    );
106	    spt_store::info::read_info(&perch).and_then(|i| i.driven_by)
107	}
108
109	/// What one stdin chunk yielded after the detach state machine: the bytes to
110	/// forward to the session, and whether the detach keybind fired.
111	struct DetachParse {
112	    forward: Vec<u8>,
113	    detach: bool,
114	}
115
116	/// Apply the `ctrl-b` detach state machine to one raw stdin chunk. `armed` holds
117	/// the "saw the prefix" state across chunk boundaries (a prefix can end one read
118	/// and its companion key begin the next). `ctrl-b d` → detach; `ctrl-b ctrl-b` →
119	/// one literal `ctrl-b`; `ctrl-b <other>` → prefix + other verbatim; every other
120	/// byte passes through. Pure so it unit-tests without a terminal. On detach the
121	/// bytes BEFORE the keybind are returned to forward; bytes after are dropped (the
122	/// viewport is ending).
123	fn parse_stdin_chunk(armed: &mut bool, input: &[u8]) -> DetachParse {
124	    let mut forward = Vec::with_capacity(input.len());
125	    for &b in input {
126	        if *armed {
127	            *armed = false;
128	            match b {
129	                DETACH_KEY => return DetachParse { forward, detach: true },
130	                DETACH_PREFIX => forward.push(DETACH_PREFIX), // literal ctrl-b
131	                other => {
132	                    forward.push(DETACH_PREFIX);
133	                    forward.push(other);
134	                }
135	            }
136	        } else if b == DETACH_PREFIX {
137	            *armed = true;
138	        } else {
139	            forward.push(b);
140	        }
141	    }
142	    DetachParse {
143	        forward,
144	        detach: false,
145	    }
146	}
147
148	/// Spawn the stdin reader: raw bytes → `StdinMsg::Bytes`, watching for the
149	/// detach keybind ([`parse_stdin_chunk`]). Detaching sends `StdinMsg::Detach`
150	/// and ends the thread; the leaked blocking `read` (if any) dies with the
151	/// process on exit.
152	fn spawn_stdin_reader(tx: mpsc::Sender<StdinMsg>) {
153	    std::thread::spawn(move || {
154	        let mut stdin = std::io::stdin().lock();
155	        let mut buf = [0u8; 1024];
156	        let mut armed = false; // saw the detach prefix, awaiting the next byte
157	        loop {
158	            let n = match stdin.read(&mut buf) {
159	                Ok(0) => break, // stdin closed
160	                Ok(n) => n,
161	                Err(_) => break,
162	            };
163	            let parsed = parse_stdin_chunk(&mut armed, &buf[..n]);
164	            if !parsed.forward.is_empty() && tx.send(StdinMsg::Bytes(parsed.forward)).is_err() {
165	                break;
166	            }
167	            if parsed.detach {
168	                let _ = tx.send(StdinMsg::Detach);
169	                return;
170	            }
171	        }
172	    });
173	}
174
175	/// Attach a local terminal to the broker-held session for `endpoint_id` under
176	/// `intent` (REQ-RCVIEW-1 / REQ-KICK-1): `Control` drives a free endpoint (and is
177	/// busy-refused with guidance on a controlled one), `Viewer` watches read-only,
178	/// `Take` kicks the incumbent controller (loud `Displaced` notice to them) and
179	/// takes control. Returns when the session exits, the operator detaches, or an
180	/// error occurs.
181	// [impl->REQ-RC-1] [impl->REQ-RCVIEW-1] [impl->REQ-KICK-1]
182	pub fn run_attach(endpoint_id: &str, intent: AttachIntent) -> Result<(), String> {
183	    let view = intent == AttachIntent::Viewer;
184	    // Busy-refuse rendered CLIENT-SIDE (REQ-RCVIEW-1): a plain `Control` attach to
185	    // an endpoint a DIFFERENT node already controls gets actionable guidance here
186	    // (the broker would also refuse on the wire — this is the friendly path). A
187	    // `--view` / `--take` bypasses it (watching coexists; taking displaces).
188	    if intent == AttachIntent::Control {
189	        if let Some(node) = current_driver(endpoint_id) {
190	            println!("'{endpoint_id}' is controlled by {node}.");
191	            println!("  spt rc {endpoint_id} --view   to watch read-only");
192	            println!("  spt rc {endpoint_id} --take   to take control (kicks {node})");
193	            return Ok(());
194	        }
195	    }
196	    // (a) STATUS-GATE (REQ-HAZARD-RC-ATTACH-FAILFAST): a perch with an EXPLICIT
197	    // `status=offline` is not attachable — short-circuit with clean guidance
198	    // [impl->REQ-HAZARD-RC-ATTACH-FAILFAST]
199	    // instead of the broker dance that would block on a dead session (the
200	    // infinite-blank hang). The B2 pull-reconcile / `endpoint stop` stamp a dead
201	    // spt-hosted perch offline, so this catches the common case before any IPC. An
202	    // ABSENT or status-less perch falls through to the broker path, which already
203	    // ends cleanly ("no live session for endpoint") — not a hang. (is_perch_alive
204	    // is NOT used here: it returns true for an absent info.json by interim parity.)
205	    let perch_path =
206	        spt_store::perch::resolve_perch_path(endpoint_id, spt_store::perch::ParentHint::Infer);
207	    if spt_store::info::read_info(&perch_path)
208	        .and_then(|i| i.status)
209	        .as_deref()
210	        == Some(spt_store::liveness::STATUS_OFFLINE)
211	    {
212	        println!("Endpoint '{endpoint_id}' is offline — nothing to attach to.");
213	        println!("  spt endpoint run --adapter <adapter> --id {endpoint_id}   to start it");
214	        return Ok(());
215	    }
216	    spt_daemon::daemon::ensure_running().map_err(|e| format!("daemon start: {e}"))?;
217	    // PUMP mode: the Split reader-thread carrier bounds every read by a deadline
218	    // (a `Whole` conn can't time out — nonblocking is dead on Windows named
219	    // pipes), so the single-threaded pump interleaves stdin + broker events
220	    // without wedging. The generous io_timeout bounds the setup calls (dial,
221	    // sessions, attach-open ack); the pump itself passes its own short slice.
222	    let mut brain = Brain::cold_start_pump(
223	        &spt_daemon::endpoint::broker_socket_name(),
224	        now_ms(),
225	        Duration::from_secs(10),
226	    )
227	    .map_err(|e| format!("broker connect: {e}"))?;
228
229	    let session_id = resolve_session(&mut brain, endpoint_id)
230	        .map_err(|e| format!("list sessions: {e}"))?
231	        .ok_or_else(|| format!("no live session for endpoint '{endpoint_id}'"))?;
232
233	    let ops = OpMinter::new();
234	    // Transport: a broker-minted in-process LOOPBACK connection (M12 W1.5, B1).
235	    // A QUIC self-dial is refused by iroh ("Connecting to ourself is not
236	    // supported"), so local attach cannot ride a self-dialed conn; instead the
237	    // broker wires an in-process loopback conn whose streams surface as the SAME
238	    // net-stream events the cross-node QUIC path produces. From `request_attach`
239	    // down this is byte-identical to a cross-node attach — the one-pump invariant
240	    // lives at the transport leaf inside the broker, never here. The loopback
241	    // peer's origin is this node's own id, so the target's serve gates same-node.
242	    let dialed = brain
243	        .net_dial_loopback()
244	        .map_err(|e| format!("loopback dial: {e}"))?;
245	    let stream_id = request_attach(&mut brain, dialed.conn_id, session_id, 0, ops.next(), intent)
246	        .map_err(|e| format!("attach request: {e}"))?;
247	    // Subscribe our side of the stream so the target's `Output` records surface
248	    // as `NetStreamData` events (the open + Request do not auto-subscribe).
249	    brain
250	        .net_stream_subscribe(stream_id, 0)
251	        .map_err(|e| format!("stream subscribe: {e}"))?;
252
253	    let _raw = RawGuard::enable();
254	    let rx = if view {
255	        None
256	    } else {
257	        let (tx, rx) = mpsc::channel();
258	        spawn_stdin_reader(tx);
259	        Some(rx)
260	    };
261
262	    // Controller: send the INITIAL terminal size so the PTY matches from the
263	    // start (REQ-RCVIEW-1). Best-effort — a failure just leaves the default size.
264	    if !view {
265	        if let Ok((cols, rows)) = crossterm::terminal::size() {
266	            let _ = send_attach_resize(&mut brain, stream_id, rows, cols);
267	        }
268	    }
269
270	    let mut decoder = AttachDecoder::new();
271	    let mut stdout = std::io::stdout();
272	    let outcome = pump(
273	        &mut brain,
274	        stream_id,
275	        &ops,
276	        rx.as_ref(),
277	        &mut decoder,
278	        &mut stdout,
279	        !view, // controller drives resize
280	    );
281
282	    // Out of raw mode (guard drops after) before the parting line.
283	    drop(_raw);
284	    match outcome {
285	        Ok(PumpEnd::Exited(code)) => {
286	            let _ = writeln!(stdout, "\r\n[session '{endpoint_id}' ended ({code:?})]");
287	            Ok(())
288	        }
289	        Ok(PumpEnd::Detached) => {
290	            let _ = writeln!(stdout, "\r\n[detached — '{endpoint_id}' still running]");
291	            Ok(())
292	        }
293	        Ok(PumpEnd::Displaced(by)) => {
294	            // The loud kick notice (REQ-KICK-1): a different operator took control.
295	            let _ = writeln!(
296	                stdout,
297	                "\r\n[displaced — '{endpoint_id}' was taken over by {by}]"
298	            );
299	            Ok(())
300	        }
301	        Ok(PumpEnd::BrokerGone) => {
302	            // A severed broker stream is recoverable, not a crash (REQ-HAZARD-RC-EOF):
303	            // the broker is the daemon-lifetime anchor and comes back on the next
304	            // `spt api` call. Surface a CLEAR actionable line instead of the raw
305	            // "failed to fill whole buffer" that read as a PTY crash.
306	            let _ = writeln!(
307	                stdout,
308	                "\r\n[daemon connection dropped — the broker may have restarted \
309	                 (e.g. `spt daemon stop`); the session '{endpoint_id}' is unaffected, \
310	                 re-run `spt endpoint run`/attach to reconnect]"
311	            );
312	            Ok(())
313	        }
314	        Ok(PumpEnd::Stalled) => {
315	            // Path b backstop (REQ-HAZARD-RC-ATTACH-FAILFAST): the attach produced
316	            // no output at all — a dead/wedged session, not a slow one. A clear
317	            // message, never the historical infinite blank.
318	            let _ = writeln!(
319	                stdout,
320	                "\r\n[no output from '{endpoint_id}' — the session may be dead or \
321	                 wedged. Detached. Try `spt endpoint list` for its state, or \
322	                 `spt endpoint run` to restart it]"
323	            );
324	            Ok(())
325	        }
326	        Err(e) => Err(e),
327	    }
328	}
329
330	enum PumpEnd {
331	    Exited(Option<i32>),
332	    Detached,
333	    /// A different operator took control (REQ-KICK-1); carries the taker's node.
334	    Displaced(String),
335	    /// The broker stream was severed mid-session (broker bounce / hang-up — e.g. a
336	    /// `spt daemon stop`). Recoverable, not a crash: the broker is the
337	    /// daemon-lifetime anchor and returns on the next `spt api` call, so this
338	    /// surfaces a clear actionable message instead of a raw io error
339	    /// (REQ-HAZARD-RC-EOF).
340	    BrokerGone,
341	    /// The attach produced NO event whatsoever within the generous first-event
342	    /// backstop window (REQ-HAZARD-RC-ATTACH-FAILFAST path b): a healthy session —
343	    /// even one still painting / mid-init — replays its buffered output (or at
344	    /// least a Subscribed/Size frame) promptly, so a totally silent stream past
345	    /// this bound is a dead/wedged session, not a slow one. Surfaces a clear
346	    /// message instead of the historical infinite blank. The bound is deliberately
347	    /// LONG (only ever bites a pathological hang; the broker-EOF-on-dead-child path
348	    /// and the status-gate catch the ordinary dead cases first).
349	    Stalled,
350	}
351
352	/// Generous first-event backstop for the attach pump (REQ-HAZARD-RC-ATTACH-FAILFAST
353	/// path b): if NO broker event arrives within this window after attach, the
354	/// session is treated as dead/wedged. Long on purpose — a fresh, mid-init alive
355	/// session (empty OutputLog, still painting) MUST pass, so this only catches a
356	/// true hang, never the working fresh-attach.
357	const FIRST_EVENT_GRACE: Duration = Duration::from_secs(30);
358
359	/// The path-b stall decision (REQ-HAZARD-RC-ATTACH-FAILFAST): the attach has
360	/// stalled iff NO broker event has ever arrived AND the generous grace has
361	/// elapsed. Pulled out so the decision is unit-testable without a 30s pump run.
362	// [impl->REQ-HAZARD-RC-ATTACH-FAILFAST]
363	fn first_event_stalled(seen_any: bool, elapsed: Duration) -> bool {
364	    !seen_any && elapsed >= FIRST_EVENT_GRACE
365	}
366
367	/// How the rc pump treats a `read_event_until` error (REQ-HAZARD-RC-EOF).
368	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
369	enum ReadDisposition {
370	    /// Transient poll-slice timeout — loop and poll again.
371	    Retry,
372	    /// Severed broker stream (broker bounced / hung up) — recoverable, surface
373	    /// gracefully.
374	    BrokerGone,
375	    /// A genuine read failure — propagate.
376	    Fatal,
377	}
378
379	/// Classify a `read_event_until` error kind. A poll-slice timeout retries; a
380	/// severed-broker-stream EOF (the broker bounced — e.g. `spt daemon stop` —
381	/// surfacing as `UnexpectedEof` "failed to fill whole buffer", or a
382	/// reset/abort/broken-pipe in the same class) is RECOVERABLE and surfaces
383	/// gracefully rather than crashing the PTY with the raw io text (the same EOF
384	/// family the v0.9.1 seed fix handles); anything else is fatal.
385	// [impl->REQ-HAZARD-RC-EOF]
386	fn classify_read_err(kind: std::io::ErrorKind) -> ReadDisposition {
387	    use std::io::ErrorKind::*;
388	    match kind {
389	        WouldBlock | TimedOut => ReadDisposition::Retry,
390	        UnexpectedEof | BrokenPipe | ConnectionReset | ConnectionAborted => {
391	            ReadDisposition::BrokerGone
392	        }
393	        _ => ReadDisposition::Fatal,
394	    }
395	}
396
397	/// The full-duplex pump: drain stdin → `send_attach_input`; render inbound
398	/// `AttachRecord::Output` → stdout; end on `Exit`, EOF, or detach.
399	#[allow(clippy::too_many_arguments)]
400	fn pump(
401	    brain: &mut Brain,
402	    stream_id: u64,
403	    ops: &OpMinter,
404	    rx: Option<&mpsc::Receiver<StdinMsg>>,
405	    decoder: &mut AttachDecoder,
406	    stdout: &mut std::io::Stdout,
407	    controller: bool,
408	) -> Result<PumpEnd, String> {
409	    // Finish our send side so the target's `serve_attach` sees EOF → Detached
410	    // (the session outlives the viewport). Best-effort: an already-finished or
411	    // exited stream send is harmless to attempt.
412	    fn detach(brain: &mut Brain, stream_id: u64) -> PumpEnd {
413	        let _ = brain.net_stream_send(stream_id, &[], None, true);
414	        PumpEnd::Detached
415	    }
416	    // Dedup cursor: a post-restart re-serve may re-transmit `Output` records;
417	    // drop any `seq` already rendered (the operator render cursor, matching the
418	    // local `OutputLog` replay discipline).
419	    let mut cursor = 0u64;
420	    // Controller window-change tracking (REQ-RCVIEW-1): poll the terminal size
421	    // each slice and forward a Resize when it changes (cross-platform without
422	    // hooking SIGWINCH / the crossterm event loop, which would conflict with the
423	    // raw-stdin reader). `None` until the first read.
424	    let mut last_size: Option<(u16, u16)> = None;
425	    // The PTY size last advertised to us as a VIEWER (the letterbox feed); shown
426	    // as a one-line indicator when it differs from our own terminal.
427	    let mut pty_size: Option<(u16, u16)> = None;
428	    // First-event backstop (REQ-HAZARD-RC-ATTACH-FAILFAST path b): a dead/wedged
429	    // attach produces nothing — bail with a clear message past FIRST_EVENT_GRACE
430	    // rather than an infinite blank. Any broker event clears it.
431	    let started = Instant::now();
432	    let mut seen_any = false;
433	    loop {
434	        // ── controller window-change → resize ──────────────────────────────
435	        if controller {
436	            if let Ok((cols, rows)) = crossterm::terminal::size() {
437	                if last_size != Some((rows, cols)) {
438	                    last_size = Some((rows, cols));
439	                    let _ = send_attach_resize(brain, stream_id, rows, cols);
440	                }
441	            }
442	        }
443
444	        // ── stdin → session ────────────────────────────────────────────────
445	        if let Some(rx) = rx {
446	            loop {
447	                match rx.try_recv() {
448	                    Ok(StdinMsg::Bytes(bytes)) => {
449	                        send_attach_input(brain, stream_id, &bytes, ops.next())
450	                            .map_err(|e| format!("send input: {e}"))?;
451	                    }
452	                    Ok(StdinMsg::Detach) => return Ok(detach(brain, stream_id)),
453	                    Err(mpsc::TryRecvError::Empty) => break,
454	                    Err(mpsc::TryRecvError::Disconnected) => {
455	                        return Ok(detach(brain, stream_id))
456	                    }
457	                }
458	            }
459	        }
460
461	        // ── session → terminal ─────────────────────────────────────────────
462	        let ev = match brain.read_event_until(Some(Instant::now() + POLL_SLICE)) {
463	            Ok(ev) => ev,
464	            Err(e) => match classify_read_err(e.kind()) {
465	                // A poll-slice timeout: no event this slice. Before looping, the
466	                // first-event backstop — if NOTHING has ever arrived past the
467	                // generous grace, the attach is dead/wedged (path b).
468	                ReadDisposition::Retry => {
469	                    if first_event_stalled(seen_any, started.elapsed()) {
470	                        return Ok(PumpEnd::Stalled);
471	                    }
472	                    continue;
473	                }
474	                // A severed broker stream (broker bounce / hang-up) — surface it
475	                // gracefully, never as a raw RC_FAIL crash (REQ-HAZARD-RC-EOF).
476	                ReadDisposition::BrokerGone => return Ok(PumpEnd::BrokerGone),
477	                ReadDisposition::Fatal => return Err(format!("read event: {e}")),
478	            },
479	        };
480	        seen_any = true; // any broker event proves the attach stream is live
481	        match ev {
482	            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
483	                for rec in decoder.push(&bytes) {
484	                    match rec {
485	                        AttachRecord::Output { seq, data_b64 } => {
486	                            if seq < cursor {
487	                                continue; // already rendered — re-serve dedup
488	                            }
489	                            cursor = seq + 1;
490	                            let bytes = decode_bytes(&data_b64)
491	                                .map_err(|e| format!("decode output: {e}"))?;
492	                            stdout.write_all(&bytes).map_err(|e| format!("write: {e}"))?;
493	                            stdout.flush().map_err(|e| format!("flush: {e}"))?;
494	                        }
495	                        AttachRecord::Exit { code } => return Ok(PumpEnd::Exited(code)),
496	                        AttachRecord::Displaced { by } => {
497	                            return Ok(PumpEnd::Displaced(by));
498	                        }
499	                        // Viewer letterbox feed (REQ-RCVIEW-1): on a size change,
500	                        // show a one-line indicator when our terminal differs from
501	                        // the new PTY size (rc is a byte pump — true clip/pad needs
502	                        // a grid model, deferred; the honest size cue renders here).
503	                        AttachRecord::Size { rows, cols } if pty_size != Some((rows, cols)) => {
504	                            pty_size = Some((rows, cols));
505	                            match crossterm::terminal::size() {
506	                                Ok((mycols, myrows)) if (myrows, mycols) != (rows, cols) => {
507	                                    let _ = write!(
508	                                        stdout,
509	                                        "\r\n[viewing {cols}x{rows} — your terminal is {mycols}x{myrows}]\r\n"
510	                                    );
511	                                    let _ = stdout.flush();
512	                                }
513	                                _ => {}
514	                            }
515	                        }
516	                        _ => {} // Request/Input echoed back: protocol noise, ignore.
517	                    }
518	                }
519	            }
520	            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
521	                return Ok(PumpEnd::Detached);
522	            }
523	            BrokerEvent::Error { message } => return Err(message),
524	            _ => {} // acks + unrelated events: not ours.
525	        }
526	    }
527	}
528
529	#[cfg(test)]
530	mod tests {
531	    use super::*;
532
533	    fn parse(armed: &mut bool, input: &[u8]) -> (Vec<u8>, bool) {
534	        let p = parse_stdin_chunk(armed, input);
535	        (p.forward, p.detach)
536	    }
537
538	    // [unit->REQ-HAZARD-RC-ATTACH-FAILFAST] path (a): attaching to a perch stamped
539	    // status=offline (the B2-reconcile / endpoint-stop state) SHORT-CIRCUITS with
540	    // clean guidance — it returns Ok WITHOUT any broker dance (no daemon needed),
541	    // so it can never hang on a dead session.
542	    #[test]
543	    fn attach_offline_endpoint_short_circuits_clean() {
544	        let _home = crate::testutil::isolated_home();
545	        let id = "stopped-ep";
546	        let perch = spt_store::perch::resolve_perch_path(id, spt_store::perch::ParentHint::Infer);
547	        std::fs::create_dir_all(&perch).unwrap();
548	        let rec = spt_store::info::InfoJson::new(id, "t", std::process::id(), "sid", "live_agent");
549	        spt_store::info::write_info(&perch, &rec).unwrap();
550	        spt_store::info::set_status(&perch, spt_store::liveness::STATUS_OFFLINE).unwrap();
551
552	        // Offline status → the gate returns early (no broker), both intents.
553	        assert!(run_attach(id, AttachIntent::Control).is_ok());
554	        assert!(run_attach(id, AttachIntent::Viewer).is_ok());
555	    }
556
557	    // [unit->REQ-HAZARD-RC-ATTACH-FAILFAST] path (b): the generous first-event
558	    // backstop fires ONLY when NOTHING has arrived past the grace — never for a
559	    // session that produced an event (a fresh/mid-init alive attach is safe), and
560	    // never before the grace elapses.
561	    #[test]
562	    fn first_event_stall_decision() {
563	        // No event + past the generous grace → stalled.
564	        assert!(first_event_stalled(false, FIRST_EVENT_GRACE));
565	        assert!(first_event_stalled(false, FIRST_EVENT_GRACE + Duration::from_secs(1)));
566	        // Any event seen → never stalled (the working/mid-init attach guard).
567	        assert!(!first_event_stalled(true, FIRST_EVENT_GRACE + Duration::from_secs(60)));
568	        // Before the grace → not yet (a slow-painting session still has time).
569	        assert!(!first_event_stalled(false, Duration::from_secs(1)));
570	        assert!(!first_event_stalled(false, FIRST_EVENT_GRACE - Duration::from_millis(1)));
571	    }
572
573	    // [unit->REQ-HAZARD-RC-EOF] a severed-broker-stream read error (UnexpectedEof
574	    // "failed to fill whole buffer", and the reset/abort/broken-pipe class) is
575	    // classified BrokerGone → graceful surface, NOT a fatal RC_FAIL crash; a
576	    // poll-slice timeout retries; any other error stays fatal.
577	    #[test]
578	    fn classify_read_err_eof_is_graceful_not_fatal() {
579	        use std::io::ErrorKind::*;
580	        // The exact kind behind "failed to fill whole buffer".
581	        assert_eq!(classify_read_err(UnexpectedEof), ReadDisposition::BrokerGone);
582	        // Same severed-stream class.
583	        assert_eq!(classify_read_err(BrokenPipe), ReadDisposition::BrokerGone);
584	        assert_eq!(classify_read_err(ConnectionReset), ReadDisposition::BrokerGone);
585	        assert_eq!(classify_read_err(ConnectionAborted), ReadDisposition::BrokerGone);
586	        // Poll-slice timeouts retry (no event this slice).
587	        assert_eq!(classify_read_err(WouldBlock), ReadDisposition::Retry);
588	        assert_eq!(classify_read_err(TimedOut), ReadDisposition::Retry);
589	        // A genuine unexpected failure still propagates.
590	        assert_eq!(classify_read_err(InvalidData), ReadDisposition::Fatal);
591	        assert_eq!(classify_read_err(PermissionDenied), ReadDisposition::Fatal);
592	    }
593
594	    // [unit->REQ-RC-1] the detach keybind state machine: `ctrl-b d` detaches
595	    // (the prefix is NOT forwarded); `ctrl-b ctrl-b` forwards one literal
596	    // ctrl-b (so the session can still receive it); `ctrl-b <other>` forwards
597	    // prefix + other verbatim; plain input passes through untouched.
598	    #[test]
599	    fn detach_keybind_semantics() {
600	        let mut armed = false;
601	        // plain passthrough
602	        assert_eq!(parse(&mut armed, b"hello"), (b"hello".to_vec(), false));
603	        assert!(!armed);
604	        // ctrl-b d => detach, bytes-before forwarded, prefix consumed
605	        assert_eq!(
606	            parse(&mut armed, &[b'h', DETACH_PREFIX, DETACH_KEY]),
607	            (b"h".to_vec(), true)
608	        );
609	        // ctrl-b ctrl-b => one literal ctrl-b, no detach
610	        let mut a2 = false;
611	        assert_eq!(
612	            parse(&mut a2, &[DETACH_PREFIX, DETACH_PREFIX]),
613	            (vec![DETACH_PREFIX], false)
614	        );
615	        // ctrl-b <other> => prefix + other verbatim
616	        let mut a3 = false;
617	        assert_eq!(
618	            parse(&mut a3, &[DETACH_PREFIX, b'x']),
619	            (vec![DETACH_PREFIX, b'x'], false)
620	        );
621	    }
622
623	    // [unit->REQ-RC-1] the prefix may straddle a read boundary: a chunk ending
624	    // in ctrl-b arms across chunks, and the next chunk's `d` detaches — so the
625	    // keybind works no matter how raw reads fragment it.
626	    #[test]
627	    fn detach_prefix_spans_chunks() {
628	        let mut armed = false;
629	        // chunk 1 ends on the bare prefix: nothing forwarded yet, armed carries.
630	        assert_eq!(parse(&mut armed, &[b'a', DETACH_PREFIX]), (b"a".to_vec(), false));
631	        assert!(armed, "prefix armed across the chunk boundary");
632	        // chunk 2 starts with the detach key: detach fires.
633	        assert_eq!(parse(&mut armed, &[DETACH_KEY]), (Vec::new(), true));
634	    }
635
636	    // [unit->REQ-RC-1] operator op ids are non-colliding across sessions: two
637	    // minters seed from distinct random bases (so a journaled send_attach_input
638	    // op from a prior rc session is never deduped as a replay), and each minter
639	    // is strictly monotonic within a session.
640	    #[test]
641	    fn op_minter_distinct_and_monotonic() {
642	        let m = OpMinter::new();
643	        let (a, b, c) = (m.next(), m.next(), m.next());
644	        assert!(b > a && c > b, "monotonic within a session");
645	        // A second minter's base differs from the first with overwhelming
646	        // probability (random 63-bit base) — assert the bases don't coincide.
647	        let m2 = OpMinter::new();
648	        assert_ne!(m.next(), m2.next(), "independent session bases");
649	    }
650	}
651

//! `spt rc <id>` — the operator-side attach pump (M12-W1, REQ-RC-1): attach a
//! local terminal to a broker-held PTY session.
//!
//! **One pump, swappable transport** (the doyle invariant). The attach protocol
//! is `serve_attach` on the target side ⇄ this pump on the operator side, both
//! speaking `AttachRecord` over a broker net-stream. *Local* attach is the
//! degenerate single-node case: the operator dials its OWN node (loopback peer)
//! and the broker's inbound-stream dispatch (`dispatch.rs`) runs `serve_attach`
//! against the same broker — the exact cross-node accept+serve path, no local
//! special-case branch. The one-path invariant lives at this pump, not the
//! transport under the stream.
//!
//! **Detach never kills the session.** The broker owns the PTY; closing our send
//! side ends the viewport with the session still running (`serve_attach` →
//! `Detached`). PTY ownership stays with the broker (the KNOWN-HAZARDS
//! attach-lifecycle invariant).
//!
//! **Single-Brain pump.** A `Brain` is `&mut`-single-threaded, so the main thread
//! owns it: a stdin reader thread only *produces* raw bytes onto an mpsc channel
//! (and watches for the detach keybind); the main loop interleaves draining that
//! channel into `send_attach_input` with `read_event_until` for inbound `Output`.
// [impl->REQ-RC-1]

use std::io::{Read, Write};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc;
use std::time::{Duration, Instant};

use spt_daemon::attach::{request_attach, send_attach_input, send_attach_resize};
use spt_daemon::brain::{now_ms, Brain, BrokerEvent};
use spt_daemon::msg::decode_bytes;
use spt_net::net::attach::{AttachDecoder, AttachIntent, AttachRecord};

/// The detach prefix: ctrl-b (0x02), matching the legacy capsule's prefix so the
/// muscle memory carries over. `ctrl-b d` detaches; `ctrl-b ctrl-b` sends one
/// literal ctrl-b to the session; any other byte after the prefix is sent
/// through verbatim (prefix + byte). Documented in the CLI help.
const DETACH_PREFIX: u8 = 0x02; // ctrl-b
const DETACH_KEY: u8 = b'd';

/// How long the main loop blocks for an inbound event before looping back to
/// drain stdin — the full-duplex interleave granularity.
const POLL_SLICE: Duration = Duration::from_millis(40);

/// A message from the stdin reader thread to the pump.
enum StdinMsg {
    /// Raw bytes to forward to the session as `AttachRecord::Input`.
    Bytes(Vec<u8>),
    /// The operator hit the detach keybind — end the viewport, leave the session.
    Detach,
}

/// Mint operator op ids from a random 64-bit base so two `spt rc` sessions never
/// collide in the broker's net-op journal (a journaled `send_attach_input` op is
/// exactly-once at the PTY — a reused id from a prior session would be deduped,
/// dropping the keystroke). Random base + monotonic counter.
struct OpMinter(AtomicU64);
impl OpMinter {
    fn new() -> Self {
        let seed = spt_proto::identity::Identity::generate().seed();
        let base = u64::from_le_bytes(seed[..8].try_into().expect("8 bytes"));
        // Keep it well below u64::MAX so the counter can't wrap into another
        // session's base within any realistic session length.
        OpMinter(AtomicU64::new(base >> 1))
    }
    fn next(&self) -> u64 {
        self.0.fetch_add(1, Ordering::Relaxed)
    }
}

/// Restore the terminal out of raw mode no matter how the pump returns.
struct RawGuard(bool);
impl RawGuard {
    fn enable() -> Self {
        RawGuard(crossterm::terminal::enable_raw_mode().is_ok())
    }
}
impl Drop for RawGuard {
    fn drop(&mut self) {
        if self.0 {
            let _ = crossterm::terminal::disable_raw_mode();
        }
    }
}

/// Resolve a live endpoint id to its broker session id via the broker's hosted
/// session table (the spawn's endpoint label, brain.sessions()). `None` ⇒ no
/// live session under that endpoint.
fn resolve_session(brain: &mut Brain, endpoint_id: &str) -> std::io::Result<Option<u64>> {
    let reply = brain.sessions()?;
    Ok(reply
        .sessions
        .into_iter()
        .find(|s| s.endpoint == endpoint_id)
        .map(|s| s.session_id))
}

/// The node currently driving `endpoint_id`, if any — read off the perch's
/// broker-owned `driven_by` marker (REQ-RCVIEW-1). Drives the client-side
/// busy-refuse guidance. `None` ⇒ free (or no perch).
fn current_driver(endpoint_id: &str) -> Option<String> {
    let perch = spt_store::perch::resolve_perch_path(
        endpoint_id,
        spt_store::perch::ParentHint::Infer,
    );
    spt_store::info::read_info(&perch).and_then(|i| i.driven_by)
}

/// What one stdin chunk yielded after the detach state machine: the bytes to
/// forward to the session, and whether the detach keybind fired.
struct DetachParse {
    forward: Vec<u8>,
    detach: bool,
}

/// Apply the `ctrl-b` detach state machine to one raw stdin chunk. `armed` holds
/// the "saw the prefix" state across chunk boundaries (a prefix can end one read
/// and its companion key begin the next). `ctrl-b d` → detach; `ctrl-b ctrl-b` →
/// one literal `ctrl-b`; `ctrl-b <other>` → prefix + other verbatim; every other
/// byte passes through. Pure so it unit-tests without a terminal. On detach the
/// bytes BEFORE the keybind are returned to forward; bytes after are dropped (the
/// viewport is ending).
fn parse_stdin_chunk(armed: &mut bool, input: &[u8]) -> DetachParse {
    let mut forward = Vec::with_capacity(input.len());
    for &b in input {
        if *armed {
            *armed = false;
            match b {
                DETACH_KEY => return DetachParse { forward, detach: true },
                DETACH_PREFIX => forward.push(DETACH_PREFIX), // literal ctrl-b
                other => {
                    forward.push(DETACH_PREFIX);
                    forward.push(other);
                }
            }
        } else if b == DETACH_PREFIX {
            *armed = true;
        } else {
            forward.push(b);
        }
    }
    DetachParse {
        forward,
        detach: false,
    }
}

/// Spawn the stdin reader: raw bytes → `StdinMsg::Bytes`, watching for the
/// detach keybind ([`parse_stdin_chunk`]). Detaching sends `StdinMsg::Detach`
/// and ends the thread; the leaked blocking `read` (if any) dies with the
/// process on exit.
fn spawn_stdin_reader(tx: mpsc::Sender<StdinMsg>) {
    std::thread::spawn(move || {
        let mut stdin = std::io::stdin().lock();
        let mut buf = [0u8; 1024];
        let mut armed = false; // saw the detach prefix, awaiting the next byte
        loop {
            let n = match stdin.read(&mut buf) {
                Ok(0) => break, // stdin closed
                Ok(n) => n,
                Err(_) => break,
            };
            let parsed = parse_stdin_chunk(&mut armed, &buf[..n]);
            if !parsed.forward.is_empty() && tx.send(StdinMsg::Bytes(parsed.forward)).is_err() {
                break;
            }
            if parsed.detach {
                let _ = tx.send(StdinMsg::Detach);
                return;
            }
        }
    });
}

/// Attach a local terminal to the broker-held session for `endpoint_id` under
/// `intent` (REQ-RCVIEW-1 / REQ-KICK-1): `Control` drives a free endpoint (and is
/// busy-refused with guidance on a controlled one), `Viewer` watches read-only,
/// `Take` kicks the incumbent controller (loud `Displaced` notice to them) and
/// takes control. Returns when the session exits, the operator detaches, or an
/// error occurs.
// [impl->REQ-RC-1] [impl->REQ-RCVIEW-1] [impl->REQ-KICK-1]
pub fn run_attach(endpoint_id: &str, intent: AttachIntent) -> Result<(), String> {
    let view = intent == AttachIntent::Viewer;
    // Busy-refuse rendered CLIENT-SIDE (REQ-RCVIEW-1): a plain `Control` attach to
    // an endpoint a DIFFERENT node already controls gets actionable guidance here
    // (the broker would also refuse on the wire — this is the friendly path). A
    // `--view` / `--take` bypasses it (watching coexists; taking displaces).
    if intent == AttachIntent::Control {
        if let Some(node) = current_driver(endpoint_id) {
            println!("'{endpoint_id}' is controlled by {node}.");
            println!("  spt rc {endpoint_id} --view   to watch read-only");
            println!("  spt rc {endpoint_id} --take   to take control (kicks {node})");
            return Ok(());
        }
    }
    // (a) STATUS-GATE (REQ-HAZARD-RC-ATTACH-FAILFAST): a perch with an EXPLICIT
    // `status=offline` is not attachable — short-circuit with clean guidance
    // [impl->REQ-HAZARD-RC-ATTACH-FAILFAST]
    // instead of the broker dance that would block on a dead session (the
    // infinite-blank hang). The B2 pull-reconcile / `endpoint stop` stamp a dead
    // spt-hosted perch offline, so this catches the common case before any IPC. An
    // ABSENT or status-less perch falls through to the broker path, which already
    // ends cleanly ("no live session for endpoint") — not a hang. (is_perch_alive
    // is NOT used here: it returns true for an absent info.json by interim parity.)
    let perch_path =
        spt_store::perch::resolve_perch_path(endpoint_id, spt_store::perch::ParentHint::Infer);
    if spt_store::info::read_info(&perch_path)
        .and_then(|i| i.status)
        .as_deref()
        == Some(spt_store::liveness::STATUS_OFFLINE)
    {
        println!("Endpoint '{endpoint_id}' is offline — nothing to attach to.");
        println!("  spt endpoint run --adapter <adapter> --id {endpoint_id}   to start it");
        return Ok(());
    }
    spt_daemon::daemon::ensure_running().map_err(|e| format!("daemon start: {e}"))?;
    // PUMP mode: the Split reader-thread carrier bounds every read by a deadline
    // (a `Whole` conn can't time out — nonblocking is dead on Windows named
    // pipes), so the single-threaded pump interleaves stdin + broker events
    // without wedging. The generous io_timeout bounds the setup calls (dial,
    // sessions, attach-open ack); the pump itself passes its own short slice.
    let mut brain = Brain::cold_start_pump(
        &spt_daemon::endpoint::broker_socket_name(),
        now_ms(),
        Duration::from_secs(10),
    )
    .map_err(|e| format!("broker connect: {e}"))?;

    let session_id = resolve_session(&mut brain, endpoint_id)
        .map_err(|e| format!("list sessions: {e}"))?
        .ok_or_else(|| format!("no live session for endpoint '{endpoint_id}'"))?;

    let ops = OpMinter::new();
    // Transport: a broker-minted in-process LOOPBACK connection (M12 W1.5, B1).
    // A QUIC self-dial is refused by iroh ("Connecting to ourself is not
    // supported"), so local attach cannot ride a self-dialed conn; instead the
    // broker wires an in-process loopback conn whose streams surface as the SAME
    // net-stream events the cross-node QUIC path produces. From `request_attach`
    // down this is byte-identical to a cross-node attach — the one-pump invariant
    // lives at the transport leaf inside the broker, never here. The loopback
    // peer's origin is this node's own id, so the target's serve gates same-node.
    let dialed = brain
        .net_dial_loopback()
        .map_err(|e| format!("loopback dial: {e}"))?;
    let stream_id = request_attach(&mut brain, dialed.conn_id, session_id, 0, ops.next(), intent)
        .map_err(|e| format!("attach request: {e}"))?;
    // Subscribe our side of the stream so the target's `Output` records surface
    // as `NetStreamData` events (the open + Request do not auto-subscribe).
    brain
        .net_stream_subscribe(stream_id, 0)
        .map_err(|e| format!("stream subscribe: {e}"))?;

    let _raw = RawGuard::enable();
    let rx = if view {
        None
    } else {
        let (tx, rx) = mpsc::channel();
        spawn_stdin_reader(tx);
        Some(rx)
    };

    // Controller: send the INITIAL terminal size so the PTY matches from the
    // start (REQ-RCVIEW-1). Best-effort — a failure just leaves the default size.
    if !view {
        if let Ok((cols, rows)) = crossterm::terminal::size() {
            let _ = send_attach_resize(&mut brain, stream_id, rows, cols);
        }
    }

    let mut decoder = AttachDecoder::new();
    let mut stdout = std::io::stdout();
    let outcome = pump(
        &mut brain,
        stream_id,
        &ops,
        rx.as_ref(),
        &mut decoder,
        &mut stdout,
        !view, // controller drives resize
    );

    // Out of raw mode (guard drops after) before the parting line.
    drop(_raw);
    match outcome {
        Ok(PumpEnd::Exited(code)) => {
            let _ = writeln!(stdout, "\r\n[session '{endpoint_id}' ended ({code:?})]");
            Ok(())
        }
        Ok(PumpEnd::Detached) => {
            let _ = writeln!(stdout, "\r\n[detached — '{endpoint_id}' still running]");
            Ok(())
        }
        Ok(PumpEnd::Displaced(by)) => {
            // The loud kick notice (REQ-KICK-1): a different operator took control.
            let _ = writeln!(
                stdout,
                "\r\n[displaced — '{endpoint_id}' was taken over by {by}]"
            );
            Ok(())
        }
        Ok(PumpEnd::BrokerGone) => {
            // A severed broker stream is recoverable, not a crash (REQ-HAZARD-RC-EOF):
            // the broker is the daemon-lifetime anchor and comes back on the next
            // `spt api` call. Surface a CLEAR actionable line instead of the raw
            // "failed to fill whole buffer" that read as a PTY crash.
            let _ = writeln!(
                stdout,
                "\r\n[daemon connection dropped — the broker may have restarted \
                 (e.g. `spt daemon stop`); the session '{endpoint_id}' is unaffected, \
                 re-run `spt endpoint run`/attach to reconnect]"
            );
            Ok(())
        }
        Ok(PumpEnd::Stalled) => {
            // Path b backstop (REQ-HAZARD-RC-ATTACH-FAILFAST): the attach produced
            // no output at all — a dead/wedged session, not a slow one. A clear
            // message, never the historical infinite blank.
            let _ = writeln!(
                stdout,
                "\r\n[no output from '{endpoint_id}' — the session may be dead or \
                 wedged. Detached. Try `spt endpoint list` for its state, or \
                 `spt endpoint run` to restart it]"
            );
            Ok(())
        }
        Err(e) => Err(e),
    }
}

enum PumpEnd {
    Exited(Option<i32>),
    Detached,
    /// A different operator took control (REQ-KICK-1); carries the taker's node.
    Displaced(String),
    /// The broker stream was severed mid-session (broker bounce / hang-up — e.g. a
    /// `spt daemon stop`). Recoverable, not a crash: the broker is the
    /// daemon-lifetime anchor and returns on the next `spt api` call, so this
    /// surfaces a clear actionable message instead of a raw io error
    /// (REQ-HAZARD-RC-EOF).
    BrokerGone,
    /// The attach produced NO event whatsoever within the generous first-event
    /// backstop window (REQ-HAZARD-RC-ATTACH-FAILFAST path b): a healthy session —
    /// even one still painting / mid-init — replays its buffered output (or at
    /// least a Subscribed/Size frame) promptly, so a totally silent stream past
    /// this bound is a dead/wedged session, not a slow one. Surfaces a clear
    /// message instead of the historical infinite blank. The bound is deliberately
    /// LONG (only ever bites a pathological hang; the broker-EOF-on-dead-child path
    /// and the status-gate catch the ordinary dead cases first).
    Stalled,
}

/// Generous first-event backstop for the attach pump (REQ-HAZARD-RC-ATTACH-FAILFAST
/// path b): if NO broker event arrives within this window after attach, the
/// session is treated as dead/wedged. Long on purpose — a fresh, mid-init alive
/// session (empty OutputLog, still painting) MUST pass, so this only catches a
/// true hang, never the working fresh-attach.
const FIRST_EVENT_GRACE: Duration = Duration::from_secs(30);

/// The path-b stall decision (REQ-HAZARD-RC-ATTACH-FAILFAST): the attach has
/// stalled iff NO broker event has ever arrived AND the generous grace has
/// elapsed. Pulled out so the decision is unit-testable without a 30s pump run.
// [impl->REQ-HAZARD-RC-ATTACH-FAILFAST]
fn first_event_stalled(seen_any: bool, elapsed: Duration) -> bool {
    !seen_any && elapsed >= FIRST_EVENT_GRACE
}

/// How the rc pump treats a `read_event_until` error (REQ-HAZARD-RC-EOF).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReadDisposition {
    /// Transient poll-slice timeout — loop and poll again.
    Retry,
    /// Severed broker stream (broker bounced / hung up) — recoverable, surface
    /// gracefully.
    BrokerGone,
    /// A genuine read failure — propagate.
    Fatal,
}

/// Classify a `read_event_until` error kind. A poll-slice timeout retries; a
/// severed-broker-stream EOF (the broker bounced — e.g. `spt daemon stop` —
/// surfacing as `UnexpectedEof` "failed to fill whole buffer", or a
/// reset/abort/broken-pipe in the same class) is RECOVERABLE and surfaces
/// gracefully rather than crashing the PTY with the raw io text (the same EOF
/// family the v0.9.1 seed fix handles); anything else is fatal.
// [impl->REQ-HAZARD-RC-EOF]
fn classify_read_err(kind: std::io::ErrorKind) -> ReadDisposition {
    use std::io::ErrorKind::*;
    match kind {
        WouldBlock | TimedOut => ReadDisposition::Retry,
        UnexpectedEof | BrokenPipe | ConnectionReset | ConnectionAborted => {
            ReadDisposition::BrokerGone
        }
        _ => ReadDisposition::Fatal,
    }
}

/// The full-duplex pump: drain stdin → `send_attach_input`; render inbound
/// `AttachRecord::Output` → stdout; end on `Exit`, EOF, or detach.
#[allow(clippy::too_many_arguments)]
fn pump(
    brain: &mut Brain,
    stream_id: u64,
    ops: &OpMinter,
    rx: Option<&mpsc::Receiver<StdinMsg>>,
    decoder: &mut AttachDecoder,
    stdout: &mut std::io::Stdout,
    controller: bool,
) -> Result<PumpEnd, String> {
    // Finish our send side so the target's `serve_attach` sees EOF → Detached
    // (the session outlives the viewport). Best-effort: an already-finished or
    // exited stream send is harmless to attempt.
    fn detach(brain: &mut Brain, stream_id: u64) -> PumpEnd {
        let _ = brain.net_stream_send(stream_id, &[], None, true);
        PumpEnd::Detached
    }
    // Dedup cursor: a post-restart re-serve may re-transmit `Output` records;
    // drop any `seq` already rendered (the operator render cursor, matching the
    // local `OutputLog` replay discipline).
    let mut cursor = 0u64;
    // Controller window-change tracking (REQ-RCVIEW-1): poll the terminal size
    // each slice and forward a Resize when it changes (cross-platform without
    // hooking SIGWINCH / the crossterm event loop, which would conflict with the
    // raw-stdin reader). `None` until the first read.
    let mut last_size: Option<(u16, u16)> = None;
    // The PTY size last advertised to us as a VIEWER (the letterbox feed); shown
    // as a one-line indicator when it differs from our own terminal.
    let mut pty_size: Option<(u16, u16)> = None;
    // First-event backstop (REQ-HAZARD-RC-ATTACH-FAILFAST path b): a dead/wedged
    // attach produces nothing — bail with a clear message past FIRST_EVENT_GRACE
    // rather than an infinite blank. Any broker event clears it.
    let started = Instant::now();
    let mut seen_any = false;
    loop {
        // ── controller window-change → resize ──────────────────────────────
        if controller {
            if let Ok((cols, rows)) = crossterm::terminal::size() {
                if last_size != Some((rows, cols)) {
                    last_size = Some((rows, cols));
                    let _ = send_attach_resize(brain, stream_id, rows, cols);
                }
            }
        }

        // ── stdin → session ────────────────────────────────────────────────
        if let Some(rx) = rx {
            loop {
                match rx.try_recv() {
                    Ok(StdinMsg::Bytes(bytes)) => {
                        send_attach_input(brain, stream_id, &bytes, ops.next())
                            .map_err(|e| format!("send input: {e}"))?;
                    }
                    Ok(StdinMsg::Detach) => return Ok(detach(brain, stream_id)),
                    Err(mpsc::TryRecvError::Empty) => break,
                    Err(mpsc::TryRecvError::Disconnected) => {
                        return Ok(detach(brain, stream_id))
                    }
                }
            }
        }

        // ── session → terminal ─────────────────────────────────────────────
        let ev = match brain.read_event_until(Some(Instant::now() + POLL_SLICE)) {
            Ok(ev) => ev,
            Err(e) => match classify_read_err(e.kind()) {
                // A poll-slice timeout: no event this slice. Before looping, the
                // first-event backstop — if NOTHING has ever arrived past the
                // generous grace, the attach is dead/wedged (path b).
                ReadDisposition::Retry => {
                    if first_event_stalled(seen_any, started.elapsed()) {
                        return Ok(PumpEnd::Stalled);
                    }
                    continue;
                }
                // A severed broker stream (broker bounce / hang-up) — surface it
                // gracefully, never as a raw RC_FAIL crash (REQ-HAZARD-RC-EOF).
                ReadDisposition::BrokerGone => return Ok(PumpEnd::BrokerGone),
                ReadDisposition::Fatal => return Err(format!("read event: {e}")),
            },
        };
        seen_any = true; // any broker event proves the attach stream is live
        match ev {
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    match rec {
                        AttachRecord::Output { seq, data_b64 } => {
                            if seq < cursor {
                                continue; // already rendered — re-serve dedup
                            }
                            cursor = seq + 1;
                            let bytes = decode_bytes(&data_b64)
                                .map_err(|e| format!("decode output: {e}"))?;
                            stdout.write_all(&bytes).map_err(|e| format!("write: {e}"))?;
                            stdout.flush().map_err(|e| format!("flush: {e}"))?;
                        }
                        AttachRecord::Exit { code } => return Ok(PumpEnd::Exited(code)),
                        AttachRecord::Displaced { by } => {
                            return Ok(PumpEnd::Displaced(by));
                        }
                        // Viewer letterbox feed (REQ-RCVIEW-1): on a size change,
                        // show a one-line indicator when our terminal differs from
                        // the new PTY size (rc is a byte pump — true clip/pad needs
                        // a grid model, deferred; the honest size cue renders here).
                        AttachRecord::Size { rows, cols } if pty_size != Some((rows, cols)) => {
                            pty_size = Some((rows, cols));
                            match crossterm::terminal::size() {
                                Ok((mycols, myrows)) if (myrows, mycols) != (rows, cols) => {
                                    let _ = write!(
                                        stdout,
                                        "\r\n[viewing {cols}x{rows} — your terminal is {mycols}x{myrows}]\r\n"
                                    );
                                    let _ = stdout.flush();
                                }
                                _ => {}
                            }
                        }
                        _ => {} // Request/Input echoed back: protocol noise, ignore.
                    }
                }
            }
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
                return Ok(PumpEnd::Detached);
            }
            BrokerEvent::Error { message } => return Err(message),
            _ => {} // acks + unrelated events: not ours.
        }
    }
}

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

    fn parse(armed: &mut bool, input: &[u8]) -> (Vec<u8>, bool) {
        let p = parse_stdin_chunk(armed, input);
        (p.forward, p.detach)
    }

    // [unit->REQ-HAZARD-RC-ATTACH-FAILFAST] path (a): attaching to a perch stamped
    // status=offline (the B2-reconcile / endpoint-stop state) SHORT-CIRCUITS with
    // clean guidance — it returns Ok WITHOUT any broker dance (no daemon needed),
    // so it can never hang on a dead session.
    #[test]
    fn attach_offline_endpoint_short_circuits_clean() {
        let _home = crate::testutil::isolated_home();
        let id = "stopped-ep";
        let perch = spt_store::perch::resolve_perch_path(id, spt_store::perch::ParentHint::Infer);
        std::fs::create_dir_all(&perch).unwrap();
        let rec = spt_store::info::InfoJson::new(id, "t", std::process::id(), "sid", "live_agent");
        spt_store::info::write_info(&perch, &rec).unwrap();
        spt_store::info::set_status(&perch, spt_store::liveness::STATUS_OFFLINE).unwrap();

        // Offline status → the gate returns early (no broker), both intents.
        assert!(run_attach(id, AttachIntent::Control).is_ok());
        assert!(run_attach(id, AttachIntent::Viewer).is_ok());
    }

    // [unit->REQ-HAZARD-RC-ATTACH-FAILFAST] path (b): the generous first-event
    // backstop fires ONLY when NOTHING has arrived past the grace — never for a
    // session that produced an event (a fresh/mid-init alive attach is safe), and
    // never before the grace elapses.
    #[test]
    fn first_event_stall_decision() {
        // No event + past the generous grace → stalled.
        assert!(first_event_stalled(false, FIRST_EVENT_GRACE));
        assert!(first_event_stalled(false, FIRST_EVENT_GRACE + Duration::from_secs(1)));
        // Any event seen → never stalled (the working/mid-init attach guard).
        assert!(!first_event_stalled(true, FIRST_EVENT_GRACE + Duration::from_secs(60)));
        // Before the grace → not yet (a slow-painting session still has time).
        assert!(!first_event_stalled(false, Duration::from_secs(1)));
        assert!(!first_event_stalled(false, FIRST_EVENT_GRACE - Duration::from_millis(1)));
    }

    // [unit->REQ-HAZARD-RC-EOF] a severed-broker-stream read error (UnexpectedEof
    // "failed to fill whole buffer", and the reset/abort/broken-pipe class) is
    // classified BrokerGone → graceful surface, NOT a fatal RC_FAIL crash; a
    // poll-slice timeout retries; any other error stays fatal.
    #[test]
    fn classify_read_err_eof_is_graceful_not_fatal() {
        use std::io::ErrorKind::*;
        // The exact kind behind "failed to fill whole buffer".
        assert_eq!(classify_read_err(UnexpectedEof), ReadDisposition::BrokerGone);
        // Same severed-stream class.
        assert_eq!(classify_read_err(BrokenPipe), ReadDisposition::BrokerGone);
        assert_eq!(classify_read_err(ConnectionReset), ReadDisposition::BrokerGone);
        assert_eq!(classify_read_err(ConnectionAborted), ReadDisposition::BrokerGone);
        // Poll-slice timeouts retry (no event this slice).
        assert_eq!(classify_read_err(WouldBlock), ReadDisposition::Retry);
        assert_eq!(classify_read_err(TimedOut), ReadDisposition::Retry);
        // A genuine unexpected failure still propagates.
        assert_eq!(classify_read_err(InvalidData), ReadDisposition::Fatal);
        assert_eq!(classify_read_err(PermissionDenied), ReadDisposition::Fatal);
    }

    // [unit->REQ-RC-1] the detach keybind state machine: `ctrl-b d` detaches
    // (the prefix is NOT forwarded); `ctrl-b ctrl-b` forwards one literal
    // ctrl-b (so the session can still receive it); `ctrl-b <other>` forwards
    // prefix + other verbatim; plain input passes through untouched.
    #[test]
    fn detach_keybind_semantics() {
        let mut armed = false;
        // plain passthrough
        assert_eq!(parse(&mut armed, b"hello"), (b"hello".to_vec(), false));
        assert!(!armed);
        // ctrl-b d => detach, bytes-before forwarded, prefix consumed
        assert_eq!(
            parse(&mut armed, &[b'h', DETACH_PREFIX, DETACH_KEY]),
            (b"h".to_vec(), true)
        );
        // ctrl-b ctrl-b => one literal ctrl-b, no detach
        let mut a2 = false;
        assert_eq!(
            parse(&mut a2, &[DETACH_PREFIX, DETACH_PREFIX]),
            (vec![DETACH_PREFIX], false)
        );
        // ctrl-b <other> => prefix + other verbatim
        let mut a3 = false;
        assert_eq!(
            parse(&mut a3, &[DETACH_PREFIX, b'x']),
            (vec![DETACH_PREFIX, b'x'], false)
        );
    }

    // [unit->REQ-RC-1] the prefix may straddle a read boundary: a chunk ending
    // in ctrl-b arms across chunks, and the next chunk's `d` detaches — so the
    // keybind works no matter how raw reads fragment it.
    #[test]
    fn detach_prefix_spans_chunks() {
        let mut armed = false;
        // chunk 1 ends on the bare prefix: nothing forwarded yet, armed carries.
        assert_eq!(parse(&mut armed, &[b'a', DETACH_PREFIX]), (b"a".to_vec(), false));
        assert!(armed, "prefix armed across the chunk boundary");
        // chunk 2 starts with the detach key: detach fires.
        assert_eq!(parse(&mut armed, &[DETACH_KEY]), (Vec::new(), true));
    }

    // [unit->REQ-RC-1] operator op ids are non-colliding across sessions: two
    // minters seed from distinct random bases (so a journaled send_attach_input
    // op from a prior rc session is never deduped as a replay), and each minter
    // is strictly monotonic within a session.
    #[test]
    fn op_minter_distinct_and_monotonic() {
        let m = OpMinter::new();
        let (a, b, c) = (m.next(), m.next(), m.next());
        assert!(b > a && c > b, "monotonic within a session");
        // A second minter's base differs from the first with overwhelming
        // probability (random 63-bit base) — assert the bases don't coincide.
        let m2 = OpMinter::new();
        assert_ne!(m.next(), m2.next(), "independent session bases");
    }
}


rc attach: `resolve_session` (find by endpoint) → loopback dial → `request_attach(session_id)` → subscribe → pump renders Output. The session resolves fine (else "no live session" error, but symptom is 0 bytes after connect). So suspect is broker-side: does the per-session OutputLog/drain survive the spawn-conn drop, and does a FRESH subscribe replay+fan? Read broker dispatch_spawn + serve_attach.