# M3a Plan — Terminal wrapper (`spt-term`)

> **Just-in-time, lightweight** — same pattern as `M0/M1/M2a/M2b-PLAN.md`. The
> ordered task layer between `ROADMAP.md` (milestone sequence), `M3-PLAN.md` (the
> M3 umbrella: Phase 0 spike-gate + M3a/M3b/M3c), and `traceable-reqs.toml` (the
> requirement checklist), scoped to **M3a only**. Branch: `dev-freeform`.

> **M3 is split** (M3-PLAN.md): **Phase 0 spike-gate ✅ COMPLETE** (Spikes #3/#4/#5/#6
> all PASS — ADR-0004 §E closed; broker/brain validated on ConPTY + forkpty). This
> plan is **M3a — `spt-term`**, the broker's PTY / session-surface half. **M3b**
> (the `spt-daemon` broker/brain process + IPC) and **M3c** (self-update) follow.

## Goal

Build the **multiplatform session-surface mechanism** that every hosted agent
session runs inside — the PTY layer the M3b broker will host. M3a delivers the
*library mechanism* (the `spt-term` crate); M3b delivers the *process* (the broker
that owns many of these behind versioned IPC).

**M3a done =** the `spt-term` crate compiles + layers acyclically
(`…→spt-msg→spt-term`) · a real child runs under a native PTY on **both Windows
(ConPTY) and Linux (forkpty)** behind one `SessionSurface` trait, output captured ·
the **ConPTY-DSR** hazard is closed (reader auto-answers `ESC[6n`, drains on a
thread, never gates exit on a blocking read) · **send-keys** (raw) + **send-line**
(cooked) injection reach the child, ordering preserved · a consumer **byte-streams**
the child's output with bounded backpressure · the **live activity buffer (PTY
digest)** parser primitive produces a structured, source-tagged rolling view from
that stream · `cargo test --workspace` green · clippy `-D warnings` clean ·
`traceable-reqs check` green with M3a reqs activated · CI matrix (ubuntu + windows)
green.

## Scope

### In — the `spt-term` crate (sibling of `spt-runtime`, above `spt-msg`)

- **`SessionSurface` trait + native PTY backends** (`REQ-TERM-1`) — ConPTY on
  Windows 10+, `forkpty(3)` on Unix, via `portable-pty` (the exact crate + handoff
  shape Spikes #1/#4 proved). Spawn a child under a real PTY; own the master
  reader + writer; capture output. The trait abstracts "anything you can write
  input to and read output from" so M4's network-attached surface and the GUI text
  pane slot in uniformly later.
- **ConPTY-DSR auto-answer** (`REQ-HAZARD-CONPTY-DSR` / 5.5) — the reader answers
  ConPTY's startup cursor-position query (`ESC[6n` → `ESC[1;1R`) on a **drain
  thread**, or ConPTY withholds *all* child stdout; and the read loop **never gates
  exit on a blocking `read()`** (a ConPTY master doesn't EOF while the writer is
  held — Spike #1's secondary gotcha). Copy-verbatim from Spike #1 (harness-orthogonal).
- **Input injection** (`REQ-TERM-2`) — `send-keys` (raw byte stream incl. escape
  sequences, e.g. `Ctrl-C`) and `send-line` (cooked line-level, flushed on newline).
  Both first-class; ordering preserved; reaches the child after a brain restart was
  proven survivable in Spike #1 (the broker owns the writer — M3b territory, but the
  injection *mechanism* is here).
- **Byte-stream local terminal streaming** (`REQ-TERM-3`) — a consumer streams the
  child's byte output with **bounded backpressure**; the v1 byte-stream shape (the
  off-node hop is M4 transport). This is the substrate the digest delta-stream and
  the remote-terminal attach both ride.
- **Live activity buffer (PTY digest) parser primitive** (`REQ-TERM-4`, ADR-0008) —
  a structured, **source-tagged** rolling buffer (last ~N user turns + agent output
  between; tool-usage sprints collapsed), built by running **caller-supplied**
  `input_pattern` / `agent_pattern` / `tool_pattern[]`+catchall over the A4 byte
  stream. M3a builds the *parser primitive* (patterns passed in, structured output,
  window depth N, arg-truncation width); the **manifest-sourcing of the patterns,
  the `spt digest` fetch, the broker-pushed delta-stream, and Path-B persistence are
  M3b** (they need the broker + manifest + history store). Honors the no-built-in-
  parser rule — the *pattern* is supplied, spt-term only runs it.

