# 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 section
**`[message-idle-translation-binary]`** — a **table carrying a `path` scalar** (modeled as a table,
not a bare top-level scalar, so a section that precedes it cannot silently absorb the key, and so
it stays N+1 extensible; spt-core does **not** `deny_unknown_fields` here, so a future key against an
older spt-core degrades gracefully rather than hard-failing the manifest). 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.
