1	# v0.13.0 — spt-hosted message delivery + the control/wedge bug cluster (JIT plan)
2
3	> Milestone born from operator dogfooding of v0.12.1/v0.12.2 (grill-with-docs, 2026-06-19).
4	> Scope decision: **one milestone** — folds the in-flight v0.12.2 work (window-regression fix +
5	> Markdown-output sweep, already gated on branch `v0.12.2-cli-fixes`) into v0.13.0.
6	> **Binding lesson (v0.12.0/v0.12.1):** several "fixes" shipped green-but-broken because they were
7	> gated on theory / mocks, not the REAL claude-spt harness. EVERY fix here is gated against a REAL
8	> dummy-harness fixture + real daemon. No prove-don't-change shortcuts on a hazard that recurred.
9
10	## The unifying invariant (the spine)
11
12	**The broker must accept INJECTED keystrokes (the v0.11.0 raw direct-inject today; the
13	manifest-declared translation-binary tomorrow) AND a live `spt rc` controller into the SAME
14	broker-held PTY without (a) the operator losing control, (b) the endpoint latching
15	`ONLINE+CONTROLLED`, or (c) the broker wedging.** The injection inlet is permanent —
16	spt-claude-code *requires* keystroke injection — so this is root-caused + fixed at the
17	PTY-injection layer, IN STEP with the delivery redesign that formalizes the inlet.
18
19	## Recovered design (CCS session 16531b26, 2026-06-17 — Q1→"A+" locked, rest deferred)
20
21	Idle message delivery to spt-hosted endpoints, redesigned:
22	- **`[message-idle-translation-binary]`** — opt-in adapter manifest key. **= the binary PATH**
23	  (operator: *binary owns the choreography*). spt-core **manages its lifecycle** (up with the
24	  endpoint, down when the endpoint goes down).
25	- The binary (i) **hosts the poll listener + subscribes to its output**, (ii) **drives parsed
26	  output + keystrokes into the broker PTY** via an spt-core **send-keys API** supporting
27	  **arbitrary keys + pre/post delays**. CC choreography (operator-specified):
28	  `ctrl+s (stash) → 50ms → payload + \r → 50ms → ctrl+s (restore)`.
29	- **Unified substrate (Q1=A):** the daemon's **poll feed** is the ONE idle substrate for both
30	  topologies; consumer differs — harness-hosted = the Monitor child; spt-hosted = the translation
31	  binary. **spt-core prefers the poll listener for a perch IF it exists** (so spt-hosted can use a
32	  listener AND keep `spt rc`). Idle-only; **busy = hook-injection** (adapter, mid-turn).
33	- Today's hardcoded direct-PTY-inject (payload+`\r`, no choreography) = the **degenerate special
34	  case** of a translation-binary — and the cause of the control-loss (it fights the controller).
35	- **Grounding gap (current):** `api bind` registers NO listener port → a listener-less spt-hosted
36	  perch SPOOLS inbound messages (only spooling+adapter-poll works today). The redesign closes this.
37
38	### RESOLVED (grilled 2026-06-19 → **ADR-0022**). Final contract:
39	- **`[message-idle-translation-binary]`** = scalar binary path, opt-in per manifest. NEW primitive
40	  (not collapsed into `[inject]`/`notif_command`; shares the poll-feed substrate).
41	- Binary = **pure stdin→stdout filter**; **spt-core owns lifecycle + every PTY write**.
42	- **stdin** (spt-core→binary, JSON-lines): `{type:"init",endpoint_id,node}` first ·
43	  `{type:"event",envelope:"<EVENT…>"}` per message · `{type:"input"}` content-free ping on operator
44	  keystroke (binary tracks user-idle for its own idle-gated buffering).
45	- **stdout** (binary→spt-core, JSON-lines): `{key:…}` · `{delay_ms:…}` · `{text:…}`.
46	- spt-core applies the sequence to the PTY **ATOMICALLY** — controller input buffered during the
47	  sequence, flushed after (so ctrl+s stash/restore can't be clobbered). This is the
48	  injection-coexists-with-controller mechanism (shared with W1).
49	- spt-core PREFERS a perch's poll listener if present. Idle-only; busy = adapter hook-injection.
50	- v0.11.0 raw inject = degenerate `{text:payload}{key:enter}` case.
51	- CONTEXT.md:39 reworded (W0); busy/idle split per ADR-0022.
52
53	## Waves
54
55	### W0 — Design formalization (doyle; gate: operator sign-off)
56	- Resolve the deferred forks above. Author **ADR-0022 (spt-hosted idle delivery redesign)** — it
57	  qualifies (hard-to-reverse delivery-model change; surprising = "why a separate binary"; real
58	  trade-off = binary-owns vs manifest-declares, chose binary). Reword CONTEXT.md:39 +
59	  delivery section. Add the `[message-idle-translation-binary]` row to the inject-seam family.
60	- Mint the milestone REQs (registry-first, stages=[] until each wave builds).
61
62	### W1 — KEYSTONE: broker-never-wedges + control-loss root cause (todlando build, doyle gate)
63	- **Repro FIRST** on the REAL dummy-harness fixture: an `spt rc`-attached spt-hosted session,
64	  inject a message (keystrokes) → instrument to find the EXACT blocking call (operator's signal:
65	  one keystroke succeeds, the next wedges → single-threaded broker parks on a blocking PTY/loopback
66	  write after injection-induced output). Do NOT fix on theory — nail the mechanism.
67	- Fix the data-plane: injected keystrokes + controller input + PTY-output drain must never
68	  deadlock the broker (candidates: non-blocking/fail-fast PTY write, split input/output, bounded-
69	  evicting). **REOPEN REQ-HAZARD-ATTACH-WEDGE** (v0.12.1 prove-don't-change was insufficient — the
70	  wedge recurs via the injection trigger, NOT just dead-child).
71	- int gate: the repro fixture asserts — after injection, the controller keeps control, no wedge,
72	  a new attach still serves, `daemon stop` bounded.
73
74	### W2 — KEYSTONE: delivery redesign (todlando build, doyle gate; depends W0)
75	- `[message-idle-translation-binary]` manifest key + validate; spt-core lifecycle-manages the
76	  binary (up/down with endpoint). The send-keys API (arbitrary keys + delays). Poll-feed substrate
77	  + listener-preference rule. spt-hosted gets real inbound delivery (closes the spool-only gap).
78	- int gate: a real dummy translation-binary delivers an inbound message into an rc-attached
79	  spt-hosted session via the choreography WITHOUT breaking control (composes with W1's invariant).
80
81	### W3 — P1: fresh endpoint shows under its project (todlando build, doyle gate)
82	- ROOT (found): `info.cwd` is NEVER set on bind. Thread cwd into `establish_perch` + set `rec.cwd`:
83	  `cmd_bind` (spt-hosted) reads its own `current_dir` (the broker spawned it in `project_cwd`);
84	  `bind_from_seed` (harness-hosted) passes `seed.cwd` (already captured, currently discarded).
85	- int gate: a REAL `endpoint run` perch has `info.cwd` set AND appears under its project tab
86	  (the v0.12.1 unit tested merge_origin_project with a *provided* origin — never asserted cwd is set).
87
88	### W4 — Picker UX (todlando build, doyle gate)
89	- Skip the first screen → open directly on **Pick existing**; `n` jumps to Create new.
90	- **Auto-attach** after Start-new AND Resume-from-history (both currently don't attach + show no
91	  stdout); add an `h` shortcut to run headless (no attach).
92	- `controlled by` shows the **node NAME** (node_label_display), not the raw hex (D).
93	- Clean up the Start-new output: drop the Rust `pid=Some(142748)` leak + the "harness binds its
94	  perch on startup" internals; user-friendly, not process-log.
95
96	### W5 — `driven_by` self-heal (todlando build, doyle gate)
97	- `ONLINE+CONTROLLED` must clear even if a detach is lost (don't rely on the detach IPC — same
98	  lesson as B2/REQ-HAZARD-HOSTED-LIVENESS-RECONCILE): reconcile clears `driven_by` when the
99	  endpoint has no live controller/session. Composes with W1 (wedge no longer blocks detach).
100
101	### W6 — perri hand-off (doyle, after W2)
102	- Direct perri to the UPDATED integration checklist + docs to build the spt-claude-code
103	  **translation binary** (CC choreography: ctrl+s→50ms→payload+\r→50ms→ctrl+s). Per the standing
104	  route: doyle designs (W0 ADR) → perri builds the adapter binary → doyle gates → perri validates.
105
106	### Side task (do early, docs-only) — correct v0.12.1 changelog
107	- v0.12.1 notes claim "a freshly-created agent appears under its own project right away" — REFUTED
108	  (P1 never worked). Amend CHANGELOG.md (main) + `gh release edit v0.12.1` notes, no retag
109	  (v0.12.0-precedent). v0.13.0 delivers it for real (W3).
110
111	## Sequencing notes
112	- W0 gates W2 (design before build). W1 (wedge/control) is independent + the scariest — can start
113	  immediately (repro-first). W3/W4/W5 are independent bug/UX fixes, parallelizable. W2+W6 are the
114	  feature spine. The folded v0.12.2 work (window fix @15fdf58, MD-sweep @2435a74, ATX @b3bb387)
115	  rides the v0.13.0 release; its changelog bullets merge in.
116	- Version: **v0.13.0** (MINOR — new delivery feature + breaking-ish behavior). Confirm at release.
117

# v0.13.0 — spt-hosted message delivery + the control/wedge bug cluster (JIT plan)

> Milestone born from operator dogfooding of v0.12.1/v0.12.2 (grill-with-docs, 2026-06-19).
> Scope decision: **one milestone** — folds the in-flight v0.12.2 work (window-regression fix +
> Markdown-output sweep, already gated on branch `v0.12.2-cli-fixes`) into v0.13.0.
> **Binding lesson (v0.12.0/v0.12.1):** several "fixes" shipped green-but-broken because they were
> gated on theory / mocks, not the REAL claude-spt harness. EVERY fix here is gated against a REAL
> dummy-harness fixture + real daemon. No prove-don't-change shortcuts on a hazard that recurred.

## The unifying invariant (the spine)

**The broker must accept INJECTED keystrokes (the v0.11.0 raw direct-inject today; the
manifest-declared translation-binary tomorrow) AND a live `spt rc` controller into the SAME
broker-held PTY without (a) the operator losing control, (b) the endpoint latching
`ONLINE+CONTROLLED`, or (c) the broker wedging.** The injection inlet is permanent —
spt-claude-code *requires* keystroke injection — so this is root-caused + fixed at the
PTY-injection layer, IN STEP with the delivery redesign that formalizes the inlet.

## Recovered design (CCS session 16531b26, 2026-06-17 — Q1→"A+" locked, rest deferred)

Idle message delivery to spt-hosted endpoints, redesigned:
- **`[message-idle-translation-binary]`** — opt-in adapter manifest key. **= the binary PATH**
  (operator: *binary owns the choreography*). spt-core **manages its lifecycle** (up with the
  endpoint, down when the endpoint goes down).
- The binary (i) **hosts the poll listener + subscribes to its output**, (ii) **drives parsed
  output + keystrokes into the broker PTY** via an spt-core **send-keys API** supporting
  **arbitrary keys + pre/post delays**. CC choreography (operator-specified):
  `ctrl+s (stash) → 50ms → payload + \r → 50ms → ctrl+s (restore)`.
- **Unified substrate (Q1=A):** the daemon's **poll feed** is the ONE idle substrate for both
  topologies; consumer differs — harness-hosted = the Monitor child; spt-hosted = the translation
  binary. **spt-core prefers the poll listener for a perch IF it exists** (so spt-hosted can use a
  listener AND keep `spt rc`). Idle-only; **busy = hook-injection** (adapter, mid-turn).
- Today's hardcoded direct-PTY-inject (payload+`\r`, no choreography) = the **degenerate special
  case** of a translation-binary — and the cause of the control-loss (it fights the controller).
- **Grounding gap (current):** `api bind` registers NO listener port → a listener-less spt-hosted
  perch SPOOLS inbound messages (only spooling+adapter-poll works today). The redesign closes this.

### RESOLVED (grilled 2026-06-19 → **ADR-0022**). Final contract:
- **`[message-idle-translation-binary]`** = scalar binary path, opt-in per manifest. NEW primitive
  (not collapsed into `[inject]`/`notif_command`; shares the poll-feed substrate).
- Binary = **pure stdin→stdout filter**; **spt-core owns lifecycle + every PTY write**.
- **stdin** (spt-core→binary, JSON-lines): `{type:"init",endpoint_id,node}` first ·
  `{type:"event",envelope:"<EVENT…>"}` per message · `{type:"input"}` content-free ping on operator
  keystroke (binary tracks user-idle for its own idle-gated buffering).
- **stdout** (binary→spt-core, JSON-lines): `{key:…}` · `{delay_ms:…}` · `{text:…}`.
- spt-core applies the sequence to the PTY **ATOMICALLY** — controller input buffered during the
  sequence, flushed after (so ctrl+s stash/restore can't be clobbered). This is the
  injection-coexists-with-controller mechanism (shared with W1).
- spt-core PREFERS a perch's poll listener if present. Idle-only; busy = adapter hook-injection.
- v0.11.0 raw inject = degenerate `{text:payload}{key:enter}` case.
- CONTEXT.md:39 reworded (W0); busy/idle split per ADR-0022.

## Waves

### W0 — Design formalization (doyle; gate: operator sign-off)
- Resolve the deferred forks above. Author **ADR-0022 (spt-hosted idle delivery redesign)** — it
  qualifies (hard-to-reverse delivery-model change; surprising = "why a separate binary"; real
  trade-off = binary-owns vs manifest-declares, chose binary). Reword CONTEXT.md:39 +
  delivery section. Add the `[message-idle-translation-binary]` row to the inject-seam family.
- Mint the milestone REQs (registry-first, stages=[] until each wave builds).

### W1 — KEYSTONE: broker-never-wedges + control-loss root cause (todlando build, doyle gate)
- **Repro FIRST** on the REAL dummy-harness fixture: an `spt rc`-attached spt-hosted session,
  inject a message (keystrokes) → instrument to find the EXACT blocking call (operator's signal:
  one keystroke succeeds, the next wedges → single-threaded broker parks on a blocking PTY/loopback
  write after injection-induced output). Do NOT fix on theory — nail the mechanism.
- Fix the data-plane: injected keystrokes + controller input + PTY-output drain must never
  deadlock the broker (candidates: non-blocking/fail-fast PTY write, split input/output, bounded-
  evicting). **REOPEN REQ-HAZARD-ATTACH-WEDGE** (v0.12.1 prove-don't-change was insufficient — the
  wedge recurs via the injection trigger, NOT just dead-child).
- int gate: the repro fixture asserts — after injection, the controller keeps control, no wedge,
  a new attach still serves, `daemon stop` bounded.

### W2 — KEYSTONE: delivery redesign (todlando build, doyle gate; depends W0)
- `[message-idle-translation-binary]` manifest key + validate; spt-core lifecycle-manages the
  binary (up/down with endpoint). The send-keys API (arbitrary keys + delays). Poll-feed substrate
  + listener-preference rule. spt-hosted gets real inbound delivery (closes the spool-only gap).
- int gate: a real dummy translation-binary delivers an inbound message into an rc-attached
  spt-hosted session via the choreography WITHOUT breaking control (composes with W1's invariant).

### W3 — P1: fresh endpoint shows under its project (todlando build, doyle gate)
- ROOT (found): `info.cwd` is NEVER set on bind. Thread cwd into `establish_perch` + set `rec.cwd`:
  `cmd_bind` (spt-hosted) reads its own `current_dir` (the broker spawned it in `project_cwd`);
  `bind_from_seed` (harness-hosted) passes `seed.cwd` (already captured, currently discarded).
- int gate: a REAL `endpoint run` perch has `info.cwd` set AND appears under its project tab
  (the v0.12.1 unit tested merge_origin_project with a *provided* origin — never asserted cwd is set).

### W4 — Picker UX (todlando build, doyle gate)
- Skip the first screen → open directly on **Pick existing**; `n` jumps to Create new.
- **Auto-attach** after Start-new AND Resume-from-history (both currently don't attach + show no
  stdout); add an `h` shortcut to run headless (no attach).
- `controlled by` shows the **node NAME** (node_label_display), not the raw hex (D).
- Clean up the Start-new output: drop the Rust `pid=Some(142748)` leak + the "harness binds its
  perch on startup" internals; user-friendly, not process-log.

### W5 — `driven_by` self-heal (todlando build, doyle gate)
- `ONLINE+CONTROLLED` must clear even if a detach is lost (don't rely on the detach IPC — same
  lesson as B2/REQ-HAZARD-HOSTED-LIVENESS-RECONCILE): reconcile clears `driven_by` when the
  endpoint has no live controller/session. Composes with W1 (wedge no longer blocks detach).

### W6 — perri hand-off (doyle, after W2)
- Direct perri to the UPDATED integration checklist + docs to build the spt-claude-code
  **translation binary** (CC choreography: ctrl+s→50ms→payload+\r→50ms→ctrl+s). Per the standing
  route: doyle designs (W0 ADR) → perri builds the adapter binary → doyle gates → perri validates.

### Side task (do early, docs-only) — correct v0.12.1 changelog
- v0.12.1 notes claim "a freshly-created agent appears under its own project right away" — REFUTED
  (P1 never worked). Amend CHANGELOG.md (main) + `gh release edit v0.12.1` notes, no retag
  (v0.12.0-precedent). v0.13.0 delivers it for real (W3).

## Sequencing notes
- W0 gates W2 (design before build). W1 (wedge/control) is independent + the scariest — can start
  immediately (repro-first). W3/W4/W5 are independent bug/UX fixes, parallelizable. W2+W6 are the
  feature spine. The folded v0.12.2 work (window fix @15fdf58, MD-sweep @2435a74, ATX @b3bb387)
  rides the v0.13.0 release; its changelog bullets merge in.
- Version: **v0.13.0** (MINOR — new delivery feature + breaking-ish behavior). Confirm at release.


1	# ADR-0022 — spt-hosted idle message delivery via an adapter translation binary
2
3	- Status: Accepted (design grilled 2026-06-19; supersedes the v0.11.0 hardcoded direct-PTY-inject)
4	- Milestone: v0.13.0
5
6	## Context
7
8	An spt-hosted endpoint is one whose PTY the daemon owns (terminal-wrapper / GUI topology). Inbound
9	inter-agent messages must reach the agent running inside that PTY while it is **idle**.
10
11	The v0.11.0 implementation hardcodes a single delivery method: the broker types the message payload
12	plus `\r` straight into the PTY stdin (`dispatch_endpoint_input` → `session.write_input`). This has
13	two fatal problems:
14
15	1. **It is harness-blind.** Real TUIs need input that *precedes* and *follows* the payload — e.g.
16	   Claude Code needs `ctrl+s` to stash any in-progress user text, then the payload, then `ctrl+s`
17	   to restore it. A bare `payload + \r` corrupts the user's in-progress input and may mis-fire.
18	2. **It fights the controller.** When an operator is attached via `spt rc`, the raw inject competes
19	   with the operator on the same PTY and the broker's single-threaded I/O path; the operator loses
20	   control and the broker can wedge (the v0.12.1 "attach wedge", returning via this trigger).
21
22	Today there is also a coverage gap: `api bind` registers no listener port, so a listener-less
23	spt-hosted perch *spools* inbound messages — only spooling + adapter-poll works.
24
25	We previously locked (Q1 → "A+", CCS session 16531b26, 2026-06-17) a **unified** model: the daemon's
26	poll feed is the one idle substrate for both topologies; the consumer differs. This ADR completes
27	that design.
28
29	## Decision
30
31	Introduce an opt-in adapter-supplied **translation binary**, declared by the manifest key
32	**`[message-idle-translation-binary]`** (a scalar path). It is a **pure stdin→stdout filter**;
33	**spt-core owns the binary's lifecycle and every PTY write.**
34
35	- **Lifecycle:** spt-core spawns the binary when the endpoint comes up and terminates it when the
36	  endpoint goes down.
37	- **stdin (spt-core → binary), JSON-lines:**
38	  - `{type:"init", endpoint_id, node}` — first line; identity.
39	  - `{type:"event", envelope:"<EVENT type=\"msg\" …>"}` — each inbound message (ADR-0020 envelope).
40	  - `{type:"input"}` — a content-free ping each time the operator types, so the binary can track
41	    user-idle state and roll its own idle-gated buffering. (PTY input content is NOT duplicated.)
42	- **stdout (binary → spt-core), JSON-lines:** keystroke-commands — `{key:"ctrl+s"}`,
43	  `{delay_ms:50}`, `{text:"<payload>"}`, `{key:"enter"}`, … (extensible).
44	- **spt-core applies the emitted sequence to the broker PTY ATOMICALLY:** controller keystrokes
45	  arriving mid-sequence are BUFFERED and flushed after, so a stash/restore can never be clobbered.
46	  spt-core owning every PTY write is what lets injection coexist with a live `spt rc` controller.
47	- **Poll-listener preference:** spt-core prefers a perch's poll listener if one exists, so an
48	  spt-hosted endpoint can run a listener AND keep `spt rc`. A dev may keep the perch "idle-ready"
49	  and use either the listener or the translation binary for *all* delivery.
50	- **Scope:** idle delivery only. Busy/mid-turn delivery stays adapter hook-injection.
51
52	The v0.11.0 raw inject is the **degenerate special case** — a translation binary that emits
53	`{text:payload}{key:enter}` with no choreography.
54
55	## Consequences
56
57	- spt-claude-code ships a ~30-line translation binary: read an `event` on stdin, emit
58	  `ctrl+s → delay 50 → text(payload) → enter → delay 50 → ctrl+s` on stdout. Perri builds it
59	  against this contract; the choreography lives entirely in the adapter's binary.
60	- The control-loss / broker-wedge bug is fixed at the source it shares with this feature: the
61	  atomic PTY-write coordination (v0.13.0 W1) makes ANY injection (raw today, choreographed
62	  tomorrow) coexist with the controller. This ADR and that fix land in step.
63	- CONTEXT.md:39 reworded: spt-hosted needs no *separate harness-owned* relay — the daemon-side
64	  translation binary is the consumer of the same poll feed.
65	- New manifest primitive (not collapsed into `[inject]{Pty,Hook,Relay,Http}` / `notif_command`),
66	  but it shares the poll-feed substrate with notif delivery.
67
68	## Alternatives considered
69
70	- **Manifest-declared keystroke sequence (no binary).** Rejected: cannot branch on message content
71	  or user-idle state; the binary form is barely more work and far more capable.
72	- **Binary hosts its own `spt api listen` + writes the PTY directly.** Rejected: spt-core already
73	  owns listener logic, and if the binary wrote the PTY directly spt-core could not coordinate
74	  atomicity with the controller. Piping the feed to stdin and reading keystrokes from stdout keeps
75	  the binary trivial and keeps every PTY write under spt-core's control.
76	- **Keep two distinct delivery paths (Q1 option B).** Rejected in favour of the unified poll-feed
77	  substrate.
78

# ADR-0022 — spt-hosted idle message delivery via an adapter translation binary

- Status: Accepted (design grilled 2026-06-19; supersedes the v0.11.0 hardcoded direct-PTY-inject)
- Milestone: v0.13.0

## Context

An spt-hosted endpoint is one whose PTY the daemon owns (terminal-wrapper / GUI topology). Inbound
inter-agent messages must reach the agent running inside that PTY while it is **idle**.

The v0.11.0 implementation hardcodes a single delivery method: the broker types the message payload
plus `\r` straight into the PTY stdin (`dispatch_endpoint_input` → `session.write_input`). This has
two fatal problems:

1. **It is harness-blind.** Real TUIs need input that *precedes* and *follows* the payload — e.g.
   Claude Code needs `ctrl+s` to stash any in-progress user text, then the payload, then `ctrl+s`
   to restore it. A bare `payload + \r` corrupts the user's in-progress input and may mis-fire.
2. **It fights the controller.** When an operator is attached via `spt rc`, the raw inject competes
   with the operator on the same PTY and the broker's single-threaded I/O path; the operator loses
   control and the broker can wedge (the v0.12.1 "attach wedge", returning via this trigger).

Today there is also a coverage gap: `api bind` registers no listener port, so a listener-less
spt-hosted perch *spools* inbound messages — only spooling + adapter-poll works.

We previously locked (Q1 → "A+", CCS session 16531b26, 2026-06-17) a **unified** model: the daemon's
poll feed is the one idle substrate for both topologies; the consumer differs. This ADR completes
that design.

## Decision

Introduce an opt-in adapter-supplied **translation binary**, declared by the manifest key
**`[message-idle-translation-binary]`** (a scalar path). It is a **pure stdin→stdout filter**;
**spt-core owns the binary's lifecycle and every PTY write.**

- **Lifecycle:** spt-core spawns the binary when the endpoint comes up and terminates it when the
  endpoint goes down.
- **stdin (spt-core → binary), JSON-lines:**
  - `{type:"init", endpoint_id, node}` — first line; identity.
  - `{type:"event", envelope:"<EVENT type=\"msg\" …>"}` — each inbound message (ADR-0020 envelope).
  - `{type:"input"}` — a content-free ping each time the operator types, so the binary can track
    user-idle state and roll its own idle-gated buffering. (PTY input content is NOT duplicated.)
- **stdout (binary → spt-core), JSON-lines:** keystroke-commands — `{key:"ctrl+s"}`,
  `{delay_ms:50}`, `{text:"<payload>"}`, `{key:"enter"}`, … (extensible).
- **spt-core applies the emitted sequence to the broker PTY ATOMICALLY:** controller keystrokes
  arriving mid-sequence are BUFFERED and flushed after, so a stash/restore can never be clobbered.
  spt-core owning every PTY write is what lets injection coexist with a live `spt rc` controller.
- **Poll-listener preference:** spt-core prefers a perch's poll listener if one exists, so an
  spt-hosted endpoint can run a listener AND keep `spt rc`. A dev may keep the perch "idle-ready"
  and use either the listener or the translation binary for *all* delivery.
- **Scope:** idle delivery only. Busy/mid-turn delivery stays adapter hook-injection.

The v0.11.0 raw inject is the **degenerate special case** — a translation binary that emits
`{text:payload}{key:enter}` with no choreography.

## Consequences

- spt-claude-code ships a ~30-line translation binary: read an `event` on stdin, emit
  `ctrl+s → delay 50 → text(payload) → enter → delay 50 → ctrl+s` on stdout. Perri builds it
  against this contract; the choreography lives entirely in the adapter's binary.
- The control-loss / broker-wedge bug is fixed at the source it shares with this feature: the
  atomic PTY-write coordination (v0.13.0 W1) makes ANY injection (raw today, choreographed
  tomorrow) coexist with the controller. This ADR and that fix land in step.
- CONTEXT.md:39 reworded: spt-hosted needs no *separate harness-owned* relay — the daemon-side
  translation binary is the consumer of the same poll feed.
- New manifest primitive (not collapsed into `[inject]{Pty,Hook,Relay,Http}` / `notif_command`),
  but it shares the poll-feed substrate with notif delivery.

## Alternatives considered

- **Manifest-declared keystroke sequence (no binary).** Rejected: cannot branch on message content
  or user-idle state; the binary form is barely more work and far more capable.
- **Binary hosts its own `spt api listen` + writes the PTY directly.** Rejected: spt-core already
  owns listener logic, and if the binary wrote the PTY directly spt-core could not coordinate
  atomicity with the controller. Piping the feed to stdin and reading keystrokes from stdout keeps
  the binary trivial and keeps every PTY write under spt-core's control.
- **Keep two distinct delivery paths (Q1 option B).** Rejected in favour of the unified poll-feed
  substrate.


Spec clear. W1 = repro broker wedge + control-loss on REAL dummy-harness fixture, nail exact blocking call, ping doyle BEFORE fix.

Ack doyle, then map the PTY I/O path + fixture. Explore in background (stay reachable).


Bash

$OWL send --reply-to doyle <<'EOF'
W1 ACK — keystone, repro-first. Read V0.13.0-DELIVERY-CONTROL-JIT.md §W1 + ADR-0022. Plan: instrument the REAL dummy-harness fixture (rc-attached spt-hosted session), inject keystrokes, nail the EXACT blocking call (single-threaded broker parking on a blocking PTY/loopback write after injection-induced output). No fix until mechanism nailed. Will ping you with the root mechanism BEFORE any patch so you gate the root. Mapping the PTY I/O path now.
EOF

Ack W1 dispatch to doyle


Error: Exit code 2
error: the following required arguments were not provided:
  <TARGET>

Usage: owl.exe send --reply-to <REPLY_TO> <TARGET> [FROM]

For more information, try '--help'.


Agent

Map broker PTY I/O + dummy-harness fixture

Prompt:
Explore the spt-core Rust codebase to map the broker PTY I/O path and the test fixtures, for a wedge/control-loss repro task. Be very thorough. Report file:line for everything.

Find and report:

1. **PTY injection path**: `dispatch_endpoint_input` and `session.write_input` (or `write_input`) — where inbound messages get typed into a broker-held PTY. What function writes to the PTY fd? Is the write blocking? Show the call chain from message-arrival → PTY write.

2. **The broker's main I/O loop**: the single-threaded event loop that drives a broker-held (spt-hosted) PTY session. How does it read PTY output and write PTY input? Is it select/poll/epoll based, or thread-per-direction? Where could it block?

3. **`spt rc` controller / attach path**: how a remote-control client attaches to a broker PTY session — the controller-input → PTY-write path, and the PTY-output → controller path. Look for "rc", "remote control", "attach", "controller", "controlled", "driven_by", "ONLINE", "CONTROLLED".

4. **Loopback / control socket writes**: any place the broker writes to a loopback socket or IPC channel that could block if the reader is slow (backpressure). Search for "loopback", broker→controller streaming.

5. **The dummy-harness test fixture**: search tests/ and any fixtures for "dummy", "dummy-harness", "dummy_harness", harness mock binaries, and integration tests that spin up a real daemon + spt-hosted session. Where is the fixture binary, how is it built/invoked, and which existing integration test attaches via rc + injects a message?

6. **REQ-HAZARD-ATTACH-WEDGE**: find its existing test(s) and the v0.12.1 "prove-don't-change" coverage (likely DEAD-CHILD backpressure). Report the test file:line and what it asserts.

7. The crate layout: which crate holds the broker/daemon, which holds the PTY/session code, which holds rc.

Report a concise structured map with exact file:line references. Do not edit anything.


<owl_reachability_notice>
You have an active owl perch. Launch this Agent with run_in_background: true so you stay reachable for incoming messages while it works.
</owl_reachability_notice>


Async agent launched successfully.
agentId: a5d385b167e5283ab (internal ID - do not mention to user. Use SendMessage with to: 'a5d385b167e5283ab' to continue this agent.)
The agent is working in the background. You will be notified automatically when it completes.
Do not duplicate this agent's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.
output_file: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\73618172-412e-4954-b8aa-e2c8955dfd49\tasks\a5d385b167e5283ab.output
Do NOT Read or tail this file via the shell tool — it is the full subagent JSONL transcript and reading it will overflow your context. If the user asks for progress, say the agent is still running; you'll get a completion notification.

Map broker PTY I/O + dummy-harness fixture

Prompt:
Explore the spt-core Rust codebase to map the broker PTY I/O path and the test fixtures, for a wedge/control-loss repro task. Be very thorough. Report file:line for everything.

Find and report:

1. **PTY injection path**: `dispatch_endpoint_input` and `session.write_input` (or `write_input`) — where inbound messages get typed into a broker-held PTY. What function writes to the PTY fd? Is the write blocking? Show the call chain from message-arrival → PTY write.

2. **The broker's main I/O loop**: the single-threaded event loop that drives a broker-held (spt-hosted) PTY session. How does it read PTY output and write PTY input? Is it select/poll/epoll based, or thread-per-direction? Where could it block?

3. **`spt rc` controller / attach path**: how a remote-control client attaches to a broker PTY session — the controller-input → PTY-write path, and the PTY-output → controller path. Look for "rc", "remote control", "attach", "controller", "controlled", "driven_by", "ONLINE", "CONTROLLED".

4. **Loopback / control socket writes**: any place the broker writes to a loopback socket or IPC channel that could block if the reader is slow (backpressure). Search for "loopback", broker→controller streaming.

5. **The dummy-harness test fixture**: search tests/ and any fixtures for "dummy", "dummy-harness", "dummy_harness", harness mock binaries, and integration tests that spin up a real daemon + spt-hosted session. Where is the fixture binary, how is it built/invoked, and which existing integration test attaches via rc + injects a message?

6. **REQ-HAZARD-ATTACH-WEDGE**: find its existing test(s) and the v0.12.1 "prove-don't-change" coverage (likely DEAD-CHILD backpressure). Report the test file:line and what it asserts.

7. The crate layout: which crate holds the broker/daemon, which holds the PTY/session code, which holds rc.

Report a concise structured map with exact file:line references. Do not edit anything.


Send syntax: target positional needed. Use plain `send doyle`.


Bash

$OWL send doyle <<'EOF'
W1 ACK — keystone, repro-first. Read V0.13.0-DELIVERY-CONTROL-JIT.md §W1 + ADR-0022. Plan: instrument the REAL dummy-harness fixture (rc-attached spt-hosted session), inject keystrokes, nail the EXACT blocking call (single-threaded broker parking on a blocking PTY/loopback write after injection-induced output). No fix until mechanism nailed. Will ping you with the root mechanism BEFORE any patch so you gate the root. Mapping the PTY I/O path now.
EOF

Ack W1 dispatch to doyle


<owl_messages>
<owl_message from="todlando-w165" priority="info">[WORKING_PERCH_NOTICE] Subagent working perch created.
Perch: todlando-w165
Agent type: Explore
To message it: $OWL send todlando-w165 todlando</owl_message>
</owl_messages>

[OWL SYSTEM] Informational owl messages above — no acknowledgment needed. Continue your current task.


➜ SENT:doyle


Acked doyle. Explore mapping PTY I/O path + dummy-harness fixture in background. Standing by for the map, then I instrument the repro.


<task-notification>
<task-id>a5d385b167e5283ab</task-id>
<tool-use-id>toolu_0127oyWoe1xKAG69UapKzyLJ</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\73618172-412e-4954-b8aa-e2c8955dfd49\tasks\a5d385b167e5283ab.output</output-file>
<status>completed</status>
<summary>Agent "Map broker PTY I/O + dummy-harness fixture" came to rest</summary>
<note>A task-notification fires each time this agent comes to rest with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note>
<result>Let me output this as a direct message instead:

## SPT-CORE BROKER PTY I/O PATH MAPPING

I've thoroughly explored the spt-core codebase and mapped the broker PTY I/O path. Here's my structured report:

### **CRATE LAYOUT**
- **spt-daemon**: Broker kernel (broker.rs, attach.rs, nethost.rs)
- **spt-term**: PTY/terminal layer (pty.rs, reader.rs, surface.rs)
- **spt**: CLI with rc.rs (remote control)
- **adapters/mock**: Dummy-harness fixture binary

---

### **1. PTY INJECTION PATH (MESSAGE → PTY WRITE)**

**CALL CHAIN:**
1. **Inbound frame dispatch** → `crates/spt-daemon/src/broker.rs:700`
   - `read_frame(&mut recv)` in main loop

2. **dispatch_endpoint_input** → `crates/spt-daemon/src/broker.rs:1062-1098`
   - Resolve endpoint → session under `sessions.lock()` (lines 1069-1075)
   - Clone Arc out to avoid holding table lock
   - Call `session.write_input(&bytes)` at **line 1087**
   - Reply with `endpoint_injected_envelope` at **line 1089**

3. **Alternative: dispatch_input** → `crates/spt-daemon/src/broker.rs:1007-1042`
   - Session-keyed injection (direct session_id)
   - Call `session.write_input(&bytes)` at **lines 1027 or 1039**

4. **The actual PTY write** → `crates/spt-term/src/pty.rs:236-240`
   ```rust
   pub fn write_input(&self, bytes: &[u8]) -> Result<(), SurfaceError> {
       let mut w = self.writer.lock().unwrap();      // line 237
       w.write_all(bytes).map_err(SurfaceError::Io)?; // line 238
       w.flush().map_err(SurfaceError::Io)            // line 239
   }
   ```
   **SharedWriter:** `Arc<Mutex<Box<dyn Write + Send>>>` (cloned to drain pump for DSR replies)

**KEY INVARIANT:** `dispatch_input` releases the `sessions.lock()` at line 1019 BEFORE calling `write_input` at line 1027 — prevents PTY blocking the dispatch loop.

---

### **2. BROKER'S MAIN I/O LOOP**

**Single-threaded peer pump** → `crates/spt-daemon/src/broker.rs:623-810`
- Accepts connections at **line 625**, spawns one thread per brain
- Main loop **line 699-810**: `read_frame()` (BLOCKING on line 700)
- Dispatch arms for KIND_SPAWN, KIND_INPUT, KIND_RESIZE, KIND_ENDPOINT_INPUT, etc.
- **NEVER blocks on PTY or sockets** — only on inbound frame reads

**Background threads (run separately):**
1. **Drain pump** → `crates/spt-term/src/reader.rs:103-159`
   - Spawned at `broker.rs:877-881` in `dispatch_spawn`
   - Reads PTY output (BLOCKING at `reader.rs:128`)
   - Answers DSR queries via shared writer
   - Forwards chunks to sink (`log.append()` under log lock)

2. **Exit-waiter thread** → `crates/spt-daemon/src/broker.rs:883-910`
   - Blocks on `waiter.wait()` at **line 890** (child reap)
   - Broadcasts exit frame to all sinks at **lines 901-906**
   - Removes session from table at **line 909**

3. **Viewer writer threads** → `crates/spt-daemon/src/broker.rs:465-489`
   - One per viewer (spawned in `add_viewer` at **line 288**)
   - Each has a bounded SPSC queue (depth 256, **line 87**)
   - Blocks on `rx.recv()` at **line 480**
   - Dies on socket write failure

---

### **3. RC (REMOTE CONTROL) ATTACHMENT PATH**

**One-pump invariant (M12 W1.5):** Local attach (rc → loopback) and cross-node (rc → peer QUIC) use identical serve path.

**Operator side** → `crates/spt/src/rc.rs:89-300+`
- `resolve_session(brain, endpoint_id)` at **line 89**
- Stdin reader thread encodes `AttachRecord::Input`
- Main loop interleaves stdin drain with `read_event_until(POLL_SLICE)` at **line 43**

**Server side (serve_attach)** → `crates/spt-daemon/src/attach.rs:107-320`
- **TWO-CONN SPLIT** at **line 116-134**: `brain` reads inbound (Request/Input/Resize), separate `wire` connection sends output (Output/Size/Exit)
  - Prevents deadlock from inline controller-replay flow-control
- Dispatcher loop at **line 153+**: reads events, buffers input until role confirmed, flushes on controller OK

**Controller input forward**:
- `send_attach_input` at `attach.rs:362-374`
- Encodes as `AttachRecord::Input`
- Sends via `brain.net_stream_send(stream_id, ...)`

**Controller exclusivity** (REQ-RCVIEW-1):
- Resize rejected on viewer at `broker.rs:1114-1118`
- Input ignored on viewer at `attach.rs:190`
- `driven_by` marker set by `become_controller()` (best-effort, not atomic)

---

### **4. LOOPBACK DUPLEX & BACKPRESSURE**

**Loopback minting** → `crates/spt-daemon/src/nethost.rs:958-1050`
- Broker mints in-process tokio `duplex(BUF_CAP)` at reuse or first dial
- `(u64 conn_id, String node_id)` returned for operator to dial

**SendHalf::Loopback** → `crates/spt-daemon/src/nethost.rs:340-365`
- Async write to duplex WriteHalf

**RecvHalf::Loopback** → `crates/spt-daemon/src/nethost.rs:374-376`
- Continuously drained by LoopbackStreamReader

**Drain (broker-internal)** → `crates/spt-daemon/src/nethost.rs:544-580`
- Runs on net runtime (tokio worker thread)
- For `retentive_cap == 0` (ordinary attach), never parks
- Dead operator = duplex EOF → `Ok(0)` at **line 545** → breaks

**Backpressure bound** → `crates/spt-daemon/src/nethost.rs:71, 797-811`
- `BROKER_QUIC_OP_TIMEOUT_MS` = 10 seconds (line 71)
- `bounded_block_on` at **line 797**: wraps async ops with tokio::time::timeout
- Parks the DISPATCH thread, not net workers
- No worker-pool exhaustion possible

**No blocking on dead operator:**
- `serve_attach` sends output via bounded `net_stream_send`
- If operator is dead, duplex EOF → send succeeds or times out
- Loopback read pump drains duplex → never backpressures dispatch

---

### **5. DUMMY-HARNESS FIXTURE**

**Binary** → `adapters/mock/src/main.rs:40-98`
- `mock-session --mode dummy --id {id} --session-id {sid} --spt {path} --heartbeat-ms 200`
- Binds perch via `spt api bind <id>` (line 81)
- Prints "DUMMY_HARNESS_READY:{id}" (line 87)
- Heartbeat loop: prints "DUMMY_HARNESS_TICK:{id}:{n}" every 200ms (line 93)
- Stays alive until killed

**Test setup** → `crates/spt/tests/dummy_harness_e2e.rs:97-127`
- Registers dummyharness adapter manifest
- Sets `[session.psyche_init]` with a long-lived psyche binary
- Spawns broker: `spt daemon run` (line 132-150)

**Test assertions** → `crates/spt/tests/dummy_harness_e2e.rs:75+`
- Endpoint run succeeds (line 176)
- Perch goes ONLINE (line 177)
- RC attach receives "DUMMY_HARNESS_TICK" (line 178-179)

---

### **6. REQ-HAZARD-ATTACH-WEDGE TEST**

**File:** `crates/spt/tests/attach_wedge_e2e.rs:162-326`
**Test:** `attach_wedge_dead_child_plus_dropped_pump_does_not_wedge_the_broker`

**Hazard hypothesis (v0.12.0):** Dead PTY child + dropped rc pump blocks `serve_attach` loopback write → parks net worker → stalls all new attaches

**Proof mechanism (KNOWN-HAZARDS 7.11:410):**
1. `send_stream` (output forward) already has QUIC deadline (10s)
2. Loopback duplex drained broker-internally (RecvHalf::Loopback) → never backs up on dead rc
3. Dead rc is just a dropped IPC subscriber against bounded, evicting ring
4. `bounded_block_on` parks dispatch thread, not workers

**Test structure:**
- **Phase 1** (lines 230-235): Victim endpoint comes ONLINE, rc sees TICK, then rc is killed abruptly
- **WEDGE TRIGGER** (lines 237-242): Kill harness PTY child
- **Phase 2 — L2 PROOF** (lines 244-250): Fresh endpoint still comes ONLINE, new rc SERVED (if wedged, times out)
- **L3 proof** (lines 252-256): Dead endpoint offlined within reconcile tick
- **L4 proof** (lines 258-263): `daemon stop` completes bounded

**Assertions** (lines 293-325):
- L2 keystone: `fresh_online` (line 301)
- L2 keystone: `fresh_served` (line 306)
- L3 folded: `victim_offlined` (line 313)
- L4 folded: `stop.status.success()` (line 321)

---

### **7. KEY FILE REFERENCES (LINE NUMBERS)**

| Component | File | Lines |
|-----------|------|-------|
| **Dispatch entry** | broker.rs | 699-810 |
| **dispatch_input** | broker.rs | 1007-1042 |
| **dispatch_endpoint_input** | broker.rs | 1062-1098 |
| **write_input** | pty.rs | 236-240 |
| **Drain::spawn** | reader.rs | 107-159 |
| **serve_attach** | attach.rs | 107-320 |
| **viewer_writer** | broker.rs | 465-489 |
| **OutputLog** | broker.rs | 122-220 |
| **Loopback RecvHalf** | nethost.rs | 544-580 |
| **bounded_block_on** | nethost.rs | 797-811 |
| **ATTACH-WEDGE test** | attach_wedge_e2e.rs | 162-326 |
| **Dummy-harness fixture** | dummy_harness_e2e.rs | 75+ |
| **Fixture binary** | adapters/mock/src/main.rs | 40-98 |
| **HAZARD disposition** | KNOWN-HAZARDS.md | 408-410 |

---

### **SUMMARY**

The broker is a **single-threaded dispatch kernel** that:
1. Never holds session-table locks during PTY writes
2. Spawns per-session drain/waiter/viewer threads (no cascade blocking)
3. Uses viewer-eviction + bounded queues (no backpressure on drain)
4. Drains loopback duplex broker-internally (never parks on dead operator)
5. All socket writes deadline-bounded (10s QUIC op timeout)

The **wedge cannot occur** because:
- Loopback reads are non-blocking for ordinary attach (no backpressure)
- Dead operator = duplex EOF → read loop exits cleanly
- `serve_attach` output sends are deadline-bounded, not indefinite waits
- Dispatch thread can never be parked by operator socket writes</result>
<usage><subagent_tokens>84234</subagent_tokens><tool_uses>48</tool_uses><duration_ms>270825</duration_ms></usage>
</task-notification>


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


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


Map in. But Explore concluded "wedge cannot occur" — that's exactly the v0.12.1 theory doyle says is insufficient. The dead-child facet is covered; the INJECTION-trigger facet is not. Must repro on real fixture, not theorize.

Strong suspect from map: `write_input` (pty.rs:236) does blocking `write_all`+`flush` on PTY master under a shared `writer` mutex — and the drain pump (reader.rs) locks the SAME writer for DSR replies. Classic two-direction PTY deadlock + single-threaded dispatch park. Read the real code.


1	//! Native-PTY session backend — ConPTY on Windows 10+, `forkpty(3)` on Unix,
2	//! both via `portable-pty` (the exact crate + handoff shape Spikes #1 / #4
3	//! proved).
4	//!
5	//! [`PtySession`] spawns a child under a real PTY, owns the master reader and
6	//! writer, and serves the [`SessionSurface`] contract. It is **single-surface
7	//! per handle** — one child's PTY, not the multiplexed fleet (that supervisor is
8	//! the M3b broker).
9	//!
10	//! ## Concurrency discipline (Spike #5, binding)
11	//!
12	//! `resize()` races the reader thread on Windows; the master is therefore held
13	//! behind a `Mutex` and every `resize` takes that lock. The writer is a shared
14	//! `Arc<Mutex<…>>` so the A2 DSR drain thread can answer ConPTY's startup
15	//! cursor query through the same serialized writer the caller injects input
16	//! through — no torn writes.
17
18	use std::io::{Read, Write};
19	use std::sync::{Arc, Mutex};
20
21	use portable_pty::{native_pty_system, Child, ChildKiller, CommandBuilder, ExitStatus, MasterPty};
22
23	use crate::surface::{SessionSurface, SurfaceError, SurfaceSize};
24
25	/// Shared, serialized handle to the PTY master writer. Cloned to the A2 drain
26	/// thread so the DSR auto-answer and caller input never interleave mid-write.
27	pub(crate) type SharedWriter = Arc<Mutex<Box<dyn Write + Send>>>;
28
29	/// A child process running under a native PTY, exposed as a [`SessionSurface`].
30	///
31	/// Build one with [`PtySession::spawn`]; drive input via [`SessionSurface::write_input`]
32	/// (or the A3 `send_keys`/`send_line` helpers), drain output via
33	/// [`PtySession::take_reader`], and observe lifecycle via [`PtySession::try_wait`] /
34	/// [`PtySession::wait`] / [`PtySession::kill`].
35	pub struct PtySession {
36	    /// The master end, behind a lock so `resize` never races the reader (Spike #5).
37	    master: Mutex<Box<dyn MasterPty + Send>>,
38	    /// The single PTY writer, shared so the A2 drain thread can answer DSR.
39	    writer: SharedWriter,
40	    /// The spawned child; behind a lock for `wait`/`try_wait`.
41	    child: Mutex<Box<dyn Child + Send + Sync>>,
42	    /// A kill handle split from the child so we can terminate from another
43	    /// thread even while `wait` blocks (`portable-pty` `ChildKiller`).
44	    killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
45	    /// The master reader, taken at most once (`take_reader`).
46	    reader: Mutex<Option<Box<dyn Read + Send>>>,
47	    /// The child OS pid, captured at spawn.
48	    pid: Option<u32>,
49	}
50
51	impl PtySession {
52	    /// Spawn `cmd` under a fresh native PTY sized to `size`.
53	    ///
54	    /// The slave end is dropped immediately after the child is spawned: the
55	    /// child holds the only remaining slave handle, which lets EOF propagate on
56	    /// the Unix raw-pipe backend once the child exits. (On Windows the no-EOF
57	    /// behaviour is governed by the writer, handled by the A2 drain discipline.)
58	    //
59	    // [impl->REQ-TERM-1]
60	    pub fn spawn(cmd: CommandBuilder, size: SurfaceSize) -> Result<Self, SurfaceError> {
61	        let pty_system = native_pty_system();
62	        let pair = pty_system
63	            .openpty(size.into())
64	            .map_err(|e| SurfaceError::OpenPty(e.to_string()))?;
65
66	        let child = pair
67	            .slave
68	            .spawn_command(cmd)
69	            .map_err(|e| SurfaceError::Spawn(e.to_string()))?;
70	        // Drop the slave so the child owns the only slave-side handle.
71	        drop(pair.slave);
72
73	        let reader = pair
74	            .master
75	            .try_clone_reader()
76	            .map_err(|e| SurfaceError::Io(io_other(e)))?;
77	        let writer = pair
78	            .master
79	            .take_writer()
80	            .map_err(|e| SurfaceError::Io(io_other(e)))?;
81	        let killer = child.clone_killer();
82	        let pid = child.process_id();
83
84	        Ok(Self {
85	            master: Mutex::new(pair.master),
86	            writer: Arc::new(Mutex::new(writer)),
87	            child: Mutex::new(child),
88	            killer: Mutex::new(killer),
89	            reader: Mutex::new(Some(reader)),
90	            pid,
91	        })
92	    }
93
94	    /// Convenience: spawn `program` with `args` under a PTY sized to `size`,
95	    /// inheriting the caller's working directory.
96	    pub fn spawn_program<P, A, S>(
97	        program: P,
98	        args: A,
99	        size: SurfaceSize,
100	    ) -> Result<Self, SurfaceError>
101	    where
102	        P: AsRef<std::ffi::OsStr>,
103	        A: IntoIterator<Item = S>,
104	        S: AsRef<std::ffi::OsStr>,
105	    {
106	        Self::spawn_program_in(program, args, size, None::<&std::ffi::OsStr>)
107	    }
108
109	    /// Spawn `program` with `args` under a PTY sized to `size`, in `cwd` when
110	    /// `Some` (REQ-HOST-RUN-2: a project-scoped working directory for the
111	    /// spawned child) — `None` inherits the caller's cwd, identical to
112	    /// [`Self::spawn_program`].
113	    // [impl->REQ-HOST-RUN-2]
114	    pub fn spawn_program_in<P, A, S, D>(
115	        program: P,
116	        args: A,
117	        size: SurfaceSize,
118	        cwd: Option<D>,
119	    ) -> Result<Self, SurfaceError>
120	    where
121	        P: AsRef<std::ffi::OsStr>,
122	        A: IntoIterator<Item = S>,
123	        S: AsRef<std::ffi::OsStr>,
124	        D: AsRef<std::ffi::OsStr>,
125	    {
126	        Self::spawn_program_in_env(program, args, size, cwd, std::iter::empty::<(&str, &str)>())
127	    }
128
129	    /// Like [`Self::spawn_program_in`] but also sets `env` vars on the child
130	    /// (manifest `[env.<VAR>]` direction = inject — REQ-HAZARD-ENV-SUBST). Each
131	    /// `(key, value)` is applied to the spawned process's environment ON TOP of
132	    /// the inherited env, so an adapter's per-endpoint injects (e.g.
133	    /// `SPT_ENDPOINT_ID = {id}`) actually reach the harness child.
134	    // [impl->REQ-HAZARD-ENV-SUBST]
135	    // [impl->REQ-HAZARD-WIN-PTY-PROGRAM-RESOLVE]
136	    pub fn spawn_program_in_env<P, A, S, D, E, K, V>(
137	        program: P,
138	        args: A,
139	        size: SurfaceSize,
140	        cwd: Option<D>,
141	        env: E,
142	    ) -> Result<Self, SurfaceError>
143	    where
144	        P: AsRef<std::ffi::OsStr>,
145	        A: IntoIterator<Item = S>,
146	        S: AsRef<std::ffi::OsStr>,
147	        D: AsRef<std::ffi::OsStr>,
148	        E: IntoIterator<Item = (K, V)>,
149	        K: AsRef<std::ffi::OsStr>,
150	        V: AsRef<std::ffi::OsStr>,
151	    {
152	        // Resolve the program for the OS spawn before portable-pty's own `which`
153	        // can pick an extensionless shebang shim that CreateProcessW rejects
154	        // (os error 193). On Windows this PATHEXT-resolves a bare name and wraps a
155	        // non-PE target (.cmd/.bat → cmd.exe, .ps1 → powershell); off Windows it
156	        // is a passthrough. The wrap prefix args precede the caller's args.
157	        // [impl->REQ-HAZARD-WIN-PTY-PROGRAM-RESOLVE]
158	        let (program, prefix) = crate::winprog::resolve_for_pty(program.as_ref());
159	        let mut cmd = CommandBuilder::new(&program);
160	        for a in prefix {
161	            cmd.arg(a);
162	        }
163	        for a in args {
164	            cmd.arg(a);
165	        }
166	        if let Some(dir) = cwd {
167	            cmd.cwd(dir);
168	        }
169	        for (k, v) in env {
170	            cmd.env(k, v);
171	        }
172	        Self::spawn(cmd, size)
173	    }
174
175	    /// Take the master reader. Returns [`SurfaceError::ReaderTaken`] on the
176	    /// second call — output is drained by exactly one consumer (the A2 drain
177	    /// thread in the hosted path; a test consumer otherwise).
178	    pub fn take_reader(&self) -> Result<Box<dyn Read + Send>, SurfaceError> {
179	        self.reader
180	            .lock()
181	            .unwrap()
182	            .take()
183	            .ok_or(SurfaceError::ReaderTaken)
184	    }
185
186	    /// The shared writer handle — cloned to the A2 DSR drain thread so its
187	    /// auto-answer goes through the same serialized writer the caller injects
188	    /// input through (no torn writes).
189	    pub(crate) fn shared_writer(&self) -> SharedWriter {
190	        Arc::clone(&self.writer)
191	    }
192
193	    /// Poll for child exit without blocking.
194	    pub fn try_wait(&self) -> Result<Option<ExitStatus>, SurfaceError> {
195	        self.child
196	            .lock()
197	            .unwrap()
198	            .try_wait()
199	            .map_err(SurfaceError::Io)
200	    }
201
202	    /// Block until the child exits, yielding its status.
203	    pub fn wait(&self) -> Result<ExitStatus, SurfaceError> {
204	        self.child.lock().unwrap().wait().map_err(SurfaceError::Io)
205	    }
206
207	    /// Terminate the child. Safe to call from another thread while `wait`
208	    /// blocks (uses the split `ChildKiller`). Confirm the reap via
209	    /// [`PtySession::wait`] / [`PtySession::try_wait`].
210	    pub fn kill(&self) -> Result<(), SurfaceError> {
211	        let res = self.killer.lock().unwrap().kill();
212	        #[cfg(windows)]
213	        {
214	            // portable-pty 0.8's Windows `ChildKiller` returns the spurious
215	            // `last_os_error` even when `TerminateProcess` *succeeds* (the
216	            // upstream success check is inverted; `WinChild::kill` swallows it,
217	            // the cloned killer does not). Its Result is therefore untrustworthy
218	            // — kill is best-effort; confirm death by reaping.
219	            let _ = res;
220	            Ok(())
221	        }
222	        #[cfg(unix)]
223	        {
224	            res.map_err(SurfaceError::Io)
225	        }
226	    }
227
228	    /// The child's OS process id, if the backend exposes one.
229	    pub fn process_id(&self) -> Option<u32> {
230	        self.pid
231	    }
232	}
233
234	// [impl->REQ-TERM-1]
235	impl SessionSurface for PtySession {
236	    fn write_input(&self, bytes: &[u8]) -> Result<(), SurfaceError> {
237	        let mut w = self.writer.lock().unwrap();
238	        w.write_all(bytes).map_err(SurfaceError::Io)?;
239	        w.flush().map_err(SurfaceError::Io)
240	    }
241
242	    fn resize(&self, size: SurfaceSize) -> Result<(), SurfaceError> {
243	        // Hold the master lock so resize never races a concurrent reader/resize
244	        // (Spike #5: resize races the reader on Windows).
245	        self.master
246	            .lock()
247	            .unwrap()
248	            .resize(size.into())
249	            .map_err(|e| SurfaceError::Io(io_other(e)))
250	    }
251	}
252
253	/// Fold a `portable-pty` error (a private `anyhow::Error` alias) into a
254	/// `std::io::Error` so the crate's public surface never leaks `anyhow`. Generic
255	/// over `Display` to avoid naming the un-exported error type.
256	fn io_other<E: std::fmt::Display>(e: E) -> std::io::Error {
257	    std::io::Error::other(e.to_string())
258	}
259

//! Native-PTY session backend — ConPTY on Windows 10+, `forkpty(3)` on Unix,
//! both via `portable-pty` (the exact crate + handoff shape Spikes #1 / #4
//! proved).
//!
//! [`PtySession`] spawns a child under a real PTY, owns the master reader and
//! writer, and serves the [`SessionSurface`] contract. It is **single-surface
//! per handle** — one child's PTY, not the multiplexed fleet (that supervisor is
//! the M3b broker).
//!
//! ## Concurrency discipline (Spike #5, binding)
//!
//! `resize()` races the reader thread on Windows; the master is therefore held
//! behind a `Mutex` and every `resize` takes that lock. The writer is a shared
//! `Arc<Mutex<…>>` so the A2 DSR drain thread can answer ConPTY's startup
//! cursor query through the same serialized writer the caller injects input
//! through — no torn writes.

use std::io::{Read, Write};
use std::sync::{Arc, Mutex};

use portable_pty::{native_pty_system, Child, ChildKiller, CommandBuilder, ExitStatus, MasterPty};

use crate::surface::{SessionSurface, SurfaceError, SurfaceSize};

/// Shared, serialized handle to the PTY master writer. Cloned to the A2 drain
/// thread so the DSR auto-answer and caller input never interleave mid-write.
pub(crate) type SharedWriter = Arc<Mutex<Box<dyn Write + Send>>>;

/// A child process running under a native PTY, exposed as a [`SessionSurface`].
///
/// Build one with [`PtySession::spawn`]; drive input via [`SessionSurface::write_input`]
/// (or the A3 `send_keys`/`send_line` helpers), drain output via
/// [`PtySession::take_reader`], and observe lifecycle via [`PtySession::try_wait`] /
/// [`PtySession::wait`] / [`PtySession::kill`].
pub struct PtySession {
    /// The master end, behind a lock so `resize` never races the reader (Spike #5).
    master: Mutex<Box<dyn MasterPty + Send>>,
    /// The single PTY writer, shared so the A2 drain thread can answer DSR.
    writer: SharedWriter,
    /// The spawned child; behind a lock for `wait`/`try_wait`.
    child: Mutex<Box<dyn Child + Send + Sync>>,
    /// A kill handle split from the child so we can terminate from another
    /// thread even while `wait` blocks (`portable-pty` `ChildKiller`).
    killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
    /// The master reader, taken at most once (`take_reader`).
    reader: Mutex<Option<Box<dyn Read + Send>>>,
    /// The child OS pid, captured at spawn.
    pid: Option<u32>,
}

impl PtySession {
    /// Spawn `cmd` under a fresh native PTY sized to `size`.
    ///
    /// The slave end is dropped immediately after the child is spawned: the
    /// child holds the only remaining slave handle, which lets EOF propagate on
    /// the Unix raw-pipe backend once the child exits. (On Windows the no-EOF
    /// behaviour is governed by the writer, handled by the A2 drain discipline.)
    //
    // [impl->REQ-TERM-1]
    pub fn spawn(cmd: CommandBuilder, size: SurfaceSize) -> Result<Self, SurfaceError> {
        let pty_system = native_pty_system();
        let pair = pty_system
            .openpty(size.into())
            .map_err(|e| SurfaceError::OpenPty(e.to_string()))?;

        let child = pair
            .slave
            .spawn_command(cmd)
            .map_err(|e| SurfaceError::Spawn(e.to_string()))?;
        // Drop the slave so the child owns the only slave-side handle.
        drop(pair.slave);

        let reader = pair
            .master
            .try_clone_reader()
            .map_err(|e| SurfaceError::Io(io_other(e)))?;
        let writer = pair
            .master
            .take_writer()
            .map_err(|e| SurfaceError::Io(io_other(e)))?;
        let killer = child.clone_killer();
        let pid = child.process_id();

        Ok(Self {
            master: Mutex::new(pair.master),
            writer: Arc::new(Mutex::new(writer)),
            child: Mutex::new(child),
            killer: Mutex::new(killer),
            reader: Mutex::new(Some(reader)),
            pid,
        })
    }

    /// Convenience: spawn `program` with `args` under a PTY sized to `size`,
    /// inheriting the caller's working directory.
    pub fn spawn_program<P, A, S>(
        program: P,
        args: A,
        size: SurfaceSize,
    ) -> Result<Self, SurfaceError>
    where
        P: AsRef<std::ffi::OsStr>,
        A: IntoIterator<Item = S>,
        S: AsRef<std::ffi::OsStr>,
    {
        Self::spawn_program_in(program, args, size, None::<&std::ffi::OsStr>)
    }

    /// Spawn `program` with `args` under a PTY sized to `size`, in `cwd` when
    /// `Some` (REQ-HOST-RUN-2: a project-scoped working directory for the
    /// spawned child) — `None` inherits the caller's cwd, identical to
    /// [`Self::spawn_program`].
    // [impl->REQ-HOST-RUN-2]
    pub fn spawn_program_in<P, A, S, D>(
        program: P,
        args: A,
        size: SurfaceSize,
        cwd: Option<D>,
    ) -> Result<Self, SurfaceError>
    where
        P: AsRef<std::ffi::OsStr>,
        A: IntoIterator<Item = S>,
        S: AsRef<std::ffi::OsStr>,
        D: AsRef<std::ffi::OsStr>,
    {
        Self::spawn_program_in_env(program, args, size, cwd, std::iter::empty::<(&str, &str)>())
    }

    /// Like [`Self::spawn_program_in`] but also sets `env` vars on the child
    /// (manifest `[env.<VAR>]` direction = inject — REQ-HAZARD-ENV-SUBST). Each
    /// `(key, value)` is applied to the spawned process's environment ON TOP of
    /// the inherited env, so an adapter's per-endpoint injects (e.g.
    /// `SPT_ENDPOINT_ID = {id}`) actually reach the harness child.
    // [impl->REQ-HAZARD-ENV-SUBST]
    // [impl->REQ-HAZARD-WIN-PTY-PROGRAM-RESOLVE]
    pub fn spawn_program_in_env<P, A, S, D, E, K, V>(
        program: P,
        args: A,
        size: SurfaceSize,
        cwd: Option<D>,
        env: E,
    ) -> Result<Self, SurfaceError>
    where
        P: AsRef<std::ffi::OsStr>,
        A: IntoIterator<Item = S>,
        S: AsRef<std::ffi::OsStr>,
        D: AsRef<std::ffi::OsStr>,
        E: IntoIterator<Item = (K, V)>,
        K: AsRef<std::ffi::OsStr>,
        V: AsRef<std::ffi::OsStr>,
    {
        // Resolve the program for the OS spawn before portable-pty's own `which`
        // can pick an extensionless shebang shim that CreateProcessW rejects
        // (os error 193). On Windows this PATHEXT-resolves a bare name and wraps a
        // non-PE target (.cmd/.bat → cmd.exe, .ps1 → powershell); off Windows it
        // is a passthrough. The wrap prefix args precede the caller's args.
        // [impl->REQ-HAZARD-WIN-PTY-PROGRAM-RESOLVE]
        let (program, prefix) = crate::winprog::resolve_for_pty(program.as_ref());
        let mut cmd = CommandBuilder::new(&program);
        for a in prefix {
            cmd.arg(a);
        }
        for a in args {
            cmd.arg(a);
        }
        if let Some(dir) = cwd {
            cmd.cwd(dir);
        }
        for (k, v) in env {
            cmd.env(k, v);
        }
        Self::spawn(cmd, size)
    }

    /// Take the master reader. Returns [`SurfaceError::ReaderTaken`] on the
    /// second call — output is drained by exactly one consumer (the A2 drain
    /// thread in the hosted path; a test consumer otherwise).
    pub fn take_reader(&self) -> Result<Box<dyn Read + Send>, SurfaceError> {
        self.reader
            .lock()
            .unwrap()
            .take()
            .ok_or(SurfaceError::ReaderTaken)
    }

    /// The shared writer handle — cloned to the A2 DSR drain thread so its
    /// auto-answer goes through the same serialized writer the caller injects
    /// input through (no torn writes).
    pub(crate) fn shared_writer(&self) -> SharedWriter {
        Arc::clone(&self.writer)
    }

    /// Poll for child exit without blocking.
    pub fn try_wait(&self) -> Result<Option<ExitStatus>, SurfaceError> {
        self.child
            .lock()
            .unwrap()
            .try_wait()
            .map_err(SurfaceError::Io)
    }

    /// Block until the child exits, yielding its status.
    pub fn wait(&self) -> Result<ExitStatus, SurfaceError> {
        self.child.lock().unwrap().wait().map_err(SurfaceError::Io)
    }

    /// Terminate the child. Safe to call from another thread while `wait`
    /// blocks (uses the split `ChildKiller`). Confirm the reap via
    /// [`PtySession::wait`] / [`PtySession::try_wait`].
    pub fn kill(&self) -> Result<(), SurfaceError> {
        let res = self.killer.lock().unwrap().kill();
        #[cfg(windows)]
        {
            // portable-pty 0.8's Windows `ChildKiller` returns the spurious
            // `last_os_error` even when `TerminateProcess` *succeeds* (the
            // upstream success check is inverted; `WinChild::kill` swallows it,
            // the cloned killer does not). Its Result is therefore untrustworthy
            // — kill is best-effort; confirm death by reaping.
            let _ = res;
            Ok(())
        }
        #[cfg(unix)]
        {
            res.map_err(SurfaceError::Io)
        }
    }

    /// The child's OS process id, if the backend exposes one.
    pub fn process_id(&self) -> Option<u32> {
        self.pid
    }
}

// [impl->REQ-TERM-1]
impl SessionSurface for PtySession {
    fn write_input(&self, bytes: &[u8]) -> Result<(), SurfaceError> {
        let mut w = self.writer.lock().unwrap();
        w.write_all(bytes).map_err(SurfaceError::Io)?;
        w.flush().map_err(SurfaceError::Io)
    }

    fn resize(&self, size: SurfaceSize) -> Result<(), SurfaceError> {
        // Hold the master lock so resize never races a concurrent reader/resize
        // (Spike #5: resize races the reader on Windows).
        self.master
            .lock()
            .unwrap()
            .resize(size.into())
            .map_err(|e| SurfaceError::Io(io_other(e)))
    }
}

/// Fold a `portable-pty` error (a private `anyhow::Error` alias) into a
/// `std::io::Error` so the crate's public surface never leaks `anyhow`. Generic
/// over `Display` to avoid naming the un-exported error type.
fn io_other<E: std::fmt::Display>(e: E) -> std::io::Error {
    std::io::Error::other(e.to_string())
}


1	//! Output drain pump — the ConPTY-DSR auto-answer reader (M3a A2).
2	//!
3	//! ConPTY can emit a cursor-position query `ESC [ 6 n` and **withhold all child
4	//! stdout until the terminal answers it** (Spike #1: a run captured exactly the
5	//! 4 bytes `\e[6n` and zero program output — the child looked dead but was
6	//! ticking fine; replying `ESC [ 1;1 R` immediately unblocked the full stream).
7	//! Whether the query fires is build- and program-dependent (some ConPTY versions
8	//! never emit it for short commands; an app that queries DSR itself always
9	//! will). A real terminal *is* expected to answer DSR regardless, so the pump
10	//! answers every `ESC[6n` it sees on every platform unconditionally — harmless
11	//! correct CPR on the Unix raw-pipe backend, and the stall-breaker on any ConPTY
12	//! that does withhold. The auto-answer is verified deterministically by injecting
13	//! a synthetic DSR query (it must not depend on the host's ConPTY emitting one).
14	//!
15	//! **No-EOF-while-writer-held (Spike #1 secondary gotcha):** a ConPTY master
16	//! does *not* EOF while the writer is held, so the read loop runs on its own
17	//! thread and shutdown **never gates on a blocking `read()` returning**. The
18	//! broker stops a surface by terminating the child (which tears the PTY down and
19	//! unblocks the read); the [`Drain`] stop flag is a cooperative secondary signal
20	//! observed whenever a read does return. Deterministic, ordered teardown (drop
21	//! the writer, then the master) is the M3b broker's lifecycle responsibility.
22
23	use std::io::{Read, Write};
24	use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
25	use std::sync::Arc;
26	use std::thread;
27
28	use crate::pty::{PtySession, SharedWriter};
29	use crate::surface::SurfaceError;
30
31	/// The ConPTY startup cursor-position query (DSR — Device Status Report). All
32	/// child stdout is withheld until this is answered (REQ-HAZARD-CONPTY-DSR).
33	const DSR_QUERY: &[u8] = b"\x1b[6n";
34	/// The reply: a Cursor Position Report placing the cursor at row 1, col 1. Any
35	/// valid CPR unblocks ConPTY; `1;1` is the minimal answer Spike #1 proved.
36	const DSR_REPLY: &[u8] = b"\x1b[1;1R";
37
38	/// Streaming matcher for [`DSR_QUERY`] that survives chunk boundaries (the 4-byte
39	/// query can split across two `read()`s). `DSR_QUERY` has no proper prefix that
40	/// is also a suffix, so a single "bytes matched so far" counter is sufficient.
41	#[derive(Default)]
42	struct DsrMatcher {
43	    matched: usize,
44	}
45
46	impl DsrMatcher {
47	    /// Feed a chunk; return how many complete DSR queries it completed.
48	    fn scan(&mut self, chunk: &[u8]) -> u64 {
49	        let mut hits = 0;
50	        for &b in chunk {
51	            if b == DSR_QUERY[self.matched] {
52	                self.matched += 1;
53	                if self.matched == DSR_QUERY.len() {
54	                    hits += 1;
55	                    self.matched = 0;
56	                }
57	            } else {
58	                // Restart — but `b` may itself begin a fresh match (e.g. a lone ESC).
59	                self.matched = usize::from(b == DSR_QUERY[0]);
60	            }
61	        }
62	        hits
63	    }
64	}
65
66	/// A running drain pump over a surface's output. Holds live counters and a
67	/// cooperative stop flag; see the module docs for the teardown contract.
68	pub struct Drain {
69	    stop: Arc<AtomicBool>,
70	    dsr_answered: Arc<AtomicU64>,
71	    bytes: Arc<AtomicU64>,
72	    handle: Option<thread::JoinHandle<()>>,
73	}
74
75	impl Drain {
76	    /// Total DSR queries answered so far (live).
77	    pub fn dsr_answered(&self) -> u64 {
78	        self.dsr_answered.load(Ordering::Relaxed)
79	    }
80
81	    /// Total bytes forwarded to the sink so far (live).
82	    pub fn bytes_forwarded(&self) -> u64 {
83	        self.bytes.load(Ordering::Relaxed)
84	    }
85
86	    /// Ask the pump to stop. Observed the next time a `read()` returns; it does
87	    /// **not** interrupt a read already blocked (that requires tearing the child
88	    /// down — the no-EOF-while-writer-held discipline).
89	    pub fn request_stop(&self) {
90	        self.stop.store(true, Ordering::Relaxed);
91	    }
92
93	    /// Join the pump thread. Only returns once the underlying `read()` has
94	    /// unblocked (EOF / error / a returned read that observes the stop flag) —
95	    /// on ConPTY that means the child must be torn down first. Best-effort.
96	    pub fn join(mut self) {
97	        if let Some(h) = self.handle.take() {
98	            let _ = h.join();
99	        }
100	    }
101	}
102
103	impl Drain {
104	    /// Spawn the drain thread over `reader`, answering DSR via `writer` and
105	    /// forwarding every byte to `sink`. The `sink` may block (that is how A4's
106	    /// bounded channel applies backpressure).
107	    fn spawn<R, S>(reader: R, writer: SharedWriter, mut sink: S) -> Self
108	    where
109	        R: Read + Send + 'static,
110	        S: FnMut(&[u8]) + Send + 'static,
111	    {
112	        let stop = Arc::new(AtomicBool::new(false));
113	        let dsr_answered = Arc::new(AtomicU64::new(0));
114	        let bytes = Arc::new(AtomicU64::new(0));
115
116	        let t_stop = Arc::clone(&stop);
117	        let t_dsr = Arc::clone(&dsr_answered);
118	        let t_bytes = Arc::clone(&bytes);
119
120	        let handle = thread::spawn(move || {
121	            let mut reader = reader;
122	            let mut matcher = DsrMatcher::default();
123	            let mut buf = [0u8; 8192];
124	            loop {
125	                if t_stop.load(Ordering::Relaxed) {
126	                    break;
127	                }
128	                match reader.read(&mut buf) {
129	                    Ok(0) => break, // EOF — child gone and handles released
130	                    Ok(n) => {
131	                        let chunk = &buf[..n];
132	                        let hits = matcher.scan(chunk);
133	                        if hits > 0 {
134	                            // Answer through the shared, serialized writer so the
135	                            // CPR never interleaves with caller-injected input.
136	                            if let Ok(mut w) = writer.lock() {
137	                                for _ in 0..hits {
138	                                    let _ = w.write_all(DSR_REPLY);
139	                                }
140	                                let _ = w.flush();
141	                            }
142	                            t_dsr.fetch_add(hits, Ordering::Relaxed);
143	                        }
144	                        sink(chunk);
145	                        t_bytes.fetch_add(n as u64, Ordering::Relaxed);
146	                    }
147	                    Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
148	                    Err(_) => break,
149	                }
150	            }
151	        });
152
153	        Drain {
154	            stop,
155	            dsr_answered,
156	            bytes,
157	            handle: Some(handle),
158	        }
159	    }
160	}
161
162	impl PtySession {
163	    /// Start draining this surface's output on a background thread, answering
164	    /// ConPTY's DSR query and forwarding every byte to `sink`. Takes the master
165	    /// reader (once — a second call returns [`SurfaceError::ReaderTaken`]).
166	    ///
167	    /// This is the substrate the A4 bounded byte-stream and the A5 digest ride.
168	    //
169	    // [impl->REQ-HAZARD-CONPTY-DSR]
170	    pub fn drain<S>(&self, sink: S) -> Result<Drain, SurfaceError>
171	    where
172	        S: FnMut(&[u8]) + Send + 'static,
173	    {
174	        let reader = self.take_reader()?;
175	        let writer = self.shared_writer();
176	        Ok(Drain::spawn(reader, writer, sink))
177	    }
178	}
179
180	#[cfg(test)]
181	mod tests {
182	    use super::*;
183	    use std::sync::Mutex;
184	    use std::time::Duration;
185
186	    // [unit->REQ-HAZARD-CONPTY-DSR]
187	    #[test]
188	    fn dsr_matcher_finds_query_across_chunk_boundary() {
189	        let mut m = DsrMatcher::default();
190	        // Split `\x1b[6n` across two feeds.
191	        assert_eq!(m.scan(b"output\x1b["), 0);
192	        assert_eq!(m.scan(b"6nmore"), 1);
193	        // A lone ESC that restarts a match mid-stream, then completes.
194	        let mut m = DsrMatcher::default();
195	        assert_eq!(m.scan(b"\x1b\x1b[6n"), 1);
196	        // Two back-to-back queries.
197	        let mut m = DsrMatcher::default();
198	        assert_eq!(m.scan(b"\x1b[6n\x1b[6n"), 2);
199	    }
200
201	    /// A reader that yields one scripted chunk then EOFs — models a ConPTY
202	    /// stream that emits a DSR query, independent of the host's real ConPTY.
203	    struct ScriptedReader {
204	        chunks: std::vec::IntoIter<Vec<u8>>,
205	    }
206	    impl Read for ScriptedReader {
207	        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
208	            match self.chunks.next() {
209	                Some(c) => {
210	                    let n = c.len().min(buf.len());
211	                    buf[..n].copy_from_slice(&c[..n]);
212	                    Ok(n)
213	                }
214	                None => Ok(0),
215	            }
216	        }
217	    }
218
219	    /// A writer capturing everything written, behind the SharedWriter shape.
220	    fn capturing_writer() -> (SharedWriter, Arc<Mutex<Vec<u8>>>) {
221	        struct Cap(Arc<Mutex<Vec<u8>>>);
222	        impl Write for Cap {
223	            fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
224	                self.0.lock().unwrap().extend_from_slice(b);
225	                Ok(b.len())
226	            }
227	            fn flush(&mut self) -> std::io::Result<()> {
228	                Ok(())
229	            }
230	        }
231	        let sink = Arc::new(Mutex::new(Vec::new()));
232	        let w: SharedWriter = Arc::new(Mutex::new(Box::new(Cap(Arc::clone(&sink)))));
233	        (w, sink)
234	    }
235
236	    // The auto-answer fires deterministically on a synthetic DSR query and the
237	    // CPR reply reaches the writer — no dependence on the host's ConPTY.
238	    // [unit->REQ-HAZARD-CONPTY-DSR]
239	    #[test]
240	    fn auto_answers_synthetic_dsr_query() {
241	        let reader = ScriptedReader {
242	            chunks: vec![b"before\x1b[6nafter".to_vec()].into_iter(),
243	        };
244	        let (writer, written) = capturing_writer();
245	        let forwarded = Arc::new(Mutex::new(Vec::new()));
246	        let fsink = Arc::clone(&forwarded);
247
248	        let drain = Drain::spawn(reader, writer, move |c| {
249	            fsink.lock().unwrap().extend_from_slice(c)
250	        });
251	        drain.join(); // ScriptedReader EOFs, so this returns
252
253	        assert_eq!(
254	            &*written.lock().unwrap(),
255	            DSR_REPLY,
256	            "the pump must answer the DSR query with exactly one CPR reply"
257	        );
258	        assert_eq!(
259	            &*forwarded.lock().unwrap(),
260	            b"before\x1b[6nafter",
261	            "every byte (query included) is forwarded verbatim to the sink"
262	        );
263	    }
264
265	    // A stream with no DSR query is forwarded untouched and answers nothing.
266	    // [unit->REQ-HAZARD-CONPTY-DSR]
267	    #[test]
268	    fn no_dsr_means_no_answer() {
269	        let reader = ScriptedReader {
270	            chunks: vec![b"plain output, no query".to_vec()].into_iter(),
271	        };
272	        let (writer, written) = capturing_writer();
273	        let drain = Drain::spawn(reader, writer, |_| {});
274	        // Let the thread finish the one read + EOF.
275	        for _ in 0..100 {
276	            if drain.bytes_forwarded() > 0 {
277	                break;
278	            }
279	            std::thread::sleep(Duration::from_millis(5));
280	        }
281	        let answered = drain.dsr_answered();
282	        drain.join();
283	        assert_eq!(answered, 0, "no DSR query → no CPR answer");
284	        assert!(written.lock().unwrap().is_empty(), "nothing written back");
285	    }
286	}
287

//! Output drain pump — the ConPTY-DSR auto-answer reader (M3a A2).
//!
//! ConPTY can emit a cursor-position query `ESC [ 6 n` and **withhold all child
//! stdout until the terminal answers it** (Spike #1: a run captured exactly the
//! 4 bytes `\e[6n` and zero program output — the child looked dead but was
//! ticking fine; replying `ESC [ 1;1 R` immediately unblocked the full stream).
//! Whether the query fires is build- and program-dependent (some ConPTY versions
//! never emit it for short commands; an app that queries DSR itself always
//! will). A real terminal *is* expected to answer DSR regardless, so the pump
//! answers every `ESC[6n` it sees on every platform unconditionally — harmless
//! correct CPR on the Unix raw-pipe backend, and the stall-breaker on any ConPTY
//! that does withhold. The auto-answer is verified deterministically by injecting
//! a synthetic DSR query (it must not depend on the host's ConPTY emitting one).
//!
//! **No-EOF-while-writer-held (Spike #1 secondary gotcha):** a ConPTY master
//! does *not* EOF while the writer is held, so the read loop runs on its own
//! thread and shutdown **never gates on a blocking `read()` returning**. The
//! broker stops a surface by terminating the child (which tears the PTY down and
//! unblocks the read); the [`Drain`] stop flag is a cooperative secondary signal
//! observed whenever a read does return. Deterministic, ordered teardown (drop
//! the writer, then the master) is the M3b broker's lifecycle responsibility.

use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;

use crate::pty::{PtySession, SharedWriter};
use crate::surface::SurfaceError;

/// The ConPTY startup cursor-position query (DSR — Device Status Report). All
/// child stdout is withheld until this is answered (REQ-HAZARD-CONPTY-DSR).
const DSR_QUERY: &[u8] = b"\x1b[6n";
/// The reply: a Cursor Position Report placing the cursor at row 1, col 1. Any
/// valid CPR unblocks ConPTY; `1;1` is the minimal answer Spike #1 proved.
const DSR_REPLY: &[u8] = b"\x1b[1;1R";

/// Streaming matcher for [`DSR_QUERY`] that survives chunk boundaries (the 4-byte
/// query can split across two `read()`s). `DSR_QUERY` has no proper prefix that
/// is also a suffix, so a single "bytes matched so far" counter is sufficient.
#[derive(Default)]
struct DsrMatcher {
    matched: usize,
}

impl DsrMatcher {
    /// Feed a chunk; return how many complete DSR queries it completed.
    fn scan(&mut self, chunk: &[u8]) -> u64 {
        let mut hits = 0;
        for &b in chunk {
            if b == DSR_QUERY[self.matched] {
                self.matched += 1;
                if self.matched == DSR_QUERY.len() {
                    hits += 1;
                    self.matched = 0;
                }
            } else {
                // Restart — but `b` may itself begin a fresh match (e.g. a lone ESC).
                self.matched = usize::from(b == DSR_QUERY[0]);
            }
        }
        hits
    }
}

/// A running drain pump over a surface's output. Holds live counters and a
/// cooperative stop flag; see the module docs for the teardown contract.
pub struct Drain {
    stop: Arc<AtomicBool>,
    dsr_answered: Arc<AtomicU64>,
    bytes: Arc<AtomicU64>,
    handle: Option<thread::JoinHandle<()>>,
}

impl Drain {
    /// Total DSR queries answered so far (live).
    pub fn dsr_answered(&self) -> u64 {
        self.dsr_answered.load(Ordering::Relaxed)
    }

    /// Total bytes forwarded to the sink so far (live).
    pub fn bytes_forwarded(&self) -> u64 {
        self.bytes.load(Ordering::Relaxed)
    }

    /// Ask the pump to stop. Observed the next time a `read()` returns; it does
    /// **not** interrupt a read already blocked (that requires tearing the child
    /// down — the no-EOF-while-writer-held discipline).
    pub fn request_stop(&self) {
        self.stop.store(true, Ordering::Relaxed);
    }

    /// Join the pump thread. Only returns once the underlying `read()` has
    /// unblocked (EOF / error / a returned read that observes the stop flag) —
    /// on ConPTY that means the child must be torn down first. Best-effort.
    pub fn join(mut self) {
        if let Some(h) = self.handle.take() {
            let _ = h.join();
        }
    }
}

impl Drain {
    /// Spawn the drain thread over `reader`, answering DSR via `writer` and
    /// forwarding every byte to `sink`. The `sink` may block (that is how A4's
    /// bounded channel applies backpressure).
    fn spawn<R, S>(reader: R, writer: SharedWriter, mut sink: S) -> Self
    where
        R: Read + Send + 'static,
        S: FnMut(&[u8]) + Send + 'static,
    {
        let stop = Arc::new(AtomicBool::new(false));
        let dsr_answered = Arc::new(AtomicU64::new(0));
        let bytes = Arc::new(AtomicU64::new(0));

        let t_stop = Arc::clone(&stop);
        let t_dsr = Arc::clone(&dsr_answered);
        let t_bytes = Arc::clone(&bytes);

        let handle = thread::spawn(move || {
            let mut reader = reader;
            let mut matcher = DsrMatcher::default();
            let mut buf = [0u8; 8192];
            loop {
                if t_stop.load(Ordering::Relaxed) {
                    break;
                }
                match reader.read(&mut buf) {
                    Ok(0) => break, // EOF — child gone and handles released
                    Ok(n) => {
                        let chunk = &buf[..n];
                        let hits = matcher.scan(chunk);
                        if hits > 0 {
                            // Answer through the shared, serialized writer so the
                            // CPR never interleaves with caller-injected input.
                            if let Ok(mut w) = writer.lock() {
                                for _ in 0..hits {
                                    let _ = w.write_all(DSR_REPLY);
                                }
                                let _ = w.flush();
                            }
                            t_dsr.fetch_add(hits, Ordering::Relaxed);
                        }
                        sink(chunk);
                        t_bytes.fetch_add(n as u64, Ordering::Relaxed);
                    }
                    Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
                    Err(_) => break,
                }
            }
        });

        Drain {
            stop,
            dsr_answered,
            bytes,
            handle: Some(handle),
        }
    }
}

impl PtySession {
    /// Start draining this surface's output on a background thread, answering
    /// ConPTY's DSR query and forwarding every byte to `sink`. Takes the master
    /// reader (once — a second call returns [`SurfaceError::ReaderTaken`]).
    ///
    /// This is the substrate the A4 bounded byte-stream and the A5 digest ride.
    //
    // [impl->REQ-HAZARD-CONPTY-DSR]
    pub fn drain<S>(&self, sink: S) -> Result<Drain, SurfaceError>
    where
        S: FnMut(&[u8]) + Send + 'static,
    {
        let reader = self.take_reader()?;
        let writer = self.shared_writer();
        Ok(Drain::spawn(reader, writer, sink))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;
    use std::time::Duration;

    // [unit->REQ-HAZARD-CONPTY-DSR]
    #[test]
    fn dsr_matcher_finds_query_across_chunk_boundary() {
        let mut m = DsrMatcher::default();
        // Split `\x1b[6n` across two feeds.
        assert_eq!(m.scan(b"output\x1b["), 0);
        assert_eq!(m.scan(b"6nmore"), 1);
        // A lone ESC that restarts a match mid-stream, then completes.
        let mut m = DsrMatcher::default();
        assert_eq!(m.scan(b"\x1b\x1b[6n"), 1);
        // Two back-to-back queries.
        let mut m = DsrMatcher::default();
        assert_eq!(m.scan(b"\x1b[6n\x1b[6n"), 2);
    }

    /// A reader that yields one scripted chunk then EOFs — models a ConPTY
    /// stream that emits a DSR query, independent of the host's real ConPTY.
    struct ScriptedReader {
        chunks: std::vec::IntoIter<Vec<u8>>,
    }
    impl Read for ScriptedReader {
        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
            match self.chunks.next() {
                Some(c) => {
                    let n = c.len().min(buf.len());
                    buf[..n].copy_from_slice(&c[..n]);
                    Ok(n)
                }
                None => Ok(0),
            }
        }
    }

    /// A writer capturing everything written, behind the SharedWriter shape.
    fn capturing_writer() -> (SharedWriter, Arc<Mutex<Vec<u8>>>) {
        struct Cap(Arc<Mutex<Vec<u8>>>);
        impl Write for Cap {
            fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
                self.0.lock().unwrap().extend_from_slice(b);
                Ok(b.len())
            }
            fn flush(&mut self) -> std::io::Result<()> {
                Ok(())
            }
        }
        let sink = Arc::new(Mutex::new(Vec::new()));
        let w: SharedWriter = Arc::new(Mutex::new(Box::new(Cap(Arc::clone(&sink)))));
        (w, sink)
    }

    // The auto-answer fires deterministically on a synthetic DSR query and the
    // CPR reply reaches the writer — no dependence on the host's ConPTY.
    // [unit->REQ-HAZARD-CONPTY-DSR]
    #[test]
    fn auto_answers_synthetic_dsr_query() {
        let reader = ScriptedReader {
            chunks: vec![b"before\x1b[6nafter".to_vec()].into_iter(),
        };
        let (writer, written) = capturing_writer();
        let forwarded = Arc::new(Mutex::new(Vec::new()));
        let fsink = Arc::clone(&forwarded);

        let drain = Drain::spawn(reader, writer, move |c| {
            fsink.lock().unwrap().extend_from_slice(c)
        });
        drain.join(); // ScriptedReader EOFs, so this returns

        assert_eq!(
            &*written.lock().unwrap(),
            DSR_REPLY,
            "the pump must answer the DSR query with exactly one CPR reply"
        );
        assert_eq!(
            &*forwarded.lock().unwrap(),
            b"before\x1b[6nafter",
            "every byte (query included) is forwarded verbatim to the sink"
        );
    }

    // A stream with no DSR query is forwarded untouched and answers nothing.
    // [unit->REQ-HAZARD-CONPTY-DSR]
    #[test]
    fn no_dsr_means_no_answer() {
        let reader = ScriptedReader {
            chunks: vec![b"plain output, no query".to_vec()].into_iter(),
        };
        let (writer, written) = capturing_writer();
        let drain = Drain::spawn(reader, writer, |_| {});
        // Let the thread finish the one read + EOF.
        for _ in 0..100 {
            if drain.bytes_forwarded() > 0 {
                break;
            }
            std::thread::sleep(Duration::from_millis(5));
        }
        let answered = drain.dsr_answered();
        drain.join();
        assert_eq!(answered, 0, "no DSR query → no CPR answer");
        assert!(written.lock().unwrap().is_empty(), "nothing written back");
    }
}


Core confirmed structurally. The deadlock shape:

- `write_input` (pty.rs:237-239) runs on the **dispatch thread**, does blocking `write_all` on the PTY master while **holding the writer mutex**.
- The drain pump (reader.rs:136) locks the **same writer** to answer a DSR query in injection-induced output.

If injection-induced output fills the harness's PTY output buffer → harness blocks on stdout → stops reading stdin → `write_all` blocks holding the writer → drain blocks on `writer.lock()` for the DSR reply → drain stops draining → harness stays blocked. Both stuck; single-threaded dispatch parked → no new attach. Matches doyle's signal exactly ("one keystroke succeeds, next wedges").

But that's still theory. Need the REAL fixture. Read the fixture + both e2e tests + mock harness.