### Out

- **The broker *process* + versioned local IPC** (M3b) — `spt-term` provides the PTY
  *mechanism* the broker *hosts*; the multiplexed, name-addressable, attach/detach
  one-per-machine supervisor with IPC is M3b. M3a hosts a single surface per handle,
  not the fleet.
- **Daemon-authoritative liveness, brain restart, idempotent boundary** (M3b).
- **Digest manifest-sourcing / `spt digest` / delta-stream hosting / persistence**
  (M3b) — only the parser primitive is M3a.
- **Off-node terminal transport** (M4); the predictive/state-sync "feels-local"
  layer (deferred); on-disk scrollback spillover (deferred).
- **Multi-subnet / notifications** — M4 (this grill's other clusters), not M3.

## Clean-room posture (validated against the sister + Phase 0)

- **`spt-term` is brand-new.** The sister never hosted ConPTY/PTY directly (it
  shelled out to `claude`). Clean-room the whole crate; the **only copied artifact is
  the ConPTY-DSR mechanism** proven in Spike #1 (harness-orthogonal).
- **Phase 0 findings are binding inputs:**
  - **Spike #4 (forkpty parity) + #5 (resize/churn):** `forkpty` is a **raw ordered
    pipe**; ConPTY is a **screen buffer** (repaint-on-resize reorders+duplicates the
    stream; a mid-session attach sees the viewport, not scrollback). **The
    `SessionSurface` trait must NOT bake in either OS's stream semantics** — the
    byte-stream contract (REQ-TERM-3) and the digest parser (REQ-TERM-4) must tolerate
    repaint/reorder on Windows and strict ordering on Linux. Attach/replay semantics
    (viewport vs scrollback) are an explicit surface decision, not an accident.
  - **Spike #5:** resize races the reader — hold the master behind a lock for
    `resize()`; the surface survives churn with no leak/hang/lost-byte.
  - **Spike #1:** the no-EOF-while-writer-held + drain-on-thread discipline.

## New requirements to register first (TRACEABILITY rule 3)

**Assessment — none.** All M3a reqs already exist in `traceable-reqs.toml`:
`REQ-TERM-1`, `REQ-TERM-2`, `REQ-TERM-3` (M3 terminal), `REQ-HAZARD-CONPTY-DSR`
(5.5), and `REQ-TERM-4` (registered this session, ADR-0008). They sit
`required_stages = []`; M3a activates them as each task lands.

## Sequencing rationale

Scaffold → the load-bearing **`SessionSurface` + PTY backend** (Spike-proven on both
OSes) → **DSR auto-answer** (must land *with* the reader or output is withheld) →
**injection** → **byte-stream** → **digest parser** (rides the stream) → activation.
Each task tags evidence + activates its reqs in the same commit; `traceable-reqs
check` green before the next.

## Tasks — `spt-term` (the session-surface mechanism)

| # | Task | Source | Reqs / hazards | Acceptance |
|---|------|--------|----------------|------------|
| A0 | Scaffold `crates/spt-term` (sibling of `spt-runtime`, above `spt-msg`; deps spt-proto/store/msg; `portable-pty`); activation-prep | new | — | crate compiles; layering `…→spt-msg→spt-term` acyclic; `check` green |
| A1 | **`SessionSurface` trait + native PTY backends**: ConPTY (win) / `forkpty` (unix) via `portable-pty`; spawn a child under a real PTY; own master reader+writer; resize behind a master lock (Spike #5) | clean-room; Spikes #1/#4/#5 | REQ-TERM-1 | a child runs under a real PTY on **both** Windows + Linux; output captured; resize-under-load does not leak/hang (regression test) |
| A2 | **ConPTY-DSR auto-answer**: reader answers `ESC[6n`→`ESC[1;1R` on a drain thread; never gate exit on a blocking `read()` (no-EOF-while-writer-held) | copy Spike #1 mechanism | REQ-HAZARD-CONPTY-DSR (5.5) | a ConPTY child's stdout is NOT withheld; the DSR query is auto-answered; a regression test asserts bytes flow only after the answer |
| A3 | **Injection**: `send-keys` (raw bytes incl. escapes) + `send-line` (cooked, newline-flushed) over the surface | clean-room; CONTEXT §input injection | REQ-TERM-2 | injected keys (incl. `Ctrl-C`) and lines reach the child; ordering preserved (regression test) |
| A4 | **Byte-stream local streaming**: a consumer streams the child's byte output; **bounded backpressure**; the v1 byte-stream shape; tolerant of ConPTY repaint/reorder (Spike #5) | clean-room | REQ-TERM-3 | a consumer streams the child's output; backpressure is bounded (no unbounded buffer); a resizing ConPTY stream is delivered without loss (value-set coverage, per Spike #5) |
| A5 | **Live activity buffer (PTY digest) parser primitive**: a structured, source-tagged rolling buffer over the A4 stream — caller-supplied `input_pattern`/`agent_pattern` (span-until-next-match) + `tool_pattern[]`+catchall (collapse sprints; extract name+arg); window depth N; arg-truncation width | ADR-0008; CONTEXT §live activity buffer | REQ-TERM-4 (impl, unit) | given patterns + a byte stream, the primitive yields a tagged buffer of the last N turns with tool sprints collapsed; tolerates repaint/reorder; daemon-side fetch/stream/persist is explicitly M3b |
| A6 | **Activation sweep**: activate REQ-TERM-1/2/3 + REQ-HAZARD-CONPTY-DSR + REQ-TERM-4(impl,unit); green `check`, clippy `-D warnings`, CI matrix (ubuntu+windows); amend ROADMAP (M3a delivered) + CONTEXT/M3a notes; author the **M3b plan** stub | — | `check` green with M3a reqs activated; CI matrix + gate pass |

## M3a requirement-activation map

Activate as each task lands (default `["impl","unit"]`):
- **REQ-TERM-1** (A1), **REQ-HAZARD-CONPTY-DSR** (A2), **REQ-TERM-2** (A3),
  **REQ-TERM-3** (A4), **REQ-TERM-4** (A5 — `impl`,`unit`; its `int` lands in M3b
  with the broker-hosted fetch/stream E2E).
- A real PTY-hosting E2E (a child genuinely driven through a `SessionSurface` on
  the CI matrix) is the cross-cutting evidence for A1–A4.

**Stay `[]` (M3b/M4):** `REQ-DAEMON-*`, `REQ-HAZARD-DAEMON-HOSTED-LIVENESS`,
`REQ-HAZARD-RESTART-IDEMPOTENT`, `REQ-HAZARD-HANDOFF-ARGV-COMPAT`,
`REQ-HAZARD-GEN-START-NOW`, `REQ-START-3`, all `REQ-UPD-*`, and the M4 grill reqs
(`REQ-INST-9..13`, `REQ-PAIR-5/6`, `REQ-NOTIF-1/2`).

## Workspace change

Add `crates/spt-term` (sibling of `spt-runtime`). Layering (R-ARCH-1, acyclic):

```
spt-proto → spt-store → spt-msg → {spt-net, spt-term, spt-runtime} → spt-live → spt-daemon → spt
```

`spt-term` is **not** public SDK (R-ARCH-2 stays proto/runtime/msg) — it is internal
machinery the broker hosts.

## Risks carried into M3a

- **OS stream-contract divergence (Spike #4/#5).** The single biggest design risk:
  do **not** let the `SessionSurface` trait or the byte-stream/digest contracts
  assume ConPTY's repaint/viewport *or* forkpty's raw-pipe semantics. Model the
  surface as a byte stream with explicit, OS-independent attach/replay semantics.
- **ConPTY-DSR + no-EOF-while-writer-held.** Already paid for once (Spike #1, cost a
  build cycle to hung instances locking the binary). The drain-thread + DSR-answer
  pattern is mandatory in A2, not optional polish.
- **Digest scope creep.** A5 is the *parser primitive only*. Resist pulling the
  manifest seam, `spt digest`, the delta-stream, or persistence forward from M3b —
  they need the broker/daemon that M3a deliberately does not build.
- **Single-surface, not the fleet.** M3a hosts one surface per handle; the
  multiplexed name-addressable supervisor is M3b. A task that finds itself building
  IPC or a process registry has crossed into M3b — stop and surface it.
