# M12 Wave 1 — implementation plan (`spt endpoint run` core + `spt rc` attach)

> JIT build plan (todlando, 2026-06-14). Parent: `M12-PLAN.md` W1. Design-locked with doyle
> (one-path `rc` invariant + `[session.self]`→broker-PTY mirror approved). This doc is the
> survivable build spec — coding off this is mechanical.

## Goal (gate condition for W1)
A user launches a HARNESS endpoint into a broker-held PTY and attaches a local terminal;
the same `spt rc` path attaches cross-node. Reverses today's harness-hosted-only launch.

## REQs (registered, `traceable-reqs.toml`)
- **REQ-HOST-RUN-1** — `spt endpoint run` spawns `[session.self]` into a broker PTY for
  kind=harness (mirrors `shellhost::launch_shell_brokered_in`), registers the perch, returns id.
- **REQ-RC-1** — `spt rc <id>` attaches a local terminal to a broker-held PTY via the
  cross-node attach pump; local = degenerate single-node case (ONE path). `--view` read-only,
  clean detach (no session termination).
- Activation order (rule 2, memory [[traceable-per-wave-activation]]): each commit sets
  `required_stages` to ONLY the evidence in that commit, cumulative. doc+impl+unit land this
  wave; `int` activates at the W-final E2E. Never pre-activate the end-state list.

## Verified seams (file:line)
- **Broker spawn**: `spt-daemon/src/brain.rs:380` `spawn_session_pid(SpawnReq) -> (u64, Option<u32>)`.
  `SpawnReq{program,args,rows,cols,endpoint}` (`msg.rs:124`), reply `Spawned{session_id,pid}`.
