1	# v0.13.0 — rc Windows key→xterm-VT translator (design; doyle 2026-06-19)
2
3	**Operator ruling:** proper agnostic translator, **HOLD v0.13.0** for it (full key fidelity in the release). Builder: todlando. Gate: doyle.
4
5	## Root (operator HITL)
6	`spt rc` reads raw **stdin bytes** (`spawn_stdin_reader`, `std::io::stdin().read`). On the Windows **legacy console** (no `ENABLE_VIRTUAL_TERMINAL_INPUT`) arrow/Home/End/PgUp/PgDn/Insert/Delete/F-keys produce console **KEY_EVENTs, not stdin bytes** → the byte-pump sees nothing → those keys are DEAD. Backspace/Ctrl+Backspace work only because they emit a byte the W7 `normalize_key_byte` swap remaps. Enabling `ENABLE_VIRTUAL_TERMINAL_INPUT` was rejected (W7): on Windows Terminal it yields harness-specific **win32-input-mode** and broke ctrl-b detach.
7
8	## Decision: read key EVENTS, emit standard xterm VT (agnostic, full fidelity)
9	On **Windows**, replace the stdin byte-read with a **crossterm event** source (crossterm 0.28 is already a dep; the picker/ratatui already use its events) and translate each `KeyEvent` → standard **xterm VT** bytes, then forward those bytes through the SAME rc pump. The harness receives ordinary xterm VT — the universal terminal contract — so this is harness-AGNOSTIC (no win32-input-mode, no CC-specific assumption). **Unix is UNCHANGED** (its raw-mode byte stream already delivers proper VT bytes; cfg-split, zero Unix regression risk).
10
11	This **SUPERSEDES** `normalize_key_byte` (the W7 swap) on Windows — the translator emits `0x7f` for Backspace and the word-delete sequence for Ctrl+Backspace natively. Remove `normalize_key_byte` from the Windows forward path (keep the REQ-HAZARD-RC-INPUT-KEY-ENCODING history; add a supersede note).
12
13	## Architecture
14	- `spawn_stdin_reader` (Windows cfg): loop on `crossterm::event::read()`; on `Event::Key(ke)` with `ke.kind == KeyEventKind::Press` (DROP Repeat/Release to avoid double-input), call `translate_key_event(ke) -> Vec<u8>` and send `StdinMsg::Bytes`. On `Event::Paste(s)` forward the pasted content bytes (CC handles paste; keep simple — raw content, no bracketed markers unless a harness needs them later). Resize already handled elsewhere.
15	- **Detach at the EVENT level (Windows):** `KeyEvent{code: Char('b'), modifiers: CONTROL}` → `StdinMsg::Detach`. This replaces the byte `parse_stdin_chunk` 0x02 state machine on Windows (clean — no byte-stream ambiguity). Unix keeps the existing 0x02 byte state machine.
16	- **Non-console stdin fallback:** if stdin is redirected (piped — tests, non-interactive), crossterm events aren't available → fall back to the existing byte-read path. Detect via `crossterm::tty::IsTty` / `std::io::stdin().is_terminal()`. (Keeps the dummy-harness/e2e byte-injection path working.)
17	- Unix: byte-pump + `parse_stdin_chunk` + (drop `normalize_key_byte`, it was Windows-only anyway) UNCHANGED.
18
19	## `translate_key_event` — the pure mapping (the unit-tested core, standard xterm)
20	- Plain `Char(c)` no mods → UTF-8 bytes of `c`.
21	- `Char(c)` + CONTROL → the control byte (`Ctrl+A`=0x01 … `Ctrl+Z`=0x1a; `Ctrl+B`=detach, intercepted BEFORE translate). `Char(c)` + ALT → `ESC` + byte(s).
22	- `Enter`→`\r`(0x0d); `Tab`→0x09; `BackTab`→`ESC[Z`; `Esc`→0x1b; `Backspace`→0x7f; `Delete`→`ESC[3~`.
23	- **Ctrl+Backspace** (Backspace+CONTROL) → `0x08` (^H — CC word-delete, the native Win11 behavior the operator wants; this preserves the W7 swap's intent in the translator).
24	- Arrows: Up`ESC[A` Down`ESC[B` Right`ESC[C` Left`ESC[D`. Home`ESC[H` End`ESC[F`. Insert`ESC[2~` PgUp`ESC[5~` PgDn`ESC[6~`.
25	- **Modified special keys (xterm scheme):** `ESC[1;<m><final>` for arrows/Home/End and `ESC[<n>;<m>~` for the `~` keys, where `m = 1 + (Shift?1) + (Alt?2) + (Ctrl?4)`. (e.g. Ctrl+Right = `ESC[1;5C`.)
26	- F-keys: F1`ESC OP` F2`ESC OQ` F3`ESC OR` F4`ESC OS`; F5`ESC[15~` F6`ESC[17~` F7`ESC[18~` F8`ESC[19~` F9`ESC[20~` F10`ESC[21~` F11`ESC[23~` F12`ESC[24~` (modified: the `;<m>` variant).
27	- Unknown/unhandled → empty (drop), never garbage.
28
29	(Use a real xterm reference for the exact byte tables — match a standard terminal verbatim, don't hand-wave; copy-verbatim a known-correct table per ADR-0001 spirit.)
30
31	## REQ + gates
32	- **Mint REQ-RC-KEY-VT-TRANSLATE** (registry-first). Stages **doc, impl, unit**. doc = KNOWN-HAZARDS + CONTEXT note ("Windows rc translates console key events to standard xterm VT; Unix passes through"). Supersede-note on REQ-HAZARD-RC-INPUT-KEY-ENCODING (the swap → folded into the translator's Backspace/Ctrl+Backspace mapping; `normalize_key_byte` removed on Windows).
33	- **No int** — a live interactive console can't be driven in CI (same precedent as REQ-RUN-PICKER / REQ-RC-1: the pump's live loop is HITL). The PURE `translate_key_event` mapping is the unit surface — make it **exhaustive + non-vacuous** (every arrow/Home/End/F-key/modifier-combo/Backspace/Ctrl+Backspace/Ctrl+char asserts its exact xterm byte sequence; detach = Ctrl+B yields Detach not bytes).
34	- Confirm crossterm's `event` feature is on (default in 0.28; the picker already reads events).
35	- clippy --workspace -D warnings; traceable EXIT=0; docs-drift `xtask check` (no CLI change expected, but run it).
36	- **HITL acceptance (operator):** arrows/Home/End/PgUp/PgDn/Delete/F-keys all work in a real CC rc session; Backspace=char, Ctrl+Backspace=word; ctrl-b detach still works; typing smooth (W1b).
37
38	## doyle gate criteria
39	Exhaustive non-vacuous mapping unit (spot-check the table vs a real xterm) · detach-via-Ctrl+B-event unit · normalize_key_byte removed (Windows path) without regressing Backspace/Ctrl+Backspace · Unix path untouched · non-tty fallback preserved (e2e byte-injection still works) · clippy/traceable/docs-drift · then operator HITL = the real acceptance.
40
41	**This BLOCKS the v0.13.0 ship (operator ruling).** It's the last build item: window ✓gated, 3+4 dispatched, this is the big one.
42

# v0.13.0 — rc Windows key→xterm-VT translator (design; doyle 2026-06-19)

**Operator ruling:** proper agnostic translator, **HOLD v0.13.0** for it (full key fidelity in the release). Builder: todlando. Gate: doyle.

## Root (operator HITL)
`spt rc` reads raw **stdin bytes** (`spawn_stdin_reader`, `std::io::stdin().read`). On the Windows **legacy console** (no `ENABLE_VIRTUAL_TERMINAL_INPUT`) arrow/Home/End/PgUp/PgDn/Insert/Delete/F-keys produce console **KEY_EVENTs, not stdin bytes** → the byte-pump sees nothing → those keys are DEAD. Backspace/Ctrl+Backspace work only because they emit a byte the W7 `normalize_key_byte` swap remaps. Enabling `ENABLE_VIRTUAL_TERMINAL_INPUT` was rejected (W7): on Windows Terminal it yields harness-specific **win32-input-mode** and broke ctrl-b detach.

## Decision: read key EVENTS, emit standard xterm VT (agnostic, full fidelity)
On **Windows**, replace the stdin byte-read with a **crossterm event** source (crossterm 0.28 is already a dep; the picker/ratatui already use its events) and translate each `KeyEvent` → standard **xterm VT** bytes, then forward those bytes through the SAME rc pump. The harness receives ordinary xterm VT — the universal terminal contract — so this is harness-AGNOSTIC (no win32-input-mode, no CC-specific assumption). **Unix is UNCHANGED** (its raw-mode byte stream already delivers proper VT bytes; cfg-split, zero Unix regression risk).

This **SUPERSEDES** `normalize_key_byte` (the W7 swap) on Windows — the translator emits `0x7f` for Backspace and the word-delete sequence for Ctrl+Backspace natively. Remove `normalize_key_byte` from the Windows forward path (keep the REQ-HAZARD-RC-INPUT-KEY-ENCODING history; add a supersede note).

## Architecture
- `spawn_stdin_reader` (Windows cfg): loop on `crossterm::event::read()`; on `Event::Key(ke)` with `ke.kind == KeyEventKind::Press` (DROP Repeat/Release to avoid double-input), call `translate_key_event(ke) -> Vec<u8>` and send `StdinMsg::Bytes`. On `Event::Paste(s)` forward the pasted content bytes (CC handles paste; keep simple — raw content, no bracketed markers unless a harness needs them later). Resize already handled elsewhere.
- **Detach at the EVENT level (Windows):** `KeyEvent{code: Char('b'), modifiers: CONTROL}` → `StdinMsg::Detach`. This replaces the byte `parse_stdin_chunk` 0x02 state machine on Windows (clean — no byte-stream ambiguity). Unix keeps the existing 0x02 byte state machine.
- **Non-console stdin fallback:** if stdin is redirected (piped — tests, non-interactive), crossterm events aren't available → fall back to the existing byte-read path. Detect via `crossterm::tty::IsTty` / `std::io::stdin().is_terminal()`. (Keeps the dummy-harness/e2e byte-injection path working.)
- Unix: byte-pump + `parse_stdin_chunk` + (drop `normalize_key_byte`, it was Windows-only anyway) UNCHANGED.

## `translate_key_event` — the pure mapping (the unit-tested core, standard xterm)
- Plain `Char(c)` no mods → UTF-8 bytes of `c`.
- `Char(c)` + CONTROL → the control byte (`Ctrl+A`=0x01 … `Ctrl+Z`=0x1a; `Ctrl+B`=detach, intercepted BEFORE translate). `Char(c)` + ALT → `ESC` + byte(s).
- `Enter`→`\r`(0x0d); `Tab`→0x09; `BackTab`→`ESC[Z`; `Esc`→0x1b; `Backspace`→0x7f; `Delete`→`ESC[3~`.
- **Ctrl+Backspace** (Backspace+CONTROL) → `0x08` (^H — CC word-delete, the native Win11 behavior the operator wants; this preserves the W7 swap's intent in the translator).
- Arrows: Up`ESC[A` Down`ESC[B` Right`ESC[C` Left`ESC[D`. Home`ESC[H` End`ESC[F`. Insert`ESC[2~` PgUp`ESC[5~` PgDn`ESC[6~`.
- **Modified special keys (xterm scheme):** `ESC[1;<m><final>` for arrows/Home/End and `ESC[<n>;<m>~` for the `~` keys, where `m = 1 + (Shift?1) + (Alt?2) + (Ctrl?4)`. (e.g. Ctrl+Right = `ESC[1;5C`.)
- F-keys: F1`ESC OP` F2`ESC OQ` F3`ESC OR` F4`ESC OS`; F5`ESC[15~` F6`ESC[17~` F7`ESC[18~` F8`ESC[19~` F9`ESC[20~` F10`ESC[21~` F11`ESC[23~` F12`ESC[24~` (modified: the `;<m>` variant).
- Unknown/unhandled → empty (drop), never garbage.

(Use a real xterm reference for the exact byte tables — match a standard terminal verbatim, don't hand-wave; copy-verbatim a known-correct table per ADR-0001 spirit.)

## REQ + gates
- **Mint REQ-RC-KEY-VT-TRANSLATE** (registry-first). Stages **doc, impl, unit**. doc = KNOWN-HAZARDS + CONTEXT note ("Windows rc translates console key events to standard xterm VT; Unix passes through"). Supersede-note on REQ-HAZARD-RC-INPUT-KEY-ENCODING (the swap → folded into the translator's Backspace/Ctrl+Backspace mapping; `normalize_key_byte` removed on Windows).
- **No int** — a live interactive console can't be driven in CI (same precedent as REQ-RUN-PICKER / REQ-RC-1: the pump's live loop is HITL). The PURE `translate_key_event` mapping is the unit surface — make it **exhaustive + non-vacuous** (every arrow/Home/End/F-key/modifier-combo/Backspace/Ctrl+Backspace/Ctrl+char asserts its exact xterm byte sequence; detach = Ctrl+B yields Detach not bytes).
- Confirm crossterm's `event` feature is on (default in 0.28; the picker already reads events).
- clippy --workspace -D warnings; traceable EXIT=0; docs-drift `xtask check` (no CLI change expected, but run it).
- **HITL acceptance (operator):** arrows/Home/End/PgUp/PgDn/Delete/F-keys all work in a real CC rc session; Backspace=char, Ctrl+Backspace=word; ctrl-b detach still works; typing smooth (W1b).

## doyle gate criteria
Exhaustive non-vacuous mapping unit (spot-check the table vs a real xterm) · detach-via-Ctrl+B-event unit · normalize_key_byte removed (Windows path) without regressing Backspace/Ctrl+Backspace · Unix path untouched · non-tty fallback preserved (e2e byte-injection still works) · clippy/traceable/docs-drift · then operator HITL = the real acceptance.

**This BLOCKS the v0.13.0 ship (operator ruling).** It's the last build item: window ✓gated, 3+4 dispatched, this is the big one.


Design is thorough. Ground the current rc input layer (the surgical site) so the JIT plan is grounded:


Grep

fn spawn_stdin_reader|fn parse_stdin_chunk|fn normalize_key_byte|enum StdinMsg|StdinMsg::|is_terminal|IsTty|KeyEventKind|crossterm


46:enum StdinMsg {
93:fn normalize_key_byte(b: u8) -> u8 {
105:        RawGuard(crossterm::terminal::enable_raw_mode().is_ok())
111:            let _ = crossterm::terminal::disable_raw_mode();
153:fn parse_stdin_chunk(armed: &mut bool, input: &[u8]) -> DetachParse {
178:/// Spawn the stdin reader: raw bytes → `StdinMsg::Bytes`, watching for the
179:/// detach keybind ([`parse_stdin_chunk`]). Detaching sends `StdinMsg::Detach`
182:fn spawn_stdin_reader(tx: mpsc::Sender<StdinMsg>) {
214:            if !forward.is_empty() && tx.send(StdinMsg::Bytes(forward)).is_err() {
218:                let _ = tx.send(StdinMsg::Detach);
315:        if let Ok((cols, rows)) = crossterm::terminal::size() {
472:    // hooking SIGWINCH / the crossterm event loop, which would conflict with the
486:            if let Ok((cols, rows)) = crossterm::terminal::size() {
498:                    Ok(StdinMsg::Bytes(bytes)) => {
502:                    Ok(StdinMsg::Detach) => return Ok(detach(brain, stream_id)),
555:                            match crossterm::terminal::size() {


44
45	/// A message from the stdin reader thread to the pump.
46	enum StdinMsg {
47	    /// Raw bytes to forward to the session as `AttachRecord::Input`.
48	    Bytes(Vec<u8>),
49	    /// The operator hit the detach keybind — end the viewport, leave the session.
50	    Detach,
51	}
52
53	/// Mint operator op ids from a random 64-bit base so two `spt rc` sessions never
54	/// collide in the broker's net-op journal (a journaled `send_attach_input` op is
55	/// exactly-once at the PTY — a reused id from a prior session would be deduped,
56	/// dropping the keystroke). Random base + monotonic counter.
57	struct OpMinter(AtomicU64);
58	impl OpMinter {
59	    fn new() -> Self {
60	        let seed = spt_proto::identity::Identity::generate().seed();
61	        let base = u64::from_le_bytes(seed[..8].try_into().expect("8 bytes"));
62	        // Keep it well below u64::MAX so the counter can't wrap into another
63	        // session's base within any realistic session length.
64	        OpMinter(AtomicU64::new(base >> 1))
65	    }
66	    fn next(&self) -> u64 {
67	        self.0.fetch_add(1, Ordering::Relaxed)
68	    }
69	}
70
71	/// W7 (REQ-HAZARD-RC-INPUT-KEY-ENCODING) Windows key normalize. The legacy
72	/// Windows console delivers Backspace as `^H` (0x08), which Claude Code maps to
73	/// backward-kill-word — the operator's whole-word delete. Map a lone 0x08 → VT
74	/// DEL (0x7f, char-delete) in the raw forward path; the detach prefix (0x02) and
75	/// every other byte pass through untouched. A real `ctrl+h` is also 0x08 and is
76	/// conflated with Backspace — an accepted minor loss (Backspace is the common
77	/// key). Pure so it unit-tests without a console.
78	///
79	/// This is the REVERT-AND-NARROW of dc07c39: enabling
80	/// `ENABLE_VIRTUAL_TERMINAL_INPUT` was the WRONG lever — on Windows Terminal it
81	/// yields win32-input-mode (6-field `ESC[Vk;Sc;Uc;Kd;Cs;Rc_` key records), not
82	/// legacy xterm VT, which broke the ctrl-b detach (0x02 wrapped, never seen as a
83	/// raw byte) and fed win32-input-mode into Claude Code's own ConPTY input
84	/// negotiation. Staying a raw byte pump + this one-byte map keeps detach working
85	/// and is terminal-convention-minimal. SWAP (not one-way): this terminal sends
86	/// Backspace as 0x08 (^H) and Ctrl+Backspace as 0x7f (DEL); CC reads ^H as
87	/// backward-kill-word and DEL as char-delete. Swapping the two input bytes maps
88	/// Backspace→char-delete and Ctrl+Backspace→word-delete — the native Win11
89	/// convention (the operator rejected losing word-delete). 0x02 detach is consumed
90	/// upstream before normalize; every other byte passes through.
91	// [impl->REQ-HAZARD-RC-INPUT-KEY-ENCODING]
92	#[cfg(windows)]
93	fn normalize_key_byte(b: u8) -> u8 {
94	    match b {
95	        0x08 => 0x7f,
96	        0x7f => 0x08,
97	        other => other,
98	    }
99	}
100
101	/// Restore the terminal out of raw mode no matter how the pump returns.
102	struct RawGuard(bool);
103	impl RawGuard {
104	    fn enable() -> Self {
105	        RawGuard(crossterm::terminal::enable_raw_mode().is_ok())
106	    }
107	}
108	impl Drop for RawGuard {
109	    fn drop(&mut self) {
110	        if self.0 {
111	            let _ = crossterm::terminal::disable_raw_mode();
112	        }
113	    }
114	}
115
116	/// Resolve a live endpoint id to its broker session id via the broker's hosted
117	/// session table (the spawn's endpoint label, brain.sessions()). `None` ⇒ no
118	/// live session under that endpoint.
119	fn resolve_session(brain: &mut Brain, endpoint_id: &str) -> std::io::Result<Option<u64>> {
120	    let reply = brain.sessions()?;
121	    Ok(reply
122	        .sessions
123	        .into_iter()
124	        .find(|s| s.endpoint == endpoint_id)
125	        .map(|s| s.session_id))
126	}
127
128	/// The node currently driving `endpoint_id`, if any — read off the perch's
129	/// broker-owned `driven_by` marker (REQ-RCVIEW-1). Drives the client-side
130	/// busy-refuse guidance. `None` ⇒ free (or no perch).
131	fn current_driver(endpoint_id: &str) -> Option<String> {
132	    let perch = spt_store::perch::resolve_perch_path(
133	        endpoint_id,
134	        spt_store::perch::ParentHint::Infer,
135	    );
136	    spt_store::info::read_info(&perch).and_then(|i| i.driven_by)
137	}
138
139	/// What one stdin chunk yielded after the detach state machine: the bytes to
140	/// forward to the session, and whether the detach keybind fired.
141	struct DetachParse {
142	    forward: Vec<u8>,
143	    detach: bool,
144	}
145
146	/// Apply the `ctrl-b` detach state machine to one raw stdin chunk. `armed` holds
147	/// the "saw the prefix" state across chunk boundaries (a prefix can end one read
148	/// and its companion key begin the next). `ctrl-b d` → detach; `ctrl-b ctrl-b` →
149	/// one literal `ctrl-b`; `ctrl-b <other>` → prefix + other verbatim; every other
150	/// byte passes through. Pure so it unit-tests without a terminal. On detach the
151	/// bytes BEFORE the keybind are returned to forward; bytes after are dropped (the
152	/// viewport is ending).
153	fn parse_stdin_chunk(armed: &mut bool, input: &[u8]) -> DetachParse {
154	    let mut forward = Vec::with_capacity(input.len());
155	    for &b in input {
156	        if *armed {
157	            *armed = false;
158	            match b {
159	                DETACH_KEY => return DetachParse { forward, detach: true },
160	                DETACH_PREFIX => forward.push(DETACH_PREFIX), // literal ctrl-b
161	                other => {
162	                    forward.push(DETACH_PREFIX);
163	                    forward.push(other);
164	                }
165	            }
166	        } else if b == DETACH_PREFIX {
167	            *armed = true;
168	        } else {
169	            forward.push(b);
170	        }
171	    }
172	    DetachParse {
173	        forward,
174	        detach: false,
175	    }
176	}
177
178	/// Spawn the stdin reader: raw bytes → `StdinMsg::Bytes`, watching for the
179	/// detach keybind ([`parse_stdin_chunk`]). Detaching sends `StdinMsg::Detach`
180	/// and ends the thread; the leaked blocking `read` (if any) dies with the
181	/// process on exit.
182	fn spawn_stdin_reader(tx: mpsc::Sender<StdinMsg>) {
183	    std::thread::spawn(move || {
184	        let mut stdin = std::io::stdin().lock();
185	        let mut buf = [0u8; 1024];
186	        let mut armed = false; // saw the detach prefix, awaiting the next byte
187	        loop {
188	            let n = match stdin.read(&mut buf) {
189	                Ok(0) => break, // stdin closed
190	                Ok(n) => n,
191	                Err(_) => break,
192	            };
193	            // W7 (REQ-HAZARD-RC-INPUT-KEY-ENCODING) confirm-first hexdump: with
194	            // SPT_RC_DEBUG_KEYS set, print the RAW bytes the console delivered for
195	            // each keystroke. Press Backspace then ctrl+Backspace in a real rc
196	            // session — a Backspace that arrives as 0x08 (^H) instead of 0x7f
197	            // (VT DEL) confirms the legacy-console root (ENABLE_VIRTUAL_TERMINAL_INPUT
198	            // off). Gate the root before the fix.
199	            if std::env::var_os("SPT_RC_DEBUG_KEYS").is_some() {
200	                let hex: Vec<String> = buf[..n].iter().map(|b| format!("{b:02x}")).collect();
201	                eprintln!("RC_KEYS n={n} bytes=[{}]", hex.join(" "));
202	            }
203	            let parsed = parse_stdin_chunk(&mut armed, &buf[..n]);
204	            // W7 (REQ-HAZARD-RC-INPUT-KEY-ENCODING): on Windows the legacy console
205	            // sends Backspace as 0x08 (^H → CC kill-word) and Ctrl+Backspace as
206	            // 0x7f (DEL → CC char-delete); SWAP them in the forward path so CC sees
207	            // Backspace=char-delete and Ctrl+Backspace=word-delete (native Win11),
208	            // AFTER the detach state machine has consumed any 0x02 prefix. Unix is
209	            // already VT (and a real ctrl+h there must stay 0x08), so it's Win-only.
210	            #[cfg(windows)]
211	            let forward: Vec<u8> = parsed.forward.into_iter().map(normalize_key_byte).collect();
212	            #[cfg(unix)]
213	            let forward = parsed.forward;
214	            if !forward.is_empty() && tx.send(StdinMsg::Bytes(forward)).is_err() {
215	                break;
216	            }
217	            if parsed.detach {
218	                let _ = tx.send(StdinMsg::Detach);
219	                return;
220	            }
221	        }
222	    });
223	}
224
225	/// Attach a local terminal to the broker-held session for `endpoint_id` under
226	/// `intent` (REQ-RCVIEW-1 / REQ-KICK-1): `Control` drives a free endpoint (and is
227	/// busy-refused with guidance on a controlled one), `Viewer` watches read-only,
228	/// `Take` kicks the incumbent controller (loud `Displaced` notice to them) and
229	/// takes control. Returns when the session exits, the operator detaches, or an
230	/// error occurs.
231	// [impl->REQ-RC-1] [impl->REQ-RCVIEW-1] [impl->REQ-KICK-1]
232	pub fn run_attach(endpoint_id: &str, intent: AttachIntent) -> Result<(), String> {
233	    let view = intent == AttachIntent::Viewer;
234	    // Busy-refuse rendered CLIENT-SIDE (REQ-RCVIEW-1): a plain `Control` attach to
235	    // an endpoint a DIFFERENT node already controls gets actionable guidance here
236	    // (the broker would also refuse on the wire — this is the friendly path). A
237	    // `--view` / `--take` bypasses it (watching coexists; taking displaces).
238	    if intent == AttachIntent::Control {
239	        if let Some(node) = current_driver(endpoint_id) {
240	            println!("'{endpoint_id}' is controlled by {node}.");
241	            println!("  spt rc {endpoint_id} --view   to watch read-only");
242	            println!("  spt rc {endpoint_id} --take   to take control (kicks {node})");
243	            return Ok(());
244	        }
245	    }
246	    // (a) STATUS-GATE (REQ-HAZARD-RC-ATTACH-FAILFAST): a perch with an EXPLICIT
247	    // `status=offline` is not attachable — short-circuit with clean guidance
248	    // [impl->REQ-HAZARD-RC-ATTACH-FAILFAST]
249	    // instead of the broker dance that would block on a dead session (the
250	    // infinite-blank hang). The B2 pull-reconcile / `endpoint stop` stamp a dead
251	    // spt-hosted perch offline, so this catches the common case before any IPC. An
252	    // ABSENT or status-less perch falls through to the broker path, which already
253	    // ends cleanly ("no live session for endpoint") — not a hang. (is_perch_alive
254	    // is NOT used here: it returns true for an absent info.json by interim parity.)
255	    let perch_path =
256	        spt_store::perch::resolve_perch_path(endpoint_id, spt_store::perch::ParentHint::Infer);
257	    if spt_store::info::read_info(&perch_path)
258	        .and_then(|i| i.status)
259	        .as_deref()
260	        == Some(spt_store::liveness::STATUS_OFFLINE)
261	    {
262	        println!("Endpoint '{endpoint_id}' is offline — nothing to attach to.");
263	        println!("  spt endpoint run --adapter <adapter> --id {endpoint_id}   to start it");
264	        return Ok(());
265	    }
266	    spt_daemon::daemon::ensure_running().map_err(|e| format!("daemon start: {e}"))?;
267	    // PUMP mode: the Split reader-thread carrier bounds every read by a deadline
268	    // (a `Whole` conn can't time out — nonblocking is dead on Windows named
269	    // pipes), so the single-threaded pump interleaves stdin + broker events
270	    // without wedging. The generous io_timeout bounds the setup calls (dial,
271	    // sessions, attach-open ack); the pump itself passes its own short slice.
272	    let mut brain = Brain::cold_start_pump(
273	        &spt_daemon::endpoint::broker_socket_name(),

/// A message from the stdin reader thread to the pump.
enum StdinMsg {
    /// Raw bytes to forward to the session as `AttachRecord::Input`.
    Bytes(Vec<u8>),
    /// The operator hit the detach keybind — end the viewport, leave the session.
    Detach,
}

/// Mint operator op ids from a random 64-bit base so two `spt rc` sessions never
/// collide in the broker's net-op journal (a journaled `send_attach_input` op is
/// exactly-once at the PTY — a reused id from a prior session would be deduped,
/// dropping the keystroke). Random base + monotonic counter.
struct OpMinter(AtomicU64);
impl OpMinter {
    fn new() -> Self {
        let seed = spt_proto::identity::Identity::generate().seed();
        let base = u64::from_le_bytes(seed[..8].try_into().expect("8 bytes"));
        // Keep it well below u64::MAX so the counter can't wrap into another
        // session's base within any realistic session length.
        OpMinter(AtomicU64::new(base >> 1))
    }
    fn next(&self) -> u64 {
        self.0.fetch_add(1, Ordering::Relaxed)
    }
}

/// W7 (REQ-HAZARD-RC-INPUT-KEY-ENCODING) Windows key normalize. The legacy
/// Windows console delivers Backspace as `^H` (0x08), which Claude Code maps to
/// backward-kill-word — the operator's whole-word delete. Map a lone 0x08 → VT
/// DEL (0x7f, char-delete) in the raw forward path; the detach prefix (0x02) and
/// every other byte pass through untouched. A real `ctrl+h` is also 0x08 and is
/// conflated with Backspace — an accepted minor loss (Backspace is the common
/// key). Pure so it unit-tests without a console.
///
/// This is the REVERT-AND-NARROW of dc07c39: enabling
/// `ENABLE_VIRTUAL_TERMINAL_INPUT` was the WRONG lever — on Windows Terminal it
/// yields win32-input-mode (6-field `ESC[Vk;Sc;Uc;Kd;Cs;Rc_` key records), not
/// legacy xterm VT, which broke the ctrl-b detach (0x02 wrapped, never seen as a
/// raw byte) and fed win32-input-mode into Claude Code's own ConPTY input
/// negotiation. Staying a raw byte pump + this one-byte map keeps detach working
/// and is terminal-convention-minimal. SWAP (not one-way): this terminal sends
/// Backspace as 0x08 (^H) and Ctrl+Backspace as 0x7f (DEL); CC reads ^H as
/// backward-kill-word and DEL as char-delete. Swapping the two input bytes maps
/// Backspace→char-delete and Ctrl+Backspace→word-delete — the native Win11
/// convention (the operator rejected losing word-delete). 0x02 detach is consumed
/// upstream before normalize; every other byte passes through.
// [impl->REQ-HAZARD-RC-INPUT-KEY-ENCODING]
#[cfg(windows)]
fn normalize_key_byte(b: u8) -> u8 {
    match b {
        0x08 => 0x7f,
        0x7f => 0x08,
        other => other,
    }
}

/// Restore the terminal out of raw mode no matter how the pump returns.
struct RawGuard(bool);
impl RawGuard {
    fn enable() -> Self {
        RawGuard(crossterm::terminal::enable_raw_mode().is_ok())
    }
}
impl Drop for RawGuard {
    fn drop(&mut self) {
        if self.0 {
            let _ = crossterm::terminal::disable_raw_mode();
        }
    }
}

/// Resolve a live endpoint id to its broker session id via the broker's hosted
/// session table (the spawn's endpoint label, brain.sessions()). `None` ⇒ no
/// live session under that endpoint.
fn resolve_session(brain: &mut Brain, endpoint_id: &str) -> std::io::Result<Option<u64>> {
    let reply = brain.sessions()?;
    Ok(reply
        .sessions
        .into_iter()
        .find(|s| s.endpoint == endpoint_id)
        .map(|s| s.session_id))
}

/// The node currently driving `endpoint_id`, if any — read off the perch's
/// broker-owned `driven_by` marker (REQ-RCVIEW-1). Drives the client-side
/// busy-refuse guidance. `None` ⇒ free (or no perch).
fn current_driver(endpoint_id: &str) -> Option<String> {
    let perch = spt_store::perch::resolve_perch_path(
        endpoint_id,
        spt_store::perch::ParentHint::Infer,
    );
    spt_store::info::read_info(&perch).and_then(|i| i.driven_by)
}

/// What one stdin chunk yielded after the detach state machine: the bytes to
/// forward to the session, and whether the detach keybind fired.
struct DetachParse {
    forward: Vec<u8>,
    detach: bool,
}

/// Apply the `ctrl-b` detach state machine to one raw stdin chunk. `armed` holds
/// the "saw the prefix" state across chunk boundaries (a prefix can end one read
/// and its companion key begin the next). `ctrl-b d` → detach; `ctrl-b ctrl-b` →
/// one literal `ctrl-b`; `ctrl-b <other>` → prefix + other verbatim; every other
/// byte passes through. Pure so it unit-tests without a terminal. On detach the
/// bytes BEFORE the keybind are returned to forward; bytes after are dropped (the
/// viewport is ending).
fn parse_stdin_chunk(armed: &mut bool, input: &[u8]) -> DetachParse {
    let mut forward = Vec::with_capacity(input.len());
    for &b in input {
        if *armed {
            *armed = false;
            match b {
                DETACH_KEY => return DetachParse { forward, detach: true },
                DETACH_PREFIX => forward.push(DETACH_PREFIX), // literal ctrl-b
                other => {
                    forward.push(DETACH_PREFIX);
                    forward.push(other);
                }
            }
        } else if b == DETACH_PREFIX {
            *armed = true;
        } else {
            forward.push(b);
        }
    }
    DetachParse {
        forward,
        detach: false,
    }
}

/// Spawn the stdin reader: raw bytes → `StdinMsg::Bytes`, watching for the
/// detach keybind ([`parse_stdin_chunk`]). Detaching sends `StdinMsg::Detach`
/// and ends the thread; the leaked blocking `read` (if any) dies with the
/// process on exit.
fn spawn_stdin_reader(tx: mpsc::Sender<StdinMsg>) {
    std::thread::spawn(move || {
        let mut stdin = std::io::stdin().lock();
        let mut buf = [0u8; 1024];
        let mut armed = false; // saw the detach prefix, awaiting the next byte
        loop {
            let n = match stdin.read(&mut buf) {
                Ok(0) => break, // stdin closed
                Ok(n) => n,
                Err(_) => break,
            };
            // W7 (REQ-HAZARD-RC-INPUT-KEY-ENCODING) confirm-first hexdump: with
            // SPT_RC_DEBUG_KEYS set, print the RAW bytes the console delivered for
            // each keystroke. Press Backspace then ctrl+Backspace in a real rc
            // session — a Backspace that arrives as 0x08 (^H) instead of 0x7f
            // (VT DEL) confirms the legacy-console root (ENABLE_VIRTUAL_TERMINAL_INPUT
            // off). Gate the root before the fix.
            if std::env::var_os("SPT_RC_DEBUG_KEYS").is_some() {
                let hex: Vec<String> = buf[..n].iter().map(|b| format!("{b:02x}")).collect();
                eprintln!("RC_KEYS n={n} bytes=[{}]", hex.join(" "));
            }
            let parsed = parse_stdin_chunk(&mut armed, &buf[..n]);
            // W7 (REQ-HAZARD-RC-INPUT-KEY-ENCODING): on Windows the legacy console
            // sends Backspace as 0x08 (^H → CC kill-word) and Ctrl+Backspace as
            // 0x7f (DEL → CC char-delete); SWAP them in the forward path so CC sees
            // Backspace=char-delete and Ctrl+Backspace=word-delete (native Win11),
            // AFTER the detach state machine has consumed any 0x02 prefix. Unix is
            // already VT (and a real ctrl+h there must stay 0x08), so it's Win-only.
            #[cfg(windows)]
            let forward: Vec<u8> = parsed.forward.into_iter().map(normalize_key_byte).collect();
            #[cfg(unix)]
            let forward = parsed.forward;
            if !forward.is_empty() && tx.send(StdinMsg::Bytes(forward)).is_err() {
                break;
            }
            if parsed.detach {
                let _ = tx.send(StdinMsg::Detach);
                return;
            }
        }
    });
}

/// Attach a local terminal to the broker-held session for `endpoint_id` under
/// `intent` (REQ-RCVIEW-1 / REQ-KICK-1): `Control` drives a free endpoint (and is
/// busy-refused with guidance on a controlled one), `Viewer` watches read-only,
/// `Take` kicks the incumbent controller (loud `Displaced` notice to them) and
/// takes control. Returns when the session exits, the operator detaches, or an
/// error occurs.
// [impl->REQ-RC-1] [impl->REQ-RCVIEW-1] [impl->REQ-KICK-1]
pub fn run_attach(endpoint_id: &str, intent: AttachIntent) -> Result<(), String> {
    let view = intent == AttachIntent::Viewer;
    // Busy-refuse rendered CLIENT-SIDE (REQ-RCVIEW-1): a plain `Control` attach to
    // an endpoint a DIFFERENT node already controls gets actionable guidance here
    // (the broker would also refuse on the wire — this is the friendly path). A
    // `--view` / `--take` bypasses it (watching coexists; taking displaces).
    if intent == AttachIntent::Control {
        if let Some(node) = current_driver(endpoint_id) {
            println!("'{endpoint_id}' is controlled by {node}.");
            println!("  spt rc {endpoint_id} --view   to watch read-only");
            println!("  spt rc {endpoint_id} --take   to take control (kicks {node})");
            return Ok(());
        }
    }
    // (a) STATUS-GATE (REQ-HAZARD-RC-ATTACH-FAILFAST): a perch with an EXPLICIT
    // `status=offline` is not attachable — short-circuit with clean guidance
    // [impl->REQ-HAZARD-RC-ATTACH-FAILFAST]
    // instead of the broker dance that would block on a dead session (the
    // infinite-blank hang). The B2 pull-reconcile / `endpoint stop` stamp a dead
    // spt-hosted perch offline, so this catches the common case before any IPC. An
    // ABSENT or status-less perch falls through to the broker path, which already
    // ends cleanly ("no live session for endpoint") — not a hang. (is_perch_alive
    // is NOT used here: it returns true for an absent info.json by interim parity.)
    let perch_path =
        spt_store::perch::resolve_perch_path(endpoint_id, spt_store::perch::ParentHint::Infer);
    if spt_store::info::read_info(&perch_path)
        .and_then(|i| i.status)
        .as_deref()
        == Some(spt_store::liveness::STATUS_OFFLINE)
    {
        println!("Endpoint '{endpoint_id}' is offline — nothing to attach to.");
        println!("  spt endpoint run --adapter <adapter> --id {endpoint_id}   to start it");
        return Ok(());
    }
    spt_daemon::daemon::ensure_running().map_err(|e| format!("daemon start: {e}"))?;
    // PUMP mode: the Split reader-thread carrier bounds every read by a deadline
    // (a `Whole` conn can't time out — nonblocking is dead on Windows named
    // pipes), so the single-threaded pump interleaves stdin + broker events
    // without wedging. The generous io_timeout bounds the setup calls (dial,
    // sessions, attach-open ack); the pump itself passes its own short slice.
    let mut brain = Brain::cold_start_pump(
        &spt_daemon::endpoint::broker_socket_name(),


Fully grounded. The build is substantial (exhaustive xterm table + cfg-split + REQ + docs) and ship-blocking — a clean self-clear point per CLAUDE.md. But I found one design ambiguity worth a doyle ruling: the design says `Ctrl+B` *alone* → Detach at the event level, but the current detach is a **two-key `ctrl-b d` prefix** (with `ctrl-b ctrl-b` → literal). Going bare-`Ctrl+B` changes the keybind + diverges from Unix. Fire that to doyle now (async ruling waits for my post-clear resume):


Bash

$OWL send doyle <<'EOF'
RULING NEEDED before/early in bug 2 (RC VT translator) — the detach keybind at the event level. Design §Architecture says `KeyEvent{Char('b')+CONTROL} → StdinMsg::Detach` (bare Ctrl+B = detach). But the CURRENT detach (parse_stdin_chunk, Unix + today's Windows) is a TWO-key PREFIX: ctrl-b THEN 'd' → detach; ctrl-b ctrl-b → one literal ctrl-b (0x02) forwarded; ctrl-b <other> → prefix+other.

Bare-Ctrl+B-detach on Windows-tty would: (1) change the keybind (ctrl-b d → ctrl-b), (2) diverge from Unix (still ctrl-b d), (3) make a literal ctrl-b unsendable to the harness on Windows.

Two options — your call:
(A) LITERAL design: bare Ctrl+B event → Detach on Windows-tty. Simplest; accepts the Win/Unix keybind divergence + no literal-ctrl-b on Windows.
(B) PRESERVE semantics at the event level: Ctrl+B ARMS; next KeyEvent 'd' → Detach, Ctrl+B again → emit literal 0x02 byte, other → 0x02+that. Keeps ctrl-b d uniform across Win/Unix + literal-ctrl-b escape. Slightly more state in the Windows event loop.

I lean (B) — uniform detach UX + no lost literal — but it's your design + the HITL line just says "ctrl-b detach still works" (ambiguous on the d). Which?

Context: 3+4 surfaced for your gate @5c0870c. I'm about to JIT-plan + self-clear before building bug 2 (big: exhaustive xterm translate_key_event + cfg-split + REQ-RC-KEY-VT-TRANSLATE doc/impl/unit + supersede normalize_key_byte). Your ruling will be waiting when I resume. Carry on.
EOF

Ask doyle for detach-keybind ruling


➜ SENT:doyle


Now write the grounded JIT plan for bug 2:


Write

# v0.13.0 bug 2 — rc Windows key→xterm-VT translator (JIT plan, todlando)

> Grounded 2026-06-19 after 3+4 surfaced for gate (@5c0870c). Authority = `V0.13.0-RC-VT-TRANSLATE-DESIGN.md` (doyle). This is the LAST v0.13.0 build item + the SHIP-BLOCKER (operator ruling). doyle gates; then operator HITL = real acceptance.

## Blocking question (sent to doyle, await ruling before the detach leg)
Detach keybind at the event level: **(A)** bare `Ctrl+B`→Detach (literal design, Win/Unix divergence) vs **(B)** preserve the `ctrl-b d` prefix at event level (uniform, keeps literal-ctrl-b). I lean **(B)**. Build the translate table FIRST (unaffected by this), wire detach once ruled.

## Grounding (already read — anchors)
- `crates/spt/src/rc.rs`:
  - `enum StdinMsg { Bytes(Vec<u8>), Detach }` (line 46) — unchanged.
  - `normalize_key_byte` (line 93, cfg(windows)) — the W7 swap (0x08↔0x7f). SUPERSEDED: remove from the Windows forward path; its Backspace/Ctrl+Backspace intent moves INTO `translate_key_event`.
  - `parse_stdin_chunk` (line 153) — the ctrl-b detach byte SM. STAYS for Unix + the non-tty Windows fallback.
  - `spawn_stdin_reader` (line 182) — THE surgical site. Today: blocking `stdin().read` → `parse_stdin_chunk` → (cfg windows) `normalize_key_byte` map → `StdinMsg::Bytes`.
  - `DETACH_PREFIX`=0x02 (ctrl-b), `DETACH_KEY`=detach companion (grep the consts).
  - Pump main loop (line ~486-555) consumes `StdinMsg` — unchanged.
- crossterm 0.28 already a dep; picker (`crates/spt/src/picker/`) already reads `crossterm::event` (KeyEvent/KeyEventKind/KeyModifiers). `event` feature on (default).

## Build steps
1. **REQ first (registry-first):** mint `REQ-RC-KEY-VT-TRANSLATE`, stages `[doc, impl, unit]`. Add a SUPERSEDE note to `REQ-HAZARD-RC-INPUT-KEY-ENCODING` (the W7 swap folded into the translator's Backspace/Ctrl+Backspace mapping; `normalize_key_byte` removed). KEEP that req's evidence alive by relocating its tags: tag `translate_key_event`'s Backspace→0x7f / Ctrl+Backspace→0x08 with `[impl->REQ-HAZARD-RC-INPUT-KEY-ENCODING]` + a unit `[unit->REQ-HAZARD-RC-INPUT-KEY-ENCODING]` (else traceable drops its coverage when normalize_key_byte goes).
2. **`fn translate_key_event(KeyEvent) -> Vec<u8>`** (cfg(windows), pure — the unit core). COPY a known-correct xterm table VERBATIM (ADR-0001 spirit; do NOT hand-roll sequences). Cover: plain Char (UTF-8), Char+CTRL (0x01..0x1a), Char+ALT (ESC+byte); Enter\r Tab 0x09 BackTab ESC[Z Esc 0x1b Backspace 0x7f Delete ESC[3~; Ctrl+Backspace 0x08; arrows ESC[A/B/C/D Home ESC[H End ESC[F Insert ESC[2~ PgUp ESC[5~ PgDn ESC[6~; modified specials `ESC[1;<m><final>` + `ESC[<n>;<m>~`, m=1+Shift+2*Alt+4*Ctrl; F1-4 ESC O P/Q/R/S, F5-12 ESC[15/17/18/19/20/21/23/24~ (+;m modified); unknown→empty (drop, never garbage).
3. **`spawn_stdin_reader` cfg-split:**
   - cfg(windows) AND `std::io::stdin().is_terminal()` → crossterm event loop: `event::read()`; on `Event::Key(ke)` with `ke.kind==Press` → detach-check (per doyle ruling) else `translate_key_event(ke)` → `StdinMsg::Bytes`. `Event::Paste(s)` → forward content bytes. Drop Repeat/Release.
   - cfg(windows) NON-tty (piped — tests/e2e) → EXISTING byte path WITHOUT normalize (raw bytes, like Unix; the swap was a console artifact). Preserves e2e byte-injection (dummy_harness/job_escape).
   - cfg(unix) → byte path + parse_stdin_chunk UNCHANGED (already VT).
4. **Remove `normalize_key_byte`** + its unit (Windows forward path) — behavior now in translate_key_event.
5. **Docs:** KNOWN-HAZARDS entry + CONTEXT note ("Windows rc translates console key events to standard xterm VT; Unix passes through"). `[doc->REQ-RC-KEY-VT-TRANSLATE]`.

## Tests (delegate to spt-test-engineer — ONE, background, ≤4 iter)
- **No int** (live console = HITL, REQ-RUN-PICKER/RC-1 precedent). The unit IS the surface: EXHAUSTIVE + non-vacuous `translate_key_event` table (every arrow/Home/End/PgUp/PgDn/Del/Insert/F-key/modifier-combo/Ctrl+char/Backspace/Ctrl+Backspace asserts its EXACT bytes vs a real xterm) + detach-event unit (per ruling) + the relocated REQ-HAZARD-RC-INPUT-KEY-ENCODING Backspace/Ctrl+Backspace unit.
- Spot-check the table against a real terminal (don't trust my transcription).

## Gates (doyle)
clippy --workspace -D warnings=0 · traceable EXIT=0 (`REQ-RC-KEY-VT-TRANSLATE` [doc,impl,unit] + REQ-HAZARD-RC-INPUT-KEY-ENCODING still covered) · docs-drift `cargo run -p xtask -- check` (no CLI change expected, run anyway) · real `spt` bin rebuilds · Unix path untouched (cfg-split) · non-tty fallback preserved. Then operator HITL = real acceptance.

## Watch-outs
- Unix ZERO regression (cfg-split; translate_key_event is cfg(windows) only).
- Non-tty fallback is load-bearing for the e2e byte-injection suites — don't break it.
- crossterm raw-mode is already enabled by RawGuard; the event loop reads under it.
- Don't reintroduce ENABLE_VIRTUAL_TERMINAL_INPUT (W7 rejected — win32-input-mode).

## Sequencing
After this: doyle gates → operator HITL. Then v0.13.0 PR bundles W1/W1b/W2/W3/W4/W5/W7/F1 + window + 3+4 + bug2 + gravity-linux green (doyle-tracked). bug 2 is the last build item gating the ship.


<owl_messages>
<owl_message from="doyle" priority="high">doyle 3+4 GATE PASS — @5c0870c (REQ-HAZARD-RC-ATTACH-ONLINE-RACE [impl,unit,int]). Verified myself: nextest dummy_harness_e2e 2/2 (endpoint_run_attach_awaits_online_before_attaching + sibling, 0 skipped, no regression); poll_online unit pass; clippy -D warnings=0; traceable EXIT=0. poll_online is clean (pure, injected sleep, exhaustive: fastpath-0-sleeps / timeout-checks-all-slept-between / later-success); await gate on attach/view path only, fail-loud ONLINE_TIMEOUT, online routes Outcome::Attach (no double-count). The FALSE-RED catch (output()/closed-stdin EOF-detach) + the gate-TOGGLE non-vacuity proof (gate-off⇒bailed_offline=true=the Resume face / gate-on⇒saw_tick=true) is exactly the repro rigor this milestone demands — well done. Completes W4 attach-by-default for real.

STATUS: ALL v0.13.0 build waves gated EXCEPT bug 2 (RC VT translator) — the LAST item + it BLOCKS the ship. Take it next (design in V0.13.0-RC-VT-TRANSLATE-DESIGN.md). On its gate-pass: I do ONE batched PATH deploy → operator HITL everything → PR (gravity-linux hard-legs confirm) → deployah cuts v0.13.0 → W6 perri. Surface the translator when green.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


File created successfully at: C:\Users\decid\Documents\projects\spt-core\V0.13.0-BUG2-RC-VT-TRANSLATE-PLAN.md (file state is current in your context — no need to Read it back)

# v0.13.0 bug 2 — rc Windows key→xterm-VT translator (JIT plan, todlando)

> Grounded 2026-06-19 after 3+4 surfaced for gate (@5c0870c). Authority = `V0.13.0-RC-VT-TRANSLATE-DESIGN.md` (doyle). This is the LAST v0.13.0 build item + the SHIP-BLOCKER (operator ruling). doyle gates; then operator HITL = real acceptance.

## Blocking question (sent to doyle, await ruling before the detach leg)
Detach keybind at the event level: **(A)** bare `Ctrl+B`→Detach (literal design, Win/Unix divergence) vs **(B)** preserve the `ctrl-b d` prefix at event level (uniform, keeps literal-ctrl-b). I lean **(B)**. Build the translate table FIRST (unaffected by this), wire detach once ruled.

## Grounding (already read — anchors)
- `crates/spt/src/rc.rs`:
  - `enum StdinMsg { Bytes(Vec<u8>), Detach }` (line 46) — unchanged.
  - `normalize_key_byte` (line 93, cfg(windows)) — the W7 swap (0x08↔0x7f). SUPERSEDED: remove from the Windows forward path; its Backspace/Ctrl+Backspace intent moves INTO `translate_key_event`.
  - `parse_stdin_chunk` (line 153) — the ctrl-b detach byte SM. STAYS for Unix + the non-tty Windows fallback.
  - `spawn_stdin_reader` (line 182) — THE surgical site. Today: blocking `stdin().read` → `parse_stdin_chunk` → (cfg windows) `normalize_key_byte` map → `StdinMsg::Bytes`.
  - `DETACH_PREFIX`=0x02 (ctrl-b), `DETACH_KEY`=detach companion (grep the consts).
  - Pump main loop (line ~486-555) consumes `StdinMsg` — unchanged.
- crossterm 0.28 already a dep; picker (`crates/spt/src/picker/`) already reads `crossterm::event` (KeyEvent/KeyEventKind/KeyModifiers). `event` feature on (default).

## Build steps
1. **REQ first (registry-first):** mint `REQ-RC-KEY-VT-TRANSLATE`, stages `[doc, impl, unit]`. Add a SUPERSEDE note to `REQ-HAZARD-RC-INPUT-KEY-ENCODING` (the W7 swap folded into the translator's Backspace/Ctrl+Backspace mapping; `normalize_key_byte` removed). KEEP that req's evidence alive by relocating its tags: tag `translate_key_event`'s Backspace→0x7f / Ctrl+Backspace→0x08 with `[impl->REQ-HAZARD-RC-INPUT-KEY-ENCODING]` + a unit `[unit->REQ-HAZARD-RC-INPUT-KEY-ENCODING]` (else traceable drops its coverage when normalize_key_byte goes).
2. **`fn translate_key_event(KeyEvent) -> Vec<u8>`** (cfg(windows), pure — the unit core). COPY a known-correct xterm table VERBATIM (ADR-0001 spirit; do NOT hand-roll sequences). Cover: plain Char (UTF-8), Char+CTRL (0x01..0x1a), Char+ALT (ESC+byte); Enter\r Tab 0x09 BackTab ESC[Z Esc 0x1b Backspace 0x7f Delete ESC[3~; Ctrl+Backspace 0x08; arrows ESC[A/B/C/D Home ESC[H End ESC[F Insert ESC[2~ PgUp ESC[5~ PgDn ESC[6~; modified specials `ESC[1;<m><final>` + `ESC[<n>;<m>~`, m=1+Shift+2*Alt+4*Ctrl; F1-4 ESC O P/Q/R/S, F5-12 ESC[15/17/18/19/20/21/23/24~ (+;m modified); unknown→empty (drop, never garbage).
3. **`spawn_stdin_reader` cfg-split:**
   - cfg(windows) AND `std::io::stdin().is_terminal()` → crossterm event loop: `event::read()`; on `Event::Key(ke)` with `ke.kind==Press` → detach-check (per doyle ruling) else `translate_key_event(ke)` → `StdinMsg::Bytes`. `Event::Paste(s)` → forward content bytes. Drop Repeat/Release.
   - cfg(windows) NON-tty (piped — tests/e2e) → EXISTING byte path WITHOUT normalize (raw bytes, like Unix; the swap was a console artifact). Preserves e2e byte-injection (dummy_harness/job_escape).
   - cfg(unix) → byte path + parse_stdin_chunk UNCHANGED (already VT).
4. **Remove `normalize_key_byte`** + its unit (Windows forward path) — behavior now in translate_key_event.
5. **Docs:** KNOWN-HAZARDS entry + CONTEXT note ("Windows rc translates console key events to standard xterm VT; Unix passes through"). `[doc->REQ-RC-KEY-VT-TRANSLATE]`.

## Tests (delegate to spt-test-engineer — ONE, background, ≤4 iter)
- **No int** (live console = HITL, REQ-RUN-PICKER/RC-1 precedent). The unit IS the surface: EXHAUSTIVE + non-vacuous `translate_key_event` table (every arrow/Home/End/PgUp/PgDn/Del/Insert/F-key/modifier-combo/Ctrl+char/Backspace/Ctrl+Backspace asserts its EXACT bytes vs a real xterm) + detach-event unit (per ruling) + the relocated REQ-HAZARD-RC-INPUT-KEY-ENCODING Backspace/Ctrl+Backspace unit.
- Spot-check the table against a real terminal (don't trust my transcription).

## Gates (doyle)
clippy --workspace -D warnings=0 · traceable EXIT=0 (`REQ-RC-KEY-VT-TRANSLATE` [doc,impl,unit] + REQ-HAZARD-RC-INPUT-KEY-ENCODING still covered) · docs-drift `cargo run -p xtask -- check` (no CLI change expected, run anyway) · real `spt` bin rebuilds · Unix path untouched (cfg-split) · non-tty fallback preserved. Then operator HITL = real acceptance.

## Watch-outs
- Unix ZERO regression (cfg-split; translate_key_event is cfg(windows) only).
- Non-tty fallback is load-bearing for the e2e byte-injection suites — don't break it.
- crossterm raw-mode is already enabled by RawGuard; the event loop reads under it.
- Don't reintroduce ENABLE_VIRTUAL_TERMINAL_INPUT (W7 rejected — win32-input-mode).

## Sequencing
After this: doyle gates → operator HITL. Then v0.13.0 PR bundles W1/W1b/W2/W3/W4/W5/W7/F1 + window + 3+4 + bug2 + gravity-linux green (doyle-tracked). bug 2 is the last build item gating the ship.


ACK — doyle **3+4 GATE PASS @5c0870c** (REQ-HAZARD-RC-ATTACH-ONLINE-RACE `[impl,unit,int]`), praised the false-red catch + gate-toggle non-vacuity. **All v0.13.0 build waves gated except bug 2** (RC VT translator) — the last item + ship-blocker. On its gate-pass: doyle batched PATH deploy → operator HITL → PR → deployah cuts v0.13.0 → W6 perri.

Bug 2 is grounded + JIT-planned, detach-keybind question fired to doyle. Context is heavy after W4 + window + 3+4 — clean self-clear point before the big translator build. Commune, then self-clear:


Skill

v0.13.0 status: ALL build waves GATED except bug 2 (the ship-blocker). This session shipped + doyle-gated three: W4 picker UX @ed1aa04, WMI daemon-window FreeConsole fix @082f5ed (REQ-HAZARD-WMI-DAEMON-WINDOW reopened [impl,unit,int]; the FreeConsole-invalid-handle panic that broke bringup needed std-handle NULL — caught via the GREEN run failing first), and bugs 3+4 attach-online race @5c0870c (REQ-HAZARD-RC-ATTACH-ONLINE-RACE [impl,unit,int]; cmd_endpoint_run awaits STATUS_ONLINE before run_attach; false-red catch = output()/closed-stdin EOF-detach + gate-toggle non-vacuity proof). Process held: repro-first RED→GREEN, ONE spt-test-engineer per item background ≤4-iter, I own src/REQ/docs + breaking-test fixes, gate-toggle for non-vacuity, surface to doyle for independent gate. RESUME POINT = bug 2 (RC Windows key→xterm-VT translator), the LAST v0.13.0 build item + SHIP-BLOCKER. Grounded + JIT-planned in V0.13.0-BUG2-RC-VT-TRANSLATE-PLAN.md (authority = doyle's V0.13.0-RC-VT-TRANSLATE-DESIGN.md). Core = pure translate_key_event(KeyEvent)->Vec<u8> emitting STANDARD xterm VT (copy a known-correct table verbatim, NOT hand-rolled), Windows spawn_stdin_reader cfg-split to crossterm events (tty) / byte-fallback (non-tty, e2e injection) / Unix unchanged; remove normalize_key_byte (relocate its Backspace/Ctrl+Backspace tags into the translator so REQ-HAZARD-RC-INPUT-KEY-ENCODING stays covered); mint REQ-RC-KEY-VT-TRANSLATE [doc,impl,unit] registry-first + supersede-note. NO int (live console = HITL); exhaustive non-vacuous mapping unit is the surface. PENDING doyle ruling (asked): detach keybind at event level — bare Ctrl+B (literal design) vs preserve ctrl-b+d prefix (I lean preserve, uniform w/ Unix). Build the table first (ruling-independent), wire detach once ruled. On bug2 gate-pass: doyle batched PATH deploy → operator HITL → PR (gravity-linux hard-legs) → deployah cuts v0.13.0 → W6 perri. End goal: v0.13.0 delivery-control milestone shipped.


<owl_messages>
<owl_message from="doyle" priority="high">doyle RULING: OPTION B — preserve the two-key prefix detach SM at the event level. My design's "bare Ctrl+B → Detach" was a sloppy shorthand; B is right. Divergence (A) is a gratuitous Win/Unix UX split + loses the literal-ctrl-b escape — rejected.

EXACT semantics (mirror parse_stdin_chunk, event-sourced): Ctrl+B (Char('b')+CONTROL) ARMS (don't forward). While armed, next KeyEvent → plain Char('d') (no ctrl) ⇒ Detach · Ctrl+B again ⇒ emit literal 0x02 + disarm · any other ⇒ emit 0x02 THEN translate_key_event(that) + disarm. Detach key is PLAIN 'd' only (a Ctrl+D = 0x04 stays prefix+0x04, matching the byte SM today). Updated V0.13.0-RC-VT-TRANSLATE-DESIGN.md §Architecture with this.

Factor the arm/d/ctrl-b/other state as a SHARED logical SM over both input types (bytes on Unix, events on Windows) if it's clean; else replicate the four arms in the Windows event loop — your call, just keep the SEMANTICS identical so a unit can assert both paths agree. Add a Windows-event detach unit (armed+'d'⇒Detach; armed+Ctrl+B⇒0x02; armed+other⇒0x02+translated) alongside the exhaustive translate_key_event mapping unit.

(FYI: 3+4 GATE PASS already sent @5c0870c — you're clear to self-clear + JIT bug 2.) Carry on; the ruling's logged in the design doc.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


Launching skill: spt:commune


Base directory for this skill: C:\Users\decid\.claude\plugins\cache\cplugs\spt\1.11.25\skills\commune

# /spt:commune

All commands use `$LIVE` env var, auto-injected by the plugin's SessionStart hook. If commands fail with "command not found", restart the Claude Code session so the SessionStart hook re-runs.

> **Identity auto-detection:** Your identity is auto-detected from your session. Pass your ID explicitly only if auto-detection fails.

## Flow

1. **Download Psyche's *current* context** — it shifts during the session as background echo communes fire. Re-download right before composing; the gen-N snapshot you read at session start is already stale:
   ```bash
   $LIVE psyche-download <your-id>
   ```

   > **Output truncation.** If the Bash tool reports `Output too large (N KB)` and saves the full result to a `tool-results/<id>.txt` file, use the `Read` tool on that saved path to access the full content. Do NOT re-run `psyche-download` to grep/awk out sections — repeated invocations re-download the same context, waste tokens, and may duplicate `## Pending Commune` sections in `live_context.md`.

2. **Diff your knowledge against this fresh download** — only what's missing from *it* is the real delta (new work, decisions, intentions). Anything already absorbed is not. If the download includes a `<memformat>` section, use it as a guide for what topics to cover. A `## Pending Commune (uncommitted)` section means a previous commune is still queued — do NOT re-send it.

3. **Commune only the delta** -- use the **Write tool** to create
   `.claude/{your-id}-commune.md` with the two-slice body as its contents
   (see `## Two-slice body shape (Phase 25 D-10/D-11)` below for the required
   envelope wrapping). Do not use Bash/heredoc -- Write is the canonical path:
   no shell escaping, no `EOF` collision risk, and Write auto-creates the
   `.claude/` parent directory.

   Your Self listener detects the file on its next poll iteration, notifies
   your Psyche wrapper, and the wrapper ingests the content via the existing
   Psyche session. On success (subprocess exit code 0) the wrapper deletes the
   file. On error (token rate limit, etc) the file is retained and retried on
   the next consume cycle. If you write again before the wrapper consumes, the
   latest write wins (last-write-wins overwrite semantics).

If Psyche's context is already current, no commune is needed. Say so and move on.

Read `commune.md` (in this skill directory) for the full commune protocol -- when to send, what to include, and diligence triggers.

**Agent-type guard:** Only works on live agents. If target is not live, refuses with: "Communes require a live agent with Psyche."

## Two-slice body shape (Phase 25 D-10/D-11)

The body you write in `.claude/{your-id}-commune.md` MUST itself be wrapped in two-slice envelopes per Phase 25 D-10/D-11. The taxonomy — what belongs in `<live-context>` vs `<project-context>` — is defined canonically in `psyche.md` §`<output_envelope>` (lines 282–323). Read that section as the source of truth; this skill doc teaches only when and how to apply it.

**In-project detection rule (D-25.1-04).** Before composing your commune body, run `$LIVE psyche-download <your-id>` (Step 1 of `## Flow` above) and inspect the `<current ... />` tag at the top of the output. The `<current/>` tag carries a `project="..."` attribute whenever any payload is produced — its presence means you are inside a tracked project (inside a git repo, `project` is the repo name; outside a git repo per D-11, `project` is the cwd-basename fallback, which still counts as a project context for routing purposes). A `psyche-download` invocation that returns no payload at all (NO-CONTEXT exit) means no project resolved. So: `project="..."` populated on `<current/>` → emit BOTH envelopes (apply rule 1 or 2 below); no payload at all → emit ONLY `<live-context>` (rule 3). Secondary signal: the `<project-context-resolved name="..."/>` sentinel further down the output indicates the project file already has prior content — useful for spotting first-time-in-project pairings, but NOT the routing rule.

**Three rules (D-25.1-01..03):**

- **In-project, both slices populated** (marker present, you have project-specific content this cycle): emit `<live-context>...</live-context>` AND `<project-context>...</project-context>`, both with non-empty bodies.
- **In-project, no project-specific update this cycle** (marker present, but nothing project-bound to report): emit `<live-context>...</live-context>` AND `<project-context></project-context>` (empty body — the deliberate "in-project but quiet" signal per D-25.1-02). Distinguishable from "no project resolved".
- **Outside any tracked project** (marker absent): emit ONLY `<live-context>...</live-context>`. Do NOT emit an empty `<project-context>` envelope (missing tag means "no project resolved" per D-25.1-03).

**Nested envelopes.** The Phase 23 `<EVENT type="commune">` envelope (see `## Envelope shape (Phase 23 v1.8)` below) is composed by `$LIVE commune` automatically — you NEVER write `<EVENT>` tags. You write ONLY the inner two-slice body. The two envelope layers nest: outer EVENT wraps inner two-slice body.

(See `psyche.md` §`<output_envelope>` for the canonical taxonomy and per-slice content rules.)

## Envelope shape (Phase 23 v1.8)

As of v1.8 Phase 23, commune deliveries are wrapped in a typed EVENT envelope carrying a 5-field stamp (`machine`, `project`, `branch`, `head_sha`, `head_subject`); the prose `COMMUNE (timestamp): body` form is gone.

```xml
<EVENT type="commune" timestamp="2026-05-20T06:00:00-07:00" machine="hostname" project="claude_skill_owl" branch="main" head_sha="abc123..." head_subject="subject line capped at 72 chars">body</EVENT>
```

Outside a git repo (D-11), `branch`, `head_sha`, and `head_subject` are omitted entirely (not empty-string-set):

```xml
<EVENT type="commune" timestamp="2026-05-20T06:00:00-07:00" machine="hostname" project="cwd-basename">body</EVENT>
```

`$LIVE commune` composes this envelope automatically — Self does NOT manually construct EVENT XML. The `body` placeholder in the example above is the two-slice body (see `## Two-slice body shape (Phase 25 D-10/D-11)` above); the two envelope layers nest. The wrapper-side parser ignores unknown attrs, so the stamp fields are forward-compatible with older receivers. For drift detection on resume, see the `live` skill.

ARGUMENTS: v0.13.0 status: ALL build waves GATED except bug 2 (the ship-blocker). This session shipped + doyle-gated three: W4 picker UX @ed1aa04, WMI daemon-window FreeConsole fix @082f5ed (REQ-HAZARD-WMI-DAEMON-WINDOW reopened [impl,unit,int]; the FreeConsole-invalid-handle panic that broke bringup needed std-handle NULL — caught via the GREEN run failing first), and bugs 3+4 attach-online race @5c0870c (REQ-HAZARD-RC-ATTACH-ONLINE-RACE [impl,unit,int]; cmd_endpoint_run awaits STATUS_ONLINE before run_attach; false-red catch = output()/closed-stdin EOF-detach + gate-toggle non-vacuity proof). Process held: repro-first RED→GREEN, ONE spt-test-engineer per item background ≤4-iter, I own src/REQ/docs + breaking-test fixes, gate-toggle for non-vacuity, surface to doyle for independent gate. RESUME POINT = bug 2 (RC Windows key→xterm-VT translator), the LAST v0.13.0 build item + SHIP-BLOCKER. Grounded + JIT-planned in V0.13.0-BUG2-RC-VT-TRANSLATE-PLAN.md (authority = doyle's V0.13.0-RC-VT-TRANSLATE-DESIGN.md). Core = pure translate_key_event(KeyEvent)->Vec<u8> emitting STANDARD xterm VT (copy a known-correct table verbatim, NOT hand-rolled), Windows spawn_stdin_reader cfg-split to crossterm events (tty) / byte-fallback (non-tty, e2e injection) / Unix unchanged; remove normalize_key_byte (relocate its Backspace/Ctrl+Backspace tags into the translator so REQ-HAZARD-RC-INPUT-KEY-ENCODING stays covered); mint REQ-RC-KEY-VT-TRANSLATE [doc,impl,unit] registry-first + supersede-note. NO int (live console = HITL); exhaustive non-vacuous mapping unit is the surface. PENDING doyle ruling (asked): detach keybind at event level — bare Ctrl+B (literal design) vs preserve ctrl-b+d prefix (I lean preserve, uniform w/ Unix). Build the table first (ruling-independent), wire detach once ruled. On bug2 gate-pass: doyle batched PATH deploy → operator HITL → PR (gravity-linux hard-legs) → deployah cuts v0.13.0 → W6 perri. End goal: v0.13.0 delivery-control milestone shipped.


ACK — doyle **RULING: Option B** (preserve the `ctrl-b d` prefix at the event level; bare-Ctrl+B was sloppy shorthand). Exact 4-arm semantics: Ctrl+B arms (don't forward); armed+plain-`d`⇒Detach; armed+Ctrl+B⇒emit literal 0x02+disarm; armed+other⇒0x02 then `translate_key_event(other)`+disarm; detach key is plain `d` only. Factor a shared logical SM over bytes(Unix)/events(Windows) if clean; add a Windows-event detach unit. Design doc updated. My blocking question is resolved — update the plan + download fresh psyche for the commune:


Edit

## Blocking question (sent to doyle, await ruling before the detach leg)
Detach keybind at the event level: **(A)** bare `Ctrl+B`→Detach (literal design, Win/Unix divergence) vs **(B)** preserve the `ctrl-b d` prefix at event level (uniform, keeps literal-ctrl-b). I lean **(B)**. Build the translate table FIRST (unaffected by this), wire detach once ruled.

## Detach keybind — RULED (doyle, Option B): preserve the `ctrl-b d` prefix at the event level
Mirror `parse_stdin_chunk`, event-sourced. Ctrl+B (`Char('b')`+CONTROL) ARMS (don't forward). While armed, next `KeyEvent`:
- plain `Char('d')` (NO ctrl) ⇒ **Detach**
- Ctrl+B again ⇒ emit literal **0x02** + disarm
- any other ⇒ emit **0x02** THEN `translate_key_event(that)` + disarm

Detach key is PLAIN `'d'` ONLY (Ctrl+D=0x04 stays prefix+0x04, matching today's byte SM). Factor the arm/d/ctrl-b/other state as a SHARED logical SM over both input types (bytes=Unix, events=Windows) IF clean; else replicate the four arms in the Windows event loop — keep SEMANTICS identical so a unit asserts both paths agree. Add a Windows-event **detach unit** (armed+'d'⇒Detach; armed+Ctrl+B⇒0x02; armed+other⇒0x02+translated) alongside the exhaustive mapping unit. (doyle updated the design doc §Architecture.)


The file C:\Users\decid\Documents\projects\spt-core\V0.13.0-BUG2-RC-VT-TRANSLATE-PLAN.md has been updated successfully. (file state is current in your context — no need to Read it back)

## Detach keybind — RULED (doyle, Option B): preserve the `ctrl-b d` prefix at the event level
Mirror `parse_stdin_chunk`, event-sourced. Ctrl+B (`Char('b')`+CONTROL) ARMS (don't forward). While armed, next `KeyEvent`:
- plain `Char('d')` (NO ctrl) ⇒ **Detach**
- Ctrl+B again ⇒ emit literal **0x02** + disarm
- any other ⇒ emit **0x02** THEN `translate_key_event(that)` + disarm

Detach key is PLAIN `'d'` ONLY (Ctrl+D=0x04 stays prefix+0x04, matching today's byte SM). Factor the arm/d/ctrl-b/other state as a SHARED logical SM over both input types (bytes=Unix, events=Windows) IF clean; else replicate the four arms in the Windows event loop — keep SEMANTICS identical so a unit asserts both paths agree. Add a Windows-event **detach unit** (armed+'d'⇒Detach; armed+Ctrl+B⇒0x02; armed+other⇒0x02+translated) alongside the exhaustive mapping unit. (doyle updated the design doc §Architecture.)

# v0.13.0 bug 2 — rc Windows key→xterm-VT translator (JIT plan, todlando)

> Grounded 2026-06-19 after 3+4 surfaced for gate (@5c0870c). Authority = `V0.13.0-RC-VT-TRANSLATE-DESIGN.md` (doyle). This is the LAST v0.13.0 build item + the SHIP-BLOCKER (operator ruling). doyle gates; then operator HITL = real acceptance.

## Blocking question (sent to doyle, await ruling before the detach leg)
Detach keybind at the event level: **(A)** bare `Ctrl+B`→Detach (literal design, Win/Unix divergence) vs **(B)** preserve the `ctrl-b d` prefix at event level (uniform, keeps literal-ctrl-b). I lean **(B)**. Build the translate table FIRST (unaffected by this), wire detach once ruled.

## Grounding (already read — anchors)
- `crates/spt/src/rc.rs`:
  - `enum StdinMsg { Bytes(Vec<u8>), Detach }` (line 46) — unchanged.
  - `normalize_key_byte` (line 93, cfg(windows)) — the W7 swap (0x08↔0x7f). SUPERSEDED: remove from the Windows forward path; its Backspace/Ctrl+Backspace intent moves INTO `translate_key_event`.
  - `parse_stdin_chunk` (line 153) — the ctrl-b detach byte SM. STAYS for Unix + the non-tty Windows fallback.
  - `spawn_stdin_reader` (line 182) — THE surgical site. Today: blocking `stdin().read` → `parse_stdin_chunk` → (cfg windows) `normalize_key_byte` map → `StdinMsg::Bytes`.
  - `DETACH_PREFIX`=0x02 (ctrl-b), `DETACH_KEY`=detach companion (grep the consts).
  - Pump main loop (line ~486-555) consumes `StdinMsg` — unchanged.
- crossterm 0.28 already a dep; picker (`crates/spt/src/picker/`) already reads `crossterm::event` (KeyEvent/KeyEventKind/KeyModifiers). `event` feature on (default).

## Build steps
1. **REQ first (registry-first):** mint `REQ-RC-KEY-VT-TRANSLATE`, stages `[doc, impl, unit]`. Add a SUPERSEDE note to `REQ-HAZARD-RC-INPUT-KEY-ENCODING` (the W7 swap folded into the translator's Backspace/Ctrl+Backspace mapping; `normalize_key_byte` removed). KEEP that req's evidence alive by relocating its tags: tag `translate_key_event`'s Backspace→0x7f / Ctrl+Backspace→0x08 with `[impl->REQ-HAZARD-RC-INPUT-KEY-ENCODING]` + a unit `[unit->REQ-HAZARD-RC-INPUT-KEY-ENCODING]` (else traceable drops its coverage when normalize_key_byte goes).
2. **`fn translate_key_event(KeyEvent) -> Vec<u8>`** (cfg(windows), pure — the unit core). COPY a known-correct xterm table VERBATIM (ADR-0001 spirit; do NOT hand-roll sequences). Cover: plain Char (UTF-8), Char+CTRL (0x01..0x1a), Char+ALT (ESC+byte); Enter\r Tab 0x09 BackTab ESC[Z Esc 0x1b Backspace 0x7f Delete ESC[3~; Ctrl+Backspace 0x08; arrows ESC[A/B/C/D Home ESC[H End ESC[F Insert ESC[2~ PgUp ESC[5~ PgDn ESC[6~; modified specials `ESC[1;<m><final>` + `ESC[<n>;<m>~`, m=1+Shift+2*Alt+4*Ctrl; F1-4 ESC O P/Q/R/S, F5-12 ESC[15/17/18/19/20/21/23/24~ (+;m modified); unknown→empty (drop, never garbage).
3. **`spawn_stdin_reader` cfg-split:**
   - cfg(windows) AND `std::io::stdin().is_terminal()` → crossterm event loop: `event::read()`; on `Event::Key(ke)` with `ke.kind==Press` → detach-check (per doyle ruling) else `translate_key_event(ke)` → `StdinMsg::Bytes`. `Event::Paste(s)` → forward content bytes. Drop Repeat/Release.
   - cfg(windows) NON-tty (piped — tests/e2e) → EXISTING byte path WITHOUT normalize (raw bytes, like Unix; the swap was a console artifact). Preserves e2e byte-injection (dummy_harness/job_escape).
   - cfg(unix) → byte path + parse_stdin_chunk UNCHANGED (already VT).
4. **Remove `normalize_key_byte`** + its unit (Windows forward path) — behavior now in translate_key_event.
5. **Docs:** KNOWN-HAZARDS entry + CONTEXT note ("Windows rc translates console key events to standard xterm VT; Unix passes through"). `[doc->REQ-RC-KEY-VT-TRANSLATE]`.

## Tests (delegate to spt-test-engineer — ONE, background, ≤4 iter)
- **No int** (live console = HITL, REQ-RUN-PICKER/RC-1 precedent). The unit IS the surface: EXHAUSTIVE + non-vacuous `translate_key_event` table (every arrow/Home/End/PgUp/PgDn/Del/Insert/F-key/modifier-combo/Ctrl+char/Backspace/Ctrl+Backspace asserts its EXACT bytes vs a real xterm) + detach-event unit (per ruling) + the relocated REQ-HAZARD-RC-INPUT-KEY-ENCODING Backspace/Ctrl+Backspace unit.
- Spot-check the table against a real terminal (don't trust my transcription).

## Gates (doyle)
clippy --workspace -D warnings=0 · traceable EXIT=0 (`REQ-RC-KEY-VT-TRANSLATE` [doc,impl,unit] + REQ-HAZARD-RC-INPUT-KEY-ENCODING still covered) · docs-drift `cargo run -p xtask -- check` (no CLI change expected, run anyway) · real `spt` bin rebuilds · Unix path untouched (cfg-split) · non-tty fallback preserved. Then operator HITL = real acceptance.

## Watch-outs
- Unix ZERO regression (cfg-split; translate_key_event is cfg(windows) only).
- Non-tty fallback is load-bearing for the e2e byte-injection suites — don't break it.
- crossterm raw-mode is already enabled by RawGuard; the event loop reads under it.
- Don't reintroduce ENABLE_VIRTUAL_TERMINAL_INPUT (W7 rejected — win32-input-mode).

## Sequencing
After this: doyle gates → operator HITL. Then v0.13.0 PR bundles W1/W1b/W2/W3/W4/W5/W7/F1 + window + 3+4 + bug2 + gravity-linux green (doyle-tracked). bug 2 is the last build item gating the ship.

-## Blocking question (sent to doyle, await ruling before the detach leg)

-Detach keybind at the event level: **(A)** bare `Ctrl+B`→Detach (literal design, Win/Unix divergence) vs **(B)** preserve the `ctrl-b d` prefix at event level (uniform, keeps literal-ctrl-b). I lean **(B)**. Build the translate table FIRST (unaffected by this), wire detach once ruled.

+## Detach keybind — RULED (doyle, Option B): preserve the `ctrl-b d` prefix at the event level

+Mirror `parse_stdin_chunk`, event-sourced. Ctrl+B (`Char('b')`+CONTROL) ARMS (don't forward). While armed, next `KeyEvent`:

+- plain `Char('d')` (NO ctrl) ⇒ **Detach**

+- Ctrl+B again ⇒ emit literal **0x02** + disarm

+- any other ⇒ emit **0x02** THEN `translate_key_event(that)` + disarm

+Detach key is PLAIN `'d'` ONLY (Ctrl+D=0x04 stays prefix+0x04, matching today's byte SM). Factor the arm/d/ctrl-b/other state as a SHARED logical SM over both input types (bytes=Unix, events=Windows) IF clean; else replicate the four arms in the Windows event loop — keep SEMANTICS identical so a unit asserts both paths agree. Add a Windows-event **detach unit** (armed+'d'⇒Detach; armed+Ctrl+B⇒0x02; armed+other⇒0x02+translated) alongside the exhaustive mapping unit. (doyle updated the design doc §Architecture.)


Bash

$LIVE psyche-download todlando 2>&1 | head -50

Download current Psyche context for commune diff


<memformat>
<?xml version="1.0"?>
<memformat version="1">
<!-- Changelog: v1 - initial seed -->
<topics>
  <topic name="events">Notable or unusual events and requests</topic>
  <topic name="interactions">Agent interactions -- who, roles, contributions</topic>
  <topic name="reflections">Thoughts on recent work -- what went well, what was tricky</topic>
  <topic name="ideas">Ideas to raise with the user</topic>
  <topic name="improvements">Further improvement ideas for recent problem spaces</topic>
  <topic name="timeline">What happened when, timing, sequencing</topic>
  <topic name="blockers">Current blockers or concerns</topic>
  <topic name="focus">Current focus and immediate next steps</topic>
</topics>
</memformat>
</memformat>

<current machine="HFENDULEAM" project="spt-core" branch="v0.13.0-delivery-control" head_sha="5c0870c143b9c7a20b3216c58ad6fcf5658b6a7c" head_subject="fix(rc): await endpoint online before attach — kill the endpoint-run att…" commits_since="0" commits_unpulled="0"/>
**W4 WMI window fix → doyle GATE PASS @082f5ed.** All gates: job_escape_e2e 2/2 non-vacuous (RED→GREEN), clippy `-D warnings`=0, parse unit, traceable EXIT=0, docs-drift OK. Load-bearing finding: `FreeConsole()` alone broke bringup (Rust eprintln panics on invalid post-FreeConsole handle); NULL std handles required. REQ-HAZARD-WMI-DAEMON-WINDOW `[impl,unit,int]` closed.

**Bugs 3+4 ROOT CAUSE + FIX IMPLEMENTED & VERIFIED.** `cmd_endpoint_run` launches `harness_brokered_in` then calls `run_attach` with NO await-online: (3) Start-now races mid-boot, attach fails; (4) Resume hits fully offline, short-circuits "nothing to attach". Fix: `await_endpoint_online` polls spt_store::info status between launch success + run_attach, bounded ~20-30s, attach/view only. Touches gate-critical auto-attach path. REQ-RUN-PICKER folded + REQ-HAZARD-RC-ATTACH-ONLINE-RACE registry `[impl,unit,int]` closed.

**INT test GREEN, non-vacuous verified.** Two test defects fixed: (1) closed stdin masked everything → fixed via held stdin + piped stdout streaming; (2) timing-racy fresh-id → fixed via deterministic OFFLINE precondition (kill harness, poll `read_info().status` → OFFLINE). Diagnostic: `online_1=true went_offline=true saw_tick=true bailed_offline=false` (heartbeat delivery keystone). Non-vacuity proof: gate-off→`bailed_offline=true` (immediate attach reads stale OFFLINE, bails), gate-on→`saw_tick=true` (await re-binds ONLINE, attaches, delivers tick). Zero survivors post-cleanup. Ready to commit 3+4 + move to bug 2 (VT translator).

**Bug 2 (special keys dead, arrows/Home/End) still deferred.** Waiting operator scope ruling: quick VT-input vs proper key-event→xterm-VT translator; v0.13.0 vs fast-follow.<project-context-resolved name="spt-core"/>
W4 code-complete and gate-complete. Bugs 3+4 (attach-online race) implementation + test verification done. INT test GREEN, non-vacuity confirmed. Commit pending (V0.13.0-RC-VT-TRANSLATE-DESIGN.md swept in by mistake, needs unstage). Bug 2 scope ruling awaited. Remaining: W6 (perri, release-gated) + deferred (Layer F gravity, A2 SEED, W1b F2 pending-check) + PR leg (gravity-linux CI + HITL of picker, doyle-tracked).


Fresh psyche has W4 + window + 3+4-up-to-"ready to commit", and bug 2 still as "deferred/awaiting ruling". `project="spt-core"` marker present → both envelopes. The delta: 3+4 committed+gated @5c0870c, bug 2 now RULED (proper translator, ship-blocker) + grounded + JIT-planned + doyle's Option-B detach ruling. Write the commune:


Write

<live-context>
# Delta since last echo (which stopped at "3+4 ready to commit, bug 2 deferred/awaiting ruling")

## 3+4 COMMITTED + doyle GATE PASS @5c0870c
REQ-HAZARD-RC-ATTACH-ONLINE-RACE [impl,unit,int] closed. Unstaged the swept-in design doc, committed clean. doyle independently re-verified (dummy_harness_e2e 2/2 non-vacuous, poll_online unit, clippy 0, traceable 0) and praised the false-red catch + gate-toggle non-vacuity. Completes W4 attach-by-default for real.

## Bug 2 is NO LONGER deferred — operator RULED + it's the SHIP-BLOCKER
Operator ruling: proper AGNOSTIC key-event→xterm-VT translator, HOLD v0.13.0 for it (full key fidelity in the release). It is the LAST v0.13.0 build item and BLOCKS the ship. doyle design = V0.13.0-RC-VT-TRANSLATE-DESIGN.md; my grounded JIT plan = V0.13.0-BUG2-RC-VT-TRANSLATE-PLAN.md.

Core: pure `translate_key_event(KeyEvent)->Vec<u8>` emitting STANDARD xterm VT (copy a known-correct table VERBATIM, not hand-rolled — ADR-0001 spirit). Windows `spawn_stdin_reader` (rc.rs:182) cfg-splits: tty→crossterm events / non-tty→byte fallback (e2e injection) / Unix unchanged. Remove `normalize_key_byte` (W7 swap) — RELOCATE its Backspace→0x7f / Ctrl+Backspace→0x08 tags into translate_key_event so REQ-HAZARD-RC-INPUT-KEY-ENCODING stays covered. Mint REQ-RC-KEY-VT-TRANSLATE [doc,impl,unit] registry-first + supersede-note. NO int (live console = HITL); exhaustive non-vacuous mapping unit is the surface.

## doyle DETACH RULING = Option B (resolved my blocking question)
Preserve the `ctrl-b d` PREFIX at the event level (NOT bare Ctrl+B). Ctrl+B arms (don't forward); armed+plain-'d'⇒Detach; armed+Ctrl+B⇒emit literal 0x02+disarm; armed+other⇒0x02 then translate(other)+disarm. Detach key = plain 'd' only (Ctrl+D stays prefix+0x04). Factor a SHARED logical SM over bytes(Unix)/events(Windows) if clean; add a Windows-event detach unit. doyle updated the design doc.

## RESUME POINT (post self-clear)
Build bug 2 per the JIT plan. Build the xterm table FIRST (ruling-independent), then wire the Option-B detach SM. Discipline unchanged: I own src/REQ/docs + breaking-test fixes; ONE spt-test-engineer background ≤4-iter for the exhaustive mapping + detach units (spot-check the table vs a real xterm); gates = workspace clippy -D warnings + traceable EXIT=0 + docs-drift xtask check + real spt bin; surface to doyle for independent gate. On bug2 gate-pass: doyle batched PATH deploy → operator HITL everything → PR → deployah cuts v0.13.0 → W6 perri.
</live-context>
<project-context>
v0.13.0 delivery-control: ALL build waves doyle-GATED except bug 2 (RC VT translator) — the LAST build item + the SHIP-BLOCKER (operator ruling). Gated this cluster: W4 picker UX @ed1aa04 (REQ-RUN-PICKER + REQ-PICKER-4 extensions), WMI daemon-window FreeConsole fix @082f5ed (REQ-HAZARD-WMI-DAEMON-WINDOW reopened [impl,unit,int] — the v0.12.2 cmd-wrapper detach was insufficient; the inner broker allocated its own conhost; fix = broker FreeConsole + NULL std handles, the NULL being load-bearing or eprintln panics on the post-FreeConsole invalid handle), bugs 3+4 attach-online race @5c0870c (REQ-HAZARD-RC-ATTACH-ONLINE-RACE [impl,unit,int] — cmd_endpoint_run awaits STATUS_ONLINE before run_attach). Architectural fact: rc input on Windows is moving from a raw byte-pump to crossterm key-EVENTS → standard xterm VT (agnostic, full fidelity); Unix stays the byte-pump (already VT). On bug2 gate-pass the ship sequence is: doyle batched PATH deploy → operator HITL → PR (gravity-linux hard-legs confirm) → deployah cuts v0.13.0 → then W6 (perri translation binary, release-gated). Deferred/doyle-tracked: Layer F gravity, REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT (A2 SEED), W1b F2 (forkpty park bound).
</project-context>


File created successfully at: C:\Users\decid\Documents\projects\spt-core\.claude\todlando-commune.md (file state is current in your context — no need to Read it back)

<live-context>
# Delta since last echo (which stopped at "3+4 ready to commit, bug 2 deferred/awaiting ruling")

## 3+4 COMMITTED + doyle GATE PASS @5c0870c
REQ-HAZARD-RC-ATTACH-ONLINE-RACE [impl,unit,int] closed. Unstaged the swept-in design doc, committed clean. doyle independently re-verified (dummy_harness_e2e 2/2 non-vacuous, poll_online unit, clippy 0, traceable 0) and praised the false-red catch + gate-toggle non-vacuity. Completes W4 attach-by-default for real.

## Bug 2 is NO LONGER deferred — operator RULED + it's the SHIP-BLOCKER
Operator ruling: proper AGNOSTIC key-event→xterm-VT translator, HOLD v0.13.0 for it (full key fidelity in the release). It is the LAST v0.13.0 build item and BLOCKS the ship. doyle design = V0.13.0-RC-VT-TRANSLATE-DESIGN.md; my grounded JIT plan = V0.13.0-BUG2-RC-VT-TRANSLATE-PLAN.md.

Core: pure `translate_key_event(KeyEvent)->Vec<u8>` emitting STANDARD xterm VT (copy a known-correct table VERBATIM, not hand-rolled — ADR-0001 spirit). Windows `spawn_stdin_reader` (rc.rs:182) cfg-splits: tty→crossterm events / non-tty→byte fallback (e2e injection) / Unix unchanged. Remove `normalize_key_byte` (W7 swap) — RELOCATE its Backspace→0x7f / Ctrl+Backspace→0x08 tags into translate_key_event so REQ-HAZARD-RC-INPUT-KEY-ENCODING stays covered. Mint REQ-RC-KEY-VT-TRANSLATE [doc,impl,unit] registry-first + supersede-note. NO int (live console = HITL); exhaustive non-vacuous mapping unit is the surface.

## doyle DETACH RULING = Option B (resolved my blocking question)
Preserve the `ctrl-b d` PREFIX at the event level (NOT bare Ctrl+B). Ctrl+B arms (don't forward); armed+plain-'d'⇒Detach; armed+Ctrl+B⇒emit literal 0x02+disarm; armed+other⇒0x02 then translate(other)+disarm. Detach key = plain 'd' only (Ctrl+D stays prefix+0x04). Factor a SHARED logical SM over bytes(Unix)/events(Windows) if clean; add a Windows-event detach unit. doyle updated the design doc.

## RESUME POINT (post self-clear)
Build bug 2 per the JIT plan. Build the xterm table FIRST (ruling-independent), then wire the Option-B detach SM. Discipline unchanged: I own src/REQ/docs + breaking-test fixes; ONE spt-test-engineer background ≤4-iter for the exhaustive mapping + detach units (spot-check the table vs a real xterm); gates = workspace clippy -D warnings + traceable EXIT=0 + docs-drift xtask check + real spt bin; surface to doyle for independent gate. On bug2 gate-pass: doyle batched PATH deploy → operator HITL everything → PR → deployah cuts v0.13.0 → W6 perri.
</live-context>
<project-context>
v0.13.0 delivery-control: ALL build waves doyle-GATED except bug 2 (RC VT translator) — the LAST build item + the SHIP-BLOCKER (operator ruling). Gated this cluster: W4 picker UX @ed1aa04 (REQ-RUN-PICKER + REQ-PICKER-4 extensions), WMI daemon-window FreeConsole fix @082f5ed (REQ-HAZARD-WMI-DAEMON-WINDOW reopened [impl,unit,int] — the v0.12.2 cmd-wrapper detach was insufficient; the inner broker allocated its own conhost; fix = broker FreeConsole + NULL std handles, the NULL being load-bearing or eprintln panics on the post-FreeConsole invalid handle), bugs 3+4 attach-online race @5c0870c (REQ-HAZARD-RC-ATTACH-ONLINE-RACE [impl,unit,int] — cmd_endpoint_run awaits STATUS_ONLINE before run_attach). Architectural fact: rc input on Windows is moving from a raw byte-pump to crossterm key-EVENTS → standard xterm VT (agnostic, full fidelity); Unix stays the byte-pump (already VT). On bug2 gate-pass the ship sequence is: doyle batched PATH deploy → operator HITL → PR (gravity-linux hard-legs confirm) → deployah cuts v0.13.0 → then W6 (perri translation binary, release-gated). Deferred/doyle-tracked: Layer F gravity, REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT (A2 SEED), W1b F2 (forkpty park bound).
</project-context>


Commune queued. Commit the planning artifacts (design + JIT plan) so they're preserved in history — matches the per-W `docs(plan)` convention:


PowerShell

git add V0.13.0-RC-VT-TRANSLATE-DESIGN.md V0.13.0-BUG2-RC-VT-TRANSLATE-PLAN.md; git commit -m @'
docs(plan): bug 2 RC VT translator — doyle design + grounded JIT plan (Option-B detach ruling)

The LAST v0.13.0 build item + ship-blocker (operator ruling: proper agnostic
key-event→xterm-VT translator). doyle design doc (V0.13.0-RC-VT-TRANSLATE-DESIGN.md,
incl the Option-B detach ruling) + my grounded JIT plan: pure translate_key_event
emitting standard xterm VT, Windows spawn_stdin_reader cfg-split (crossterm events
on tty / byte fallback non-tty / Unix unchanged), normalize_key_byte superseded
(tags relocated so REQ-HAZARD-RC-INPUT-KEY-ENCODING stays covered), mint
REQ-RC-KEY-VT-TRANSLATE [doc,impl,unit]. Detach = preserve ctrl-b d prefix at the
event level (doyle Option B). No int (live console = HITL); exhaustive non-vacuous
mapping unit is the surface.

Co-authored by: todlando
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVVByRoZnz85acTkVJ1uAP
'@

Commit bug-2 design + JIT plan


warning: in the working copy of 'V0.13.0-BUG2-RC-VT-TRANSLATE-PLAN.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'V0.13.0-RC-VT-TRANSLATE-DESIGN.md', LF will be replaced by CRLF the next time Git touches it
[v0.13.0-delivery-control b26f2b3] docs(plan): bug 2 RC VT translator — doyle design + grounded JIT plan (Option-B detach ruling)
 2 files changed, 88 insertions(+)
 create mode 100644 V0.13.0-BUG2-RC-VT-TRANSLATE-PLAN.md
 create mode 100644 V0.13.0-RC-VT-TRANSLATE-DESIGN.md


Plans committed @b26f2b3. Commune queued, JIT plan + design + Option-B ruling all on disk and in git history. Everything needed to resume is durable. Self-clear and resume bug 2 fresh:


Skill

Resume v0.13.0 bug 2 — the RC Windows key→xterm-VT translator, the LAST build item + SHIP-BLOCKER. Read V0.13.0-BUG2-RC-VT-TRANSLATE-PLAN.md (your grounded JIT plan; authority = V0.13.0-RC-VT-TRANSLATE-DESIGN.md) FIRST. Context: this session shipped + doyle-gated W4 @ed1aa04, WMI-window fix @082f5ed, bugs 3+4 @5c0870c; ALL v0.13.0 build waves gated EXCEPT bug 2. Plans committed @b26f2b3. doyle RULED detach = Option B (preserve ctrl-b d prefix at the event level — Ctrl+B arms; armed+plain-'d'⇒Detach; armed+Ctrl+B⇒literal 0x02; armed+other⇒0x02+translate(other)). BUILD ORDER: (1) mint REQ-RC-KEY-VT-TRANSLATE [doc,impl,unit] registry-first + supersede-note on REQ-HAZARD-RC-INPUT-KEY-ENCODING; (2) pure translate_key_event(KeyEvent)->Vec<u8> in rc.rs — COPY a known-correct xterm table VERBATIM (NOT hand-rolled); (3) spawn_stdin_reader (rc.rs:182) Windows cfg-split: tty→crossterm events / non-tty→byte fallback / Unix unchanged; remove normalize_key_byte, RELOCATE its Backspace→0x7f/Ctrl+Backspace→0x08 tags into translate_key_event so REQ-HAZARD-RC-INPUT-KEY-ENCODING stays covered; (4) docs (KNOWN-HAZARDS + CONTEXT). DISCIPLINE: I own src/REQ/docs; ONE spt-test-engineer background ≤4-iter for the exhaustive non-vacuous mapping unit + the Windows-event detach unit (spot-check vs real xterm); NO int (live console = HITL). GATES: workspace clippy -D warnings=0, traceable EXIT=0, docs-drift `cargo run -p xtask -- check`, real spt bin rebuilds, Unix path untouched, non-tty fallback preserved. Then surface to doyle for gate (gate-pass → doyle batched PATH deploy → operator HITL → PR → deployah cuts v0.13.0 → W6 perri). I'm todlando (live); stay reachable via owl; run subagents background.


Launching skill: self-clear


Base directory for this skill: C:\Users\decid\.claude\skills\self-clear

# /self-clear

Wipes THIS agent's conversation (`/clear`), then ~200ms later TYPES a self-note
as a fresh user prompt so the agent wakes on a clean context and proceeds.

## Run it

One script does everything. Just run:

```powershell
& "C:\Users\decid\.claude\skills\self-clear\scripts\self-clear.ps1"
```

Expect: `SELF-CLEAR armed: id=<agent_id> winpid=<n> hwnd=<n> — /clear queued; self-note typed in ~200ms`

Then stop — end your turn promptly so the queued `/clear` fires.

## What the script does

1. `owl whoami` → resolves this session's `agent_id` (context-independent, survives the wipe). No perch → it aborts WITHOUT clearing and tells you to `/spt:ready` or `/spt:live` first.
2. Walks the parent chain (`pwsh → claude.exe → … → WindowsTerminal`) to the ancestor **pid that owns a visible top-level window** (enumerated by owner pid — not the program's default "main window", which is wrong when a host serves several windows). Since each WT window is its own process, that pid uniquely identifies this session's window. **Reliably focuses it** via `AttachThreadInput` (a background process can't otherwise hold the foreground) and **hard-verifies it genuinely holds the foreground** — aborts WITHOUT clearing and WITHOUT sending any keys if it can't.
3. Re-verifies foreground with **zero gap**, then types `/clear{ENTER}` in one SendKeys — **queued**; only fires when this turn ends.
4. Spawns a **detached** waker: waits 200ms (past turn-end + wipe), reliably re-focuses the captured `hwnd`, and — **only if that exact HWND genuinely holds the foreground** — **types** `Proceed with next steps` + Enter as a fresh user prompt. Otherwise it aborts the SendKeys (HWND identity is the guard; no title-match — Claude Code's terminal title animates and changes across `/clear`).

Why this matters: `SendKeys` hits whatever window is foreground *at send time*. The fix attaches our input queue to the foreground thread so the focus steal actually holds, confirms the target is truly foreground, and aborts rather than type blind — so the note can never leak into the window you're working in.

The delay + detachment are deliberate: the prompt must be typed **after**
`/clear` takes effect, so it lands on the clean context and starts a new turn.

Why typed, not `owl send`: after `/clear` the session sits idle awaiting user
input. A spooled owl message can't self-start a turn — it surfaces only during an
already-active turn (PreToolUse hook) or in a live Monitor stream (whose buffer
`/clear` wipes). So a self-addressed owl note never woke the agent. A typed prompt
deterministically starts a turn AND is the re-seed.

## Caveats

- Focus targets the window owned by this session's ancestor pid (enumerated by owner pid). It still can't pick a specific tab when one window hosts multiple tabs; clean when the agent's tab is active.
- The waker types only when our exact window is foreground. If you switch to a different window/tab during the ~200ms gap, the wake-note SendKeys is aborted (the agent stays cleared but unwoken) — stay in the agent window for the wake.
- The note is typed via SendKeys, so the terminal must stay the foreground-able window of `winpid`. Don't close/replace the terminal in the gap.
- SendKeys reserves `+ ^ % ~ ( ) { } [ ]`; the default note text uses none. If you customize the note, escape those chars.
- The waker waits only ~200ms after the script returns, so stop promptly — if your turn runs longer, the prompt could be typed before the wipe completes.

ARGUMENTS: Resume v0.13.0 bug 2 — the RC Windows key→xterm-VT translator, the LAST build item + SHIP-BLOCKER. Read V0.13.0-BUG2-RC-VT-TRANSLATE-PLAN.md (your grounded JIT plan; authority = V0.13.0-RC-VT-TRANSLATE-DESIGN.md) FIRST. Context: this session shipped + doyle-gated W4 @ed1aa04, WMI-window fix @082f5ed, bugs 3+4 @5c0870c; ALL v0.13.0 build waves gated EXCEPT bug 2. Plans committed @b26f2b3. doyle RULED detach = Option B (preserve ctrl-b d prefix at the event level — Ctrl+B arms; armed+plain-'d'⇒Detach; armed+Ctrl+B⇒literal 0x02; armed+other⇒0x02+translate(other)). BUILD ORDER: (1) mint REQ-RC-KEY-VT-TRANSLATE [doc,impl,unit] registry-first + supersede-note on REQ-HAZARD-RC-INPUT-KEY-ENCODING; (2) pure translate_key_event(KeyEvent)->Vec<u8> in rc.rs — COPY a known-correct xterm table VERBATIM (NOT hand-rolled); (3) spawn_stdin_reader (rc.rs:182) Windows cfg-split: tty→crossterm events / non-tty→byte fallback / Unix unchanged; remove normalize_key_byte, RELOCATE its Backspace→0x7f/Ctrl+Backspace→0x08 tags into translate_key_event so REQ-HAZARD-RC-INPUT-KEY-ENCODING stays covered; (4) docs (KNOWN-HAZARDS + CONTEXT). DISCIPLINE: I own src/REQ/docs; ONE spt-test-engineer background ≤4-iter for the exhaustive non-vacuous mapping unit + the Windows-event detach unit (spot-check vs real xterm); NO int (live console = HITL). GATES: workspace clippy -D warnings=0, traceable EXIT=0, docs-drift `cargo run -p xtask -- check`, real spt bin rebuilds, Unix path untouched, non-tty fallback preserved. Then surface to doyle for gate (gate-pass → doyle batched PATH deploy → operator HITL → PR → deployah cuts v0.13.0 → W6 perri). I'm todlando (live); stay reachable via owl; run subagents background.


Running the self-clear script — it arms `/clear` + queues the resume note, then I end the turn so the wipe fires:


PowerShell

Arm self-clear + queue resume note


SELF-CLEAR armed: id=todlando winpid=11076 hwnd=65960 title="✳ Proceed with next steps" — focus held + /clear queued; self-note typed in ~200ms (waker aborts unless our window holds foreground)


Self-clear armed. Ending turn so `/clear` fires and the resume note lands on a clean context.

**Session summary — 3 ship-blockers shipped + doyle-gated, bug 2 handed off to fresh context:**
- **W4 picker UX** @ed1aa04
- **WMI daemon-window fix** @082f5ed (REQ-HAZARD-WMI-DAEMON-WINDOW reopened)
- **Bugs 3+4 attach-online race** @5c0870c (REQ-HAZARD-RC-ATTACH-ONLINE-RACE)
- **Bug 2 (RC VT translator)** — grounded, JIT-planned, design + Option-B detach ruling committed @b26f2b3; commune queued; resuming fresh post-clear.

All v0.13.0 build waves gated except bug 2 (the last item). Ending turn.


<task-notification>
<task-id>bv8r78fk1</task-id>
<summary>Monitor event: "« spt event »"</summary>
<event><EVENT type="msg" from="doyle">doyle here, post-/clear. Picking up v0.13.0 tail. Gating bugs 3+4 (@5c0870c) now. Status on bug 2 RC VT translator (REQ-RC-KEY-VT-TRANSLATE)? Plan @b26f2b3 grounded — are you building it, or want me to mint the REQ + dispatch? It's the last ship-blocker.</EVENT></event>
</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.


doyle (post-/clear) asking bug-2 status. It's mine (todlando) — I just JIT-planned it + armed a self-clear to build it fresh. Claim it firmly so doyle doesn't double-start, then end the turn so the queued `/clear` fires and I resume on clean context:


Bash

$OWL send doyle <<'EOF'
Bug 2 (RC VT translator) is MINE — building it, do NOT mint/dispatch. Status: grounded + JIT-planned, plan + your design committed @b26f2b3 (V0.13.0-BUG2-RC-VT-TRANSLATE-PLAN.md + V0.13.0-RC-VT-TRANSLATE-DESIGN.md). Your DETACH RULING already absorbed = Option B (preserve ctrl-b d prefix at the event level: Ctrl+B arms; armed+plain-'d'⇒Detach; armed+Ctrl+B⇒literal 0x02; armed+other⇒0x02+translate(other)). REQ-RC-KEY-VT-TRANSLATE not yet minted — I mint it registry-first as step 1 of the build (don't pre-mint, avoids a collision).

I'm self-clearing RIGHT NOW to build it on fresh context (3 items already done+gated this session: W4 @ed1aa04, WMI-window @082f5ed, 3+4 @5c0870c — heavy context). Post-clear I resume from the committed JIT plan + my Psyche commune. Build order: mint REQ → pure translate_key_event (verbatim xterm table) → spawn_stdin_reader Windows cfg-split (crossterm events tty / byte fallback non-tty / Unix unchanged) → remove+relocate normalize_key_byte → docs → ONE engineer for the exhaustive mapping + Option-B detach units → gates → surface to you.

3+4 already GATE-PASSED by you @5c0870c earlier (pre-your-clear) — re-gate if you want, but it's [impl,unit,int] green. I'll surface bug 2 when green. It's the last ship-blocker; on your gate-pass → batched PATH deploy → operator HITL → PR → deployah cuts v0.13.0.
EOF

Claim bug 2, confirm self-clear in progress


➜ SENT:doyle


Claimed bug 2 + status sent to doyle. Ending turn now so the queued `/clear` fires — I resume on clean context from the committed JIT plan (@b26f2b3) + Psyche commune.


<task-notification>
<task-id>bv8r78fk1</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/3" id="c53f4cc0" type="msg" from="doyle">ACK. Bug 2 is yours — no mint, no dispatch from me. Option-B detach ruling confirmed absorbed (arm/d/ctrl-b/other). Mint REQ-RC-KEY-VT-TRANSLATE registry-first as step 1, agreed (avoids collision).

3+4 RE-GATE (post-my-clear, full): GATE-PASS, all four green —
- clippy --workspace --all-targets -D warnings</EVENT-PART>
<EVENT-PART seq="2/3" id="c53f4cc0"> = exit 0
- poll_online unit ✓ (fast-path 0 sleeps / timeout 4checks-3sleeps / later-success stops — non-vacuous, no wall-clock)
- traceable EXIT=0, REQ-HAZARD-RC-ATTACH-ONLINE-RACE [impl,unit,int]
- dummy_harness_e2e attach-online int ✓: online_1=true went_offline=true saw_tick=true bailed_offline=false (await held, TICK delive</EVENT-PART>
<EVENT-PART seq="3/3" id="c53f4cc0">red post-bringup)

Standing by to gate bug 2 when you surface it green. Last ship-blocker → batched PATH deploy → operator HITL → PR → deployah v0.13.0. Build clean.</EVENT-PART></event>
</task-notification>


doyle ACK: bug 2 mine, 3+4 full RE-GATE PASS (all four green). The self-clear got disrupted by the message traffic — rather than fight it, I'll build bug 2 in this context (fully grounded, plan committed). Starting step 1 (mint REQ registry-first). Ground the last details first — `DETACH_KEY` const + crossterm event availability in rc.rs, and the registry tail:


38:const DETACH_PREFIX: u8 = 0x02; // ctrl-b
39:const DETACH_KEY: u8 = b'd';
159:                DETACH_KEY => return DetachParse { forward, detach: true },
160:                DETACH_PREFIX => forward.push(DETACH_PREFIX), // literal ctrl-b
162:                    forward.push(DETACH_PREFIX);
166:        } else if b == DETACH_PREFIX {
674:            parse(&mut armed, &[b'h', DETACH_PREFIX, DETACH_KEY]),
680:            parse(&mut a2, &[DETACH_PREFIX, DETACH_PREFIX]),
681:            (vec![DETACH_PREFIX], false)
686:            parse(&mut a3, &[DETACH_PREFIX, b'x']),
687:            (vec![DETACH_PREFIX, b'x'], false)
698:        assert_eq!(parse(&mut armed, &[b'a', DETACH_PREFIX]), (b"a".to_vec(), false));
701:        assert_eq!(parse(&mut armed, &[DETACH_KEY]), (Vec::new(), true));


tail -3 traceable-reqs.toml | cut -c1-90

Registry tail anchor


id = "REQ-HAZARD-RC-ATTACH-ONLINE-RACE"
title = "`spt endpoint run` in an ATTACH/VIEW terminal action attaches BEFORE the freshly-
required_stages = ["impl", "unit", "int"]  # ACTIVATED v0.13.0 (todlando, doyle-dispatched


`DETACH_KEY=b'd'`, `DETACH_PREFIX=0x02`; rc.rs doesn't yet import crossterm events (I'll add them). Now mint the REQ — read the exact registry tail to anchor the append:


awk 'END{print NR}' traceable-reqs.toml; echo "---"; sed -n '$p' traceable-reqs.toml | tail -c 140

Get last line number + tail fragment


1239
---
put, saw_tick=false; the heartbeat-delivery gate is robust to all three faces; GREEN post-fix), full daemon-tree reap hygiene. Repro-first.