# 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). **Amended 2026-06-23 (v0.14.2) — raw-inject removed entirely; see "Amendment" below.**
- Milestone: v0.13.0 (amendment: v0.14.2)

## 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.

## Author-time proof: `spt adapter translate-proof`

<!-- [doc->REQ-ADAPTER-TRANSLATE-PROOF] -->

The translation binary is opaque to spt-core and runs deep inside the idle-delivery path (broker →
PTY), so a broken one is hard to diagnose from a live session. `spt adapter translate-proof
<adapter> --event <envelope> [--session <id>]` is the **author-time proof** for it — symmetric to
`spt adapter digest-proof` for the `[digest]` extractor.

It runs the **same driver the daemon runs** (`spt_daemon::translation`), verbatim — no protocol
reimplementation. It:

1. resolves the adapter's declared `[message-idle-translation-binary].path` against the install dir
   (REQ-INSTALL-11), exactly as the daemon does at bringup;
2. spawns it with `TranslationChild::spawn` and sends the identity `{type:"init", endpoint_id, node}`
   line (`endpoint_id` = the adapter option, `node` = this host's label), then the inbound
   `{type:"event", envelope}` line — feeding the `--event` envelope verbatim after filling `{id}`
   (→ the option) and `{session_id}` (→ `--session`, else a placeholder), the same keys the daemon
   fills into a delivered envelope;
3. drains the emitted `{key}`/`{text}`/`{delay_ms}`/`{commit}` keystroke-command stream (until the
   terminating `{commit}` or a short overall budget) and prints it author-readable — each `{key}`
   rendered with its `key_to_bytes` PTY bytes — so the author sees exactly what the binary would
   inject.

**It is the EMIT half only.** It proves the binary's spawn → feed → emit contract; it does **not**
exercise spt-core's atomic PTY apply or the controller-input buffering (that coordination is covered
by the W2 inject-control gate). `--help` states this. The `TranslationChild` `Drop` does the bounded
no-zombie reap, so the proof never leaks the child.

Exit codes mirror digest-proof: **0** ok; **1** on spawn-fail / zero commands / no terminating
`{commit}` / unparseable output (a binary that emits a sequence the broker could never commit would
hit the commit deadline and FAULT live — the proof catches it first, never reading as a silent
success); **2** when the adapter declares no `[message-idle-translation-binary]` section.

## 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.

## Amendment (2026-06-23, v0.14.2) — raw-inject removed entirely

**Operator ruling (2026-06-23):** idle message delivery is exactly **two paths** — harness-hosted
**relay-poll**, or spt-hosted **translation binary** — with **nothing in between**. The v0.11.0
**raw PTY inject is removed as a delivery path**, superseding the original Decision's framing of it as
the "degenerate special case" (this doc, §Decision last line).

- **spt-hosted idle delivery is translation-binary-ONLY.** An adapter that declares no
  `[message-idle-translation-binary]`, **or whose binary fails to spawn**, does **NOT** fall back to
  raw PTY inject. The inbound message stays **SPOOLED** on the poll-feed substrate (§Context) and is
  delivered once a working binary exists — never typed raw into the PTY. A binary-less spt-hosted
  adapter therefore has no idle PTY delivery until it declares one; messages spool (not lost), not
  raw-injected.
- **Spawn failure is loud, not silent.** `build_translation` already logs
  `TRANSLATION_SPAWN_FAILED:<path>: <err>` (broker.rs:1077) — but to the **detached daemon's stderr**,
  so it read as silent in practice. That silent degrade-to-raw-inject is precisely what masked F-019
  (below). Removing the raw-inject fallback makes a failed binary a visible no-delivery (spooled +
  logged), not a broken pseudo-delivery.

**Why:** the silent raw-inject fallback hid a real bug for a multi-hour hunt, and raw `payload+\r`
does not even submit for a modern TUI (Claude Code), so the "degenerate" path was a non-working
degrade, not a safety net.

### F-019 (the bug this surfaced — fixed separately in v0.14.2, ships first)

Independent of the raw-inject removal: the daemon bringup did **not** resolve the translation
binary's `path` against `install_dir`, despite §"Author-time proof" (this doc, step 1) stating it
resolves it "exactly as the daemon does at bringup." The daemon diverged from its own contract — a
bare relative `path = "cc-spt-idle-translate"` hit `Command::new(path)` (not on PATH) → spawn fail →
the (now-removed) silent raw-inject fallback → CC never submitted. Fixed by routing every
adapter-shipped program spawn through `resolve_program_in_dir` (REQ-INSTALL-11): translation binary at
the initial harnesshost spawn **and** the W3d live-update respawn (`read_translation_path`), plus the
adapter **notif** command (notif.rs / reporting.rs via `with_install_dir`). The F-019 path fix lands
first as the immediate CC-on-Windows idle-delivery unblock; this raw-inject removal rides its own
change.