- **Mirror target**: `spt-daemon/src/shellhost.rs:210` `launch_shell_brokered_in(...)` —
  fills opaque template (`runtime::fill_template`+`tokenize`), `Brain::cold_start` → `spawn_session_pid`,
  records pid. New harness path mirrors this but: (a) template = `[session.self].command`,
  (b) endpoint label = the harness endpoint id (NOT shell_session_label), (c) PTY size = real
  terminal dims (a harness IS a terminal — unlike shells' 24x512 anti-wrap), (d) registration
  per the harness perch contract (fill from trace), (e) NO link-token (harness binds via `api`).
- **Attach pump**: `spt-daemon/src/attach.rs` — `serve_attach(brain,stream_id,endpoint,origin_node,net_from_seq)`
  -> `AttachServeOutcome{Refused,Exited(Option<i32>),Detached}`; `request_attach(brain,conn_id,session_id,from_seq,open_op)->stream_id`;
  `send_attach_input(brain,stream_id,bytes,wire_op)`. Cross-node rides QUIC (`spt-net/src/net/attach.rs`
  `AttachRecord::{Request,Input,Output,Exit}`). Local = single-node degenerate case (loopback peer).
  `--view` = NOT-YET; fork at serve entry / gate input forwarding (don't call send_attach_input).
- **Manifest**: `spt-runtime/src/manifest.rs:165` `Session.self_` (`[session.self]`),
  `SessionRole{command,cwd,detach,env_remove,keys,...}` (:234). `registry::resolve_option(_in)`
  splits composite `<adapter>:<profile>` → merged manifest (leaf-replace overlay).
- **CLI**: `spt/src/cli.rs:201-335` `EndpointCmd` enum; dispatch `:879-908`. Add `Run{...}`
  variant + a top-level `Rc{id,view}` command (rc is top-level per plan, not under endpoint).

## Harness perch bind + registration seam  (TRACED)
- **Harness binds itself, no daemon pre-register.** On startup the harness runs `spt api listen`
  / `spt api bind` → `api/startup.rs:147 establish_perch(id,session_id,parent_pid,adapter,subnet,endpoint_type)`:
  `create_dir_all(perch)` → `InfoJson::new(id, now_stamp(), pid, session_id, type)` (gen stamped,
  `:183`) → `info::write_info` → `ready` marker → mint/read token. Idempotent (revive = fresh
  stamp, durable home/adapter/resources carried via `stamp_creation_fields`).
- **Self perch create**: no pre-create API; the bind IS the create. (`ready.rs:61 start_homed`
  same flow for `spt ready`.) Generation = `info.json` `started` field, `now_stamp()` at bind.
- **DECISION (b): spawn-first, harness self-registers on bind.** Existing structure assumes it.
  No new daemon registration code — `spt endpoint run` only needs to (1) spawn `[session.self]`
  into the broker PTY labeled `<id>`, (2) ensure the harness binds to `<id>` (the env/id question
  below). Broker-held PTY ⇒ harness survives `rc` detach (hazard satisfied, by construction).
- **cwd — REQ-HOST-RUN-2 (doyle-tracked, post-W1 / pre-M12-gate).** W1 ships broker-inherited
  cwd (= daemon cwd) as a bringup-PROOF SHORTCUT only. NOT optional for M12: the consumer (Claude
  Code) is project-scoped — wrong cwd ⇒ wrong `.claude`/session-history/digest source; `cc <id>`
  at a project root must land CC in that project. Real cwd DOES need the additive `SpawnReq.cwd`
  wire change (broker spawns the PTY; cwd must reach `portable-pty` CommandBuilder). N-1-safe,
  done deliberately. Registered now (stages=[]); must land before the M12 gate.
- **Follow-up flags (doyle, NOT W1 blockers):** (1) PTY *resize* events must flow through the
  attach pump for an interactive harness — W1 sets initial real dims only; live resize = later W.
  (2) Detach keybind (T1.2.5): explicit, cannot collide with harness passthrough input (legacy
  capsule = ctrl-b prefix); documented. Folded into REQ-RC-1.
- **RESOLVED — VERDICT (A): argv `{id}` template, NO SpawnReq wire change.** Harness learns its
  id purely via argv: `[session.self].command = "<bin> --id {id} --session-id {session_id}"`
  (real example `adapters/mock/manifest.toml:43` + `adapters/mock/src/main.rs:43` parses `--id`).
  `fill_template` (`runtime.rs:93`) fills `{id}`/`{session_id}`/`{adapter_name}` (`SUBSTITUTION_KEYS`
  `runtime.rs:36`) before tokenize. Broker spawn `broker.rs:531 dispatch_spawn` →
  `pty.rs:95 PtySession::spawn_program(program,args,size)` → portable-pty CommandBuilder INHERITS
  broker env (no env param). `[env]` doc confirms "spt-hosted inherits from the broker." So the
  daemon fn just substitutes `{id}` + the standard keys; harness self-binds to `<id>` post-spawn.
  Session id: fresh create = mint one; `--resume <session>` = use that.

## Task breakdown
### T1.1 `spt endpoint run` non-interactive core
1. `EndpointCmd::Run` variant + flags: `--adapter <a[:profile]>`, `--id <id>`, `--create`,
   `--resume <session>`, `--attach|--start|--view` (mutually-exclusive terminal action).
2. New daemon fn (shellhost sibling, e.g. `harnesshost::launch_harness_brokered_in` or extend
   endpoint.rs): resolve manifest → `[session.self]` → fill template → broker PTY spawn →
   register perch per trace decision → return endpoint id.
3. `cmd_endpoint_run` in cli.rs: resolve adapter option, daemon call, then dispatch terminal
   action (`--start` returns; `--attach` falls into the rc pump; `--view` rc pump read-only).
4. Non-interactive flag completeness: every terminal action of the W2 picker reachable by flags
   (so `cc-<id>` shortcuts bake fully non-interactive).

### T1.2 `spt rc <id>` attach client
1. Top-level `Cmd::Rc{id, #[arg(long)] view}` + dispatch arm → `cmd_rc`.
2. `cmd_rc`: resolve endpoint id → broker-held session → `request_attach` → full-duplex pump
   (operator stdin → `send_attach_input`; session output → terminal stdout). EOF/detach exits.
3. ONE path (doyle invariant): local = loopback peer through the SAME attach.rs pump; no local
   special-case branch. Cross-node correctness IS local correctness.
4. `--view`: read-only — do NOT forward stdin (skip send_attach_input), only render output.
5. Clean detach keybind; detach NEVER terminates the broker-held session (hazard: PTY
   ownership stays with broker). ConPTY DSR auto-answer in the reader (hazard 5.5).

## T1.2 design (traced 2026-06-14) — the rc operator pump
- **No operator pump/CLI exists today**; only primitives `request_attach`/`send_attach_input`
  (attach.rs:210/231) + the target `serve_attach`. Reuse-template for the render loop:
  `tests/attach.rs:117 render_until` (reads `BrokerEvent::NetStreamData` → `AttachDecoder` →
  `AttachRecord::Output`, dedup by `seq` cursor).
- **Inbound attach auto-dispatches** (`dispatch.rs:335-360`): a stream whose first record is
  `Request{session_id}` → broker resolves session_id→endpoint via `brain.sessions()` →
  `serve_attach`. So a loopback self-dial's inbound stream is served by the EXISTING production
  path — no new server code.
- **conn acquisition — (A) self-dial EMPIRICALLY DEAD.** iroh refuses it: `net_dial(own_addr)` →
  `"Connecting to ourself is not supported"` (fail-fast, pinned green by
  `loopback_self_dial_is_refused_local_uses_fallback_transport`). So local attach CANNOT ride a
  self-dialed QUIC conn. Cross-node rc is UNAFFECTED (dials a real remote) — already works on the
  proven attach.rs primitives. LOCAL attach needs a fallback transport (doyle's call, pinged):
  - **(B1) broker loopback-conn** [rec]: NetHost in-process conn, streams locally cross-wired,
    inbound dispatched to `serve_attach` like QUIC; everything above byte-identical; local
    loopback-dials instead of `net_dial`. ONE pump, covered by cross-node tests. Cost: broker-layer
    addition (new conn variant + in-process stream routing + dispatch), broker-internal (6.7 safe).
  - **(B2) local-direct**: rc attaches straight to the broker session (`brain.attach`+`Output`+
    `send_effect`), no net stream — a transport branch in the pump (the "lesser branch" doyle warned of).
  - rc.rs currently codes the self-dial path (now known-refused); swap the conn-acquisition to the
    chosen B once doyle rules. The pump itself is transport-invariant — no pump change either way.
- **endpoint→session**: `brain.sessions() -> SessionsReply{ sessions: [SessionInfo{session_id,
  endpoint,resume_seq}] }` (brain.rs:923), filter by `endpoint == <id>` → `session_id`. (Local
  only this wave; remote `id@node_hex` + cross-node resolution is the gate's cross-subnet proof.)
- **rc pump shape**:
  1. resolve `<id>` → `session_id` via `brain.sessions()`.
  2. `conn_id = brain.net_dial(brain.net_status().addr, Some(dial_op))`.
  3. `stream_id = request_attach(brain, conn_id, session_id, 0, open_op)`.
  4. raw terminal mode; spawn stdin-reader thread → `send_attach_input(brain, stream_id, bytes,
     mint_op())` per chunk (SKIP if `--view`). main loop: `brain.read_event()` → `NetStreamData`
     → decode `AttachRecord::Output` → dedup `seq` → write stdout; `Exit` → break; detach key → break.
  5. detach keybind: explicit prefix (legacy capsule ctrl-b); on detach the broker session lives
     on (serve_attach returns `Detached`, no terminate). ConPTY DSR auto-answer in the reader (5.5).
- **terminal raw mode = NEW**: no crossterm/termios in `spt` today (`spt` is a thin I/O layer).
  Add `crossterm` (also W2's ratatui dep — consistent). Raw mode + restore-on-exit guard.

## Tests (unit this wave; int at W-final)
- `[unit->REQ-HOST-RUN-1]` harness spawn fills `[session.self]` template + registers perch +
  returns id (mirror of shellhost's `launch_parks_token...` test shape; tempdir owlery, mock
  harness manifest with a NOOP `[session.self]`). Template failure fails closed.
- `[unit->REQ-RC-1]` local `rc` rides the SAME attach pump as cross-node (assert one code
  path — e.g. local attach constructs the same request_attach call / loopback peer). `--view`
  forwards no input. Detach leaves the session alive.
- `[int->REQ-HOST-RUN-1/REQ-RC-1]` (W-final): `endpoint run` brings up a real harness + `rc`
  attaches, both topologies; cross-subnet/PTY proof (the spt-claude-code v1 acceptance dep).

## Hazards to honor
- 6.7 broker/brain separate processes (don't collapse). 5.5 ConPTY DSR auto-answer in attach
  reader. 5.6 Windows detached no-inherit (harness spawn rides broker, not detached_no_inherit —
  confirm broker path is clean). 2.4 gen_start fresh on cold-start (don't rehydrate). 4.10
  registry eviction (perch registration is daemon-authoritative).

## Build order  (status 2026-06-14)
1. [DONE] Bind/register seam traced (decision b: spawn-first, harness self-registers).
2. [DONE] Daemon `harnesshost::{prepare_harness_spawn, launch_harness_brokered_in, mint_session_id}`
   + 3 unit tests green; `spt-daemon` compiles.
3. [DONE] `EndpointCmd::Run` + `cmd_endpoint_run` (resolve harness adapter → spawn → start/attach/view).
4. [DONE] top-level `Cmd::Rc` + `rc.rs` pump (single-Brain, stdin-thread+mpsc, seq-dedup, detach
   state machine) + 3 unit tests green (detach semantics, prefix-spans-chunks, op-minter); crossterm dep.
5. [DONE] `--attach`/`--view` route through the same `rc::run_attach` pump.
6. [DONE] Self-dial EMPIRICALLY DEAD (iroh "Connecting to ourself is not supported") — pinned green
   `loopback_self_dial_is_refused_local_uses_fallback_transport`. doyle ruled B1 (broker loopback-conn) = W1.5.
7. [DONE] Bringup + cross-node attach int test `spt_hosted_bringup_then_cross_node_attach_drives_the_pty`
   (harnesshost spawns `[session.self]` into broker PTY → cross-node attach drives it → session
   outlives detach). REQ-HOST-RUN-1 int + REQ-RC-1 cross-node int. GREEN.
8. [DONE] REQ-HOST-RUN-1 + REQ-RC-1 activated to impl+unit+int; `traceable-reqs check` EXIT=0.
9. [DONE] Full suite green (46 binaries, 0 fail); `clippy --all-targets -D warnings` EXIT=0;
   `traceable-reqs check` EXIT=0. W1 GATE REQUESTED from doyle 2026-06-14. Uncommitted (operator-gated).

## W1 GATE PACKAGE (for doyle)
- **Bringup (REQ-HOST-RUN-1)**: `spt endpoint run --adapter <h[:p]> --id <id> [--start|--attach|--view]`
  spawns `[session.self]` into a broker PTY; harness self-registers on bind. impl+unit+int green.
- **Attach (REQ-RC-1)**: `spt rc <id>` operator pump (cross-node face proven; local face = W1.5).
  Detach = ctrl-b then `d` (ctrl-b ctrl-b = literal); detach never kills the session. impl+unit+int green.
- **Evidence**: harnesshost 3 unit + rc 3 unit + 2 int (`spt_hosted_bringup_then_cross_node_attach_drives_the_pty`,
  `loopback_self_dial_is_refused...`). `traceable-reqs check` EXIT=0.
- **Carried to W1.5**: B1 broker loopback-conn + local attach int (+ "both transports → one pump" assertion).
- **Carried to pre-M12-gate**: REQ-HOST-RUN-2 project cwd (additive SpawnReq.cwd).
