@zhuxixi/pi-agent-board 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +4 -3
  3. package/docs/superpowers/plans/2026-10-10-attach-delete-in-place.md +832 -0
  4. package/docs/superpowers/plans/2026-10-14-issue-170-attach-reply-guard.md +771 -0
  5. package/docs/superpowers/plans/2026-10-14-issue-178-accept-registration-race.md +1257 -0
  6. package/docs/superpowers/plans/2026-10-14-issue-184-snapshot-window-contract.md +657 -0
  7. package/docs/superpowers/plans/2026-10-14-issue-185-attach-wheel-alt-screen.md +47 -0
  8. package/docs/superpowers/plans/2026-10-14-issue-187-motionless-drag.md +931 -0
  9. package/docs/superpowers/plans/2026-10-14-issue-189-mixed-input-segments.md +823 -0
  10. package/docs/superpowers/plans/2026-10-15-issue-188-x10-mouse-release.md +526 -0
  11. package/docs/superpowers/plans/2026-10-16-issue-198-attach-wheel-forward-fallback.md +32 -0
  12. package/docs/superpowers/plans/2026-10-18-attach-wheel-replay-pseudo-scrollback.md +437 -0
  13. package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +6 -0
  14. package/docs/superpowers/specs/2026-10-10-attach-delete-in-place-design.md +224 -0
  15. package/docs/superpowers/specs/2026-10-14-issue-170-attach-reply-guard-design.md +190 -0
  16. package/docs/superpowers/specs/2026-10-14-issue-178-accept-registration-race-design.md +287 -0
  17. package/docs/superpowers/specs/2026-10-14-issue-184-snapshot-window-contract-design.md +288 -0
  18. package/docs/superpowers/specs/2026-10-14-issue-185-attach-wheel-alt-screen-design.md +126 -0
  19. package/docs/superpowers/specs/2026-10-14-issue-187-motionless-drag-design.md +269 -0
  20. package/docs/superpowers/specs/2026-10-14-issue-189-mixed-input-segments-design.md +369 -0
  21. package/docs/superpowers/specs/2026-10-15-issue-188-x10-mouse-release-design.md +252 -0
  22. package/docs/superpowers/specs/2026-10-16-issue-198-attach-wheel-forward-fallback-design.md +67 -0
  23. package/docs/superpowers/specs/2026-10-18-attach-wheel-replay-pseudo-scrollback-design.md +250 -0
  24. package/package.json +1 -1
  25. package/src/commands/agent-board.ts +27 -3
  26. package/src/core/attach-delete.mjs +24 -0
  27. package/src/core/pty-scroll.mjs +323 -12
  28. package/src/core/rows.mjs +19 -0
  29. package/src/core/terminal-attach-client.mjs +9 -4
  30. package/src/core/terminal-attach-protocol.mjs +5 -2
  31. package/src/core/terminal-reply-guard.mjs +200 -0
  32. package/src/ui/dashboard.ts +4 -0
  33. package/src/ui/pty-attach.ts +334 -38
@@ -0,0 +1,771 @@
1
+ # Issue #170 Attach Inbound Reply Guard Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Stop fragmented terminal replies (DA1/DA2/DA3, kitty flags, cell size, DCS) from leaking into the attached child's input box, while still forwarding color replies (OSC 4/10/11/12 and the `997` scheme report) as one whole sequence.
6
+
7
+ **Architecture:** A new pure module `src/core/terminal-reply-guard.mjs` owns a classifier (`classifyReplyEvent`) and a timer-free state machine (`createReplyGuard`) that absorbs reply fragments inside a 250ms / 64-byte window anchored at a reply head. `PtyAttachComponent.handleInput` gets a three-way first-line wiring (pass / swallow / forward-whole) plus one window timer, all disabled by `AGENT_BOARD_REPLY_GUARD=0`.
8
+
9
+ **Tech Stack:** ESM `.mjs` pure module (no deps, no pi-tui import), TypeScript wiring (tabs), `node:test` + `assert/strict` unit tests, `node --experimental-transform-types` component smoke following the #180 pattern.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-10-14-issue-170-attach-reply-guard-design.md` — acceptance IDs **A1–A9** (automated) and **U1–U3** (user-observed). Line numbers below are at branch base `f81949d`.
12
+
13
+ **Dry run (authoring evidence):** the Task 1–2 module code and every A1–A5/A7 unit assertion below were executed as written in a scratch directory before this plan was committed — 15 tests pass, c8 reports 100% statements / 97.8% branches / 100% functions on `terminal-reply-guard.mjs`. The snippets are verified, not illustrative; only the Task 3 wiring snippets are unexecuted.
14
+
15
+ ## Global Constraints
16
+
17
+ - Work ONLY inside the worktree `<WT>` = `<repo>/.pi/worktrees/issue-170-attach-reply-guard` (branch `issue-170-attach-reply-guard`); never touch main.
18
+ - Every edit path starts with `<WT>`; git commands use `git -C <WT>` (the session cwd cannot change).
19
+ - Tabs for indentation; English comments and commit messages; conventional commit subjects.
20
+ - Constants verbatim from the spec: `REPLY_GUARD_WINDOW_MS = 250`, `REPLY_GUARD_MAX_ABSORB_BYTES = 64`. They are NOT configurable — the kill switch is the only new knob.
21
+ - `src/core/terminal-reply-guard.mjs` is pure: no I/O, no timers, no `process.env`, no pi-tui import. Time is always injected as a `now` argument.
22
+ - The guard runs FIRST in `handleInput`, before `parseMouseInputChunk` and every key interception. Text/keys it does not classify as a reply must reach the existing fallback unchanged.
23
+ - No changes to #128 forwarding semantics, the #180 `childInputReady` gate, the jiggle controller, `detachColorSchemeBridge`, or the detach chords.
24
+ - Test names carry their acceptance ID (`test("A1: …")`).
25
+ - Branch gate is `npm run verify` (A8); a task's own gate is the command listed in its steps.
26
+
27
+ ## Known boundaries (do NOT "fix" these without a new spec)
28
+
29
+ - DSR/CPR (`ESC[<r>;<c>R`) is explicitly out of scope (shares a prefix with key encodings).
30
+ - A reply tail that arrives after the window closed still leaks into the child — the guard lowers the rate, it cannot eliminate it.
31
+ - An `other` event (key/mouse/paste) always ends an open window and is passed: user keys win over absorbing one more fragment.
32
+ - Consequence of the point above: a lone `ESC \` (ST) arriving as its own fragment classifies as `other` and is passed. It is not in the A-item matrix; treat it as the documented cost of never swallowing keys.
33
+
34
+ ---
35
+
36
+ ### Task 1: Pure classifier (A1, A4 pass side, A5 complete forms)
37
+
38
+ **Files:**
39
+ - Create: `<WT>/src/core/terminal-reply-guard.mjs`
40
+ - Create: `<WT>/test/terminal-reply-guard.test.mjs`
41
+
42
+ **Interfaces:**
43
+ - Consumes: nothing.
44
+ - Produces: `classifyReplyEvent(data) → { kind: "reply-complete", family, action: "drop" | "forward" } | { kind: "reply-head", family } | { kind: "plain" } | { kind: "other" }`, plus `REPLY_GUARD_WINDOW_MS`, `REPLY_GUARD_MAX_ABSORB_BYTES`. Tasks 2–3 import from `../core/terminal-reply-guard.mjs`.
45
+
46
+ - [ ] **Step 1: Write the failing tests**
47
+
48
+ Create `<WT>/test/terminal-reply-guard.test.mjs`:
49
+
50
+ ```js
51
+ import test from "node:test";
52
+ import assert from "node:assert/strict";
53
+ import {
54
+ classifyReplyEvent,
55
+ REPLY_GUARD_MAX_ABSORB_BYTES,
56
+ REPLY_GUARD_WINDOW_MS,
57
+ } from "../src/core/terminal-reply-guard.mjs";
58
+
59
+ // Real bytes from upstream earendil-works/pi#10657 (WezTerm 20240203 + ConPTY):
60
+ // a DA1 reply delivered as four reads, more than 50ms apart.
61
+ const DA1 = "\x1b[?61;6;7;22;23;24;28;32;42c";
62
+ const DA1_CHUNKS = ["\x1b[?61;6;", "7;22;23;", "24;28;32", ";42c"];
63
+ const OSC11_REPLY = "\x1b]11;rgb:2887/2a18/353c\x1b\\";
64
+
65
+ test("A1: complete drop-family replies classify as drop", () => {
66
+ for (const [family, seq] of [
67
+ ["device-attributes", DA1],
68
+ ["device-attributes", "\x1b[?c"],
69
+ ["device-attributes", "\x1b[=1;2c"],
70
+ ["device-attributes", "\x1b[>41;330;0c"],
71
+ ["kitty-flags", "\x1b[?1u"],
72
+ ["kitty-flags", "\x1b[?61;6u"],
73
+ ["cell-size", "\x1b[6;24;80t"],
74
+ ["dcs", "\x1bP>|WezTerm 20240203\x1b\\"],
75
+ ]) {
76
+ assert.deepEqual(classifyReplyEvent(seq), { kind: "reply-complete", family, action: "drop" }, JSON.stringify(seq));
77
+ }
78
+ });
79
+
80
+ test("A1: near-miss shapes keep the designed classification", () => {
81
+ // Complete but parameterless DA1 → drop (complete shape wins over "incomplete").
82
+ assert.deepEqual(classifyReplyEvent("\x1b[?c"), { kind: "reply-complete", family: "device-attributes", action: "drop" });
83
+ // Unknown third value on the scheme report → forward (the child's parser rejects it).
84
+ assert.deepEqual(classifyReplyEvent("\x1b[?997;3n"), { kind: "reply-complete", family: "color-scheme", action: "forward" });
85
+ // Complete but unknown CSI (DSR) → not a reply family: pass as "other".
86
+ assert.deepEqual(classifyReplyEvent("\x1b[1;2R"), { kind: "other" });
87
+ });
88
+
89
+ test("A5: color replies are forward-family, both terminators", () => {
90
+ for (const seq of [
91
+ OSC11_REPLY,
92
+ "\x1b]11;rgb:efef/f1f1/f5f5\x07",
93
+ "\x1b]10;rgb:ffff/ffff/ffff\x07",
94
+ "\x1b]12;rgb:0000/0000/0000\x1b\\",
95
+ "\x1b]4;0;rgb:0000/0000/0000\x07",
96
+ ]) {
97
+ assert.deepEqual(classifyReplyEvent(seq), { kind: "reply-complete", family: "osc-color", action: "forward" }, JSON.stringify(seq));
98
+ }
99
+ for (const seq of ["\x1b[?997;1n", "\x1b[?997;2n"]) {
100
+ assert.deepEqual(classifyReplyEvent(seq), { kind: "reply-complete", family: "color-scheme", action: "forward" }, JSON.stringify(seq));
101
+ }
102
+ });
103
+
104
+ test("A4: non-reply input is 'other' (escape-led) or 'plain' (text), never a reply", () => {
105
+ for (const seq of [
106
+ "\x1b[5~", "\x1b[6~", "\x1b[H", "\x1b[F", "\x1b[3~", "\x1b[<35;20;5M",
107
+ "\x1bOA", "\x1bOB", "\x1b", "\x1bz", "\x1b[27;5;92~", "\x1b[92;5u",
108
+ "\x1b[200~paste\x1b[201~", "\x1b[1;5D", "\x1b[1;2R",
109
+ ]) {
110
+ assert.deepEqual(classifyReplyEvent(seq), { kind: "other" }, JSON.stringify(seq));
111
+ }
112
+ for (const seq of ["hello", "7;22;23;", ";42c", "24;28;32"]) {
113
+ assert.deepEqual(classifyReplyEvent(seq), { kind: "plain" }, JSON.stringify(seq));
114
+ }
115
+ assert.deepEqual(classifyReplyEvent(""), { kind: "plain" }, "an empty event is inert");
116
+ });
117
+ ```
118
+
119
+ - [ ] **Step 2: Run the tests to verify they fail**
120
+
121
+ Run: `cd <WT> && node --test test/terminal-reply-guard.test.mjs`
122
+ Expected: FAIL — `Cannot find module '../src/core/terminal-reply-guard.mjs'`.
123
+
124
+ - [ ] **Step 3: Write the module skeleton + classifier**
125
+
126
+ Create `<WT>/src/core/terminal-reply-guard.mjs`:
127
+
128
+ ```js
129
+ /**
130
+ * Inbound terminal reply guard for the PTY attach client (issue #170).
131
+ *
132
+ * pi-tui hands replies to forwarded terminal queries to this overlay as input
133
+ * fragments: the prefix arms a 50ms sequence timeout in StdinBuffer and a
134
+ * 150ms negotiation flush in ProcessTerminal, so a CSI-shaped reply head is
135
+ * emitted as an input event and the remaining fragments arrive as plain text.
136
+ * The overlay's passthrough then feeds that garbage into the child's editor
137
+ * (upstream earendil-works/pi#10657 — a DA1 reply arrives as four reads and
138
+ * shows up in the child as a stable `c7c`).
139
+ *
140
+ * This module classifies every inbound event and the state machine below
141
+ * swallows reply fragments until the sequence completes. Color replies (OSC
142
+ * 4/10/11/12 and the 997 scheme report) are forwarded as ONE whole sequence,
143
+ * because the child's pi-tui parsers anchor `^...$`; everything else falls
144
+ * through untouched (issue #128 decision: the child negotiates at spawn, a late
145
+ * DA/kitty reply is meaningless to it, and forwarding kitty flags would retune
146
+ * the shared real-terminal keyboard stack).
147
+ *
148
+ * Pure only: no I/O, no timers, no `process.env`, no pi-tui import. Time is
149
+ * injected by the caller (see `armReplyGuardTimer` in src/ui/pty-attach.ts).
150
+ */
151
+
152
+ /** How long an open reply window stays open for trailing fragments (spec §3.3). */
153
+ export const REPLY_GUARD_WINDOW_MS = 250;
154
+
155
+ /** Bound on how much of a user's fast typing an open window may absorb. */
156
+ export const REPLY_GUARD_MAX_ABSORB_BYTES = 64;
157
+
158
+ // Complete reply shapes that are dropped. The child's negotiation happened at
159
+ // spawn, before any attach client existed, so a late reply can only pollute its
160
+ // input or retune keyboards that no longer asked.
161
+ const DROP_SHAPES = [
162
+ // DA1 / DA3 (`\x1b[?...c`, `\x1b[=...c`) and DA2 (`\x1b[>...c`).
163
+ { family: "device-attributes", pattern: /^\x1b\[[?>=][0-9;]*c$/ },
164
+ // kitty keyboard flags reply.
165
+ { family: "kitty-flags", pattern: /^\x1b\[\?[0-9;]*u$/ },
166
+ // XTWINOPS cell-size report (`\x1b[6;<rows>;<cols>t`).
167
+ { family: "cell-size", pattern: /^\x1b\[6;[0-9]+;[0-9]+t$/ },
168
+ // DCS replies such as XTVersion.
169
+ { family: "dcs", pattern: /^\x1bP[\s\S]*\x1b\\$/ },
170
+ ];
171
+
172
+ // Complete reply shapes that are forwarded whole: the child's pi-tui parsers
173
+ // consume them (automatic light/dark theme) and only as one sequence.
174
+ const FORWARD_SHAPES = [
175
+ // `\x1b[?997;<n>n` color-scheme report. Any single digit is forwarded — value
176
+ // validation belongs to the child's parser, not to this guard.
177
+ { family: "color-scheme", pattern: /^\x1b\[\?997;[0-9]+n$/ },
178
+ // OSC 4 palette / 10 foreground / 11 background / 12 cursor color replies,
179
+ // BEL- or ST-terminated.
180
+ { family: "osc-color", pattern: /^\x1b\](?:4;[0-9]+|10|11|12);[\s\S]*(?:\x07|\x1b\\)$/ },
181
+ ];
182
+
183
+ // Prefixes that can only start a reply and can never be a key/mouse/paste
184
+ // encoding — only these arm an absorption window. Deliberately absent: bare
185
+ // `\x1b`, `\x1b[`, `\x1b[5`/`\x1b[6` (PageUp/PageDown heads), `\x1b[<` (SGR
186
+ // mouse), `\x1bO` (SS3).
187
+ const REPLY_HEADS = [
188
+ { family: "device-attributes", prefix: "\x1b[?" },
189
+ { family: "device-attributes", prefix: "\x1b[>" },
190
+ { family: "cell-size", prefix: "\x1b[6;" },
191
+ { family: "dcs", prefix: "\x1bP" },
192
+ { family: "osc-color", prefix: "\x1b]1" },
193
+ { family: "osc-color", prefix: "\x1b]4" },
194
+ ];
195
+
196
+ /**
197
+ * Classify one inbound input event. Complete reply shapes win over head
198
+ * prefixes (a complete kitty reply also starts with `\x1b[?`); escape-led
199
+ * events that are neither are keys, mouse or paste data.
200
+ */
201
+ export function classifyReplyEvent(data) {
202
+ if (typeof data !== "string" || data.length === 0) return { kind: "plain" };
203
+ for (const shape of DROP_SHAPES) {
204
+ if (shape.pattern.test(data)) return { kind: "reply-complete", family: shape.family, action: "drop" };
205
+ }
206
+ for (const shape of FORWARD_SHAPES) {
207
+ if (shape.pattern.test(data)) return { kind: "reply-complete", family: shape.family, action: "forward" };
208
+ }
209
+ for (const head of REPLY_HEADS) {
210
+ if (data.startsWith(head.prefix)) return { kind: "reply-head", family: head.family };
211
+ }
212
+ // Everything else that starts with ESC is a key/mouse/paste encoding; plain
213
+ // text may be a reply tail fragment (those never start with ESC after the
214
+ // upstream split, see the field evidence in the issue).
215
+ return data.startsWith("\x1b") ? { kind: "other" } : { kind: "plain" };
216
+ }
217
+ ```
218
+
219
+ - [ ] **Step 4: Run the tests to verify they pass**
220
+
221
+ Run: `cd <WT> && node --test --test-name-pattern="A1" test/terminal-reply-guard.test.mjs && node --test --test-name-pattern="A4" test/terminal-reply-guard.test.mjs && node --test --test-name-pattern="A5" test/terminal-reply-guard.test.mjs`
222
+ Expected: PASS, 3 matching test blocks.
223
+
224
+ - [ ] **Step 5: Commit**
225
+
226
+ ```bash
227
+ git -C <WT> add src/core/terminal-reply-guard.mjs test/terminal-reply-guard.test.mjs
228
+ git -C <WT> commit -m "feat(attach): classify inbound terminal reply events (#170)"
229
+ ```
230
+
231
+ ---
232
+
233
+ ### Task 2: Pure state machine + kill switch parser (A2, A3, A5 fragment forms, A7 unit half)
234
+
235
+ **Files:**
236
+ - Modify: `<WT>/src/core/terminal-reply-guard.mjs` (append)
237
+ - Modify: `<WT>/test/terminal-reply-guard.test.mjs` (extend import + append)
238
+
239
+ **Interfaces:**
240
+ - Consumes: `classifyReplyEvent`, `REPLY_GUARD_WINDOW_MS`, `REPLY_GUARD_MAX_ABSORB_BYTES` (Task 1).
241
+ - Produces: `createReplyGuard({ windowMs, maxAbsorbBytes }) → { feed(data, now) → { action: "pass" } | { action: "swallow" } | { action: "forward", data }, expire(now) → decision | null, pendingSince() → number | null, reset() → void }` and `isReplyGuardEnabled(value) → boolean`. Task 3 imports `createReplyGuard`, `isReplyGuardEnabled`, `REPLY_GUARD_WINDOW_MS`.
242
+
243
+ - [ ] **Step 1: Write the failing tests**
244
+
245
+ In `<WT>/test/terminal-reply-guard.test.mjs`, extend the import with `createReplyGuard` and `isReplyGuardEnabled`, then append:
246
+
247
+ ```js
248
+ test("A2: a four-read DA1 reply is absorbed end to end", () => {
249
+ const guard = createReplyGuard();
250
+ let now = 1000;
251
+ for (const chunk of DA1_CHUNKS.slice(0, 3)) {
252
+ assert.deepEqual(guard.feed(chunk, now), { action: "swallow" }, JSON.stringify(chunk));
253
+ now += 60;
254
+ }
255
+ assert.deepEqual(guard.feed(DA1_CHUNKS[3], now), { action: "swallow" });
256
+ assert.equal(guard.pendingSince(), null, "completion closes the window");
257
+ assert.deepEqual(guard.feed("hello", now + 1), { action: "pass" }, "the guard is idle again");
258
+ });
259
+
260
+ test("A3: the window absorbs plain fragments, expires on time, and resets", () => {
261
+ const guard = createReplyGuard();
262
+ assert.deepEqual(guard.feed("\x1b[?61;6;", 1000), { action: "swallow" });
263
+ assert.deepEqual(guard.feed("7;22;", 1060), { action: "swallow" });
264
+ assert.equal(guard.pendingSince(), 1000, "the window is anchored at the head, not at the last fragment");
265
+ assert.equal(guard.expire(1249), null, "an early timer must not close the window");
266
+ assert.equal(guard.pendingSince(), 1000);
267
+ assert.deepEqual(guard.expire(1250), { action: "swallow" }, "expiry drops the incomplete prefix");
268
+ assert.equal(guard.pendingSince(), null);
269
+ assert.deepEqual(guard.feed("plain", 1300), { action: "pass" }, "state is idle after expiry");
270
+ });
271
+
272
+ test("A3: the absorb cap bounds the loss and drops the overflowing event", () => {
273
+ const guard = createReplyGuard({ maxAbsorbBytes: 16 });
274
+ assert.deepEqual(guard.feed("\x1b[6;", 1000), { action: "swallow" });
275
+ assert.deepEqual(guard.feed("0123456789", 1010), { action: "swallow" }, "14 bytes: still absorbed");
276
+ assert.deepEqual(guard.feed("XYZ", 1020), { action: "swallow" }, "overflow is dropped, not passed");
277
+ assert.equal(guard.pendingSince(), null, "overflow closes the window");
278
+ assert.deepEqual(guard.feed("after", 1030), { action: "pass" });
279
+ });
280
+
281
+ test("A3: reset() leaves no residue and windows do not leak across events", () => {
282
+ const guard = createReplyGuard();
283
+ guard.feed("\x1b[?1;", 1000);
284
+ guard.reset();
285
+ assert.equal(guard.pendingSince(), null);
286
+ assert.deepEqual(guard.feed("tail", 1010), { action: "pass" }, "a reset window must not absorb");
287
+ assert.deepEqual(guard.feed("\x1b[?61;6;", 1100), { action: "swallow" }, "a later head arms normally");
288
+ assert.deepEqual(guard.expire(1400), { action: "swallow" });
289
+ assert.deepEqual(guard.feed("\x1b]11;rgb:", 1500), { action: "swallow" }, "the next window is independent");
290
+ assert.deepEqual(guard.feed("2887/2a18/353c\x07", 1510), { action: "forward", data: "\x1b]11;rgb:2887/2a18/353c\x07" });
291
+ });
292
+
293
+ test("A3: an 'other' event ends the window, passes, and protects later text", () => {
294
+ for (const seq of ["\x1b[5~", "\x1b[<35;20;5M", "\x1b[200~hi\x1b[201~"]) {
295
+ const guard = createReplyGuard();
296
+ guard.feed("\x1b[?61;6;", 1000);
297
+ assert.deepEqual(guard.feed(seq, 1010), { action: "pass" }, JSON.stringify(seq));
298
+ assert.equal(guard.pendingSince(), null, JSON.stringify(seq));
299
+ assert.deepEqual(guard.feed("hello", 1020), { action: "pass" }, "the dropped buffer must not swallow later text");
300
+ }
301
+ });
302
+
303
+ test("A3: a new reply head re-arms and the stale buffer is gone", () => {
304
+ const guard = createReplyGuard();
305
+ assert.deepEqual(guard.feed("\x1b[?61;6;", 1000), { action: "swallow" });
306
+ assert.deepEqual(guard.feed("\x1b]11;", 1100), { action: "swallow" }, "the new head wins");
307
+ assert.equal(guard.pendingSince(), 1100, "the window restarts at the new head");
308
+ assert.equal(guard.expire(1250), null, "not yet 250ms after the new head");
309
+ assert.deepEqual(guard.feed("rgb:2887/2a18/353c\x07", 1300), { action: "forward", data: "\x1b]11;rgb:2887/2a18/353c\x07" }, "the forwarded bytes carry no stale DA prefix");
310
+ assert.equal(guard.pendingSince(), null);
311
+ });
312
+
313
+ test("A3: a complete reply inside an open window is handled alone, never joined", () => {
314
+ const guard = createReplyGuard();
315
+ guard.feed("\x1b]11;rgb:", 1000);
316
+ assert.deepEqual(guard.feed(OSC11_REPLY, 1050), { action: "forward", data: OSC11_REPLY }, "byte-for-byte, without the stale prefix");
317
+ assert.equal(guard.pendingSince(), null);
318
+ const dropped = createReplyGuard();
319
+ dropped.feed("\x1b[?61;6;", 1000);
320
+ assert.deepEqual(dropped.feed("\x1b[?1u", 1010), { action: "swallow" }, "a stale buffer plus a fresh kitty reply still drops");
321
+ assert.equal(dropped.pendingSince(), null);
322
+ });
323
+
324
+ test("A3: the spec's window and cap are pinned", () => {
325
+ assert.equal(REPLY_GUARD_WINDOW_MS, 250);
326
+ assert.equal(REPLY_GUARD_MAX_ABSORB_BYTES, 64);
327
+ });
328
+
329
+ test("A4: near-miss prefixes arm the window, unknown complete CSI does not", () => {
330
+ for (const seq of ["\x1b[?1;", "\x1b[6;", "\x1b[6;24;80", "\x1b]11;", "\x1bP"]) {
331
+ const guard = createReplyGuard();
332
+ assert.deepEqual(guard.feed(seq, 1000), { action: "swallow" }, JSON.stringify(seq));
333
+ assert.equal(guard.pendingSince(), 1000, JSON.stringify(seq));
334
+ }
335
+ const guard = createReplyGuard();
336
+ assert.deepEqual(guard.feed("\x1b[1;2R", 1000), { action: "pass" });
337
+ assert.equal(guard.pendingSince(), null, "a complete unknown CSI must not arm the guard");
338
+ });
339
+
340
+ test("A5: a fragmented color reply is joined and forwarded whole", () => {
341
+ const guard = createReplyGuard();
342
+ assert.deepEqual(guard.feed("\x1b]11;rgb:", 1000), { action: "swallow" });
343
+ assert.deepEqual(guard.feed("2887/2a18/", 1060), { action: "swallow" });
344
+ assert.deepEqual(guard.feed("353c\x1b\\", 1120), { action: "forward", data: "\x1b]11;rgb:2887/2a18/353c\x1b\\" });
345
+ assert.equal(guard.pendingSince(), null);
346
+ });
347
+
348
+ test("A7: the kill switch accepts 0/off/false and defaults to enabled", () => {
349
+ assert.equal(isReplyGuardEnabled(undefined), true);
350
+ assert.equal(isReplyGuardEnabled(""), true);
351
+ assert.equal(isReplyGuardEnabled("1"), true);
352
+ assert.equal(isReplyGuardEnabled(" ON "), true);
353
+ for (const off of ["0", "off", "false", " 0 ", "OFF", "False"]) {
354
+ assert.equal(isReplyGuardEnabled(off), false, JSON.stringify(off));
355
+ }
356
+ });
357
+ ```
358
+
359
+ - [ ] **Step 2: Run the tests to verify they fail**
360
+
361
+ Run: `cd <WT> && node --test test/terminal-reply-guard.test.mjs`
362
+ Expected: FAIL — `createReplyGuard is not a function` (and `isReplyGuardEnabled is not a function`).
363
+
364
+ - [ ] **Step 3: Append the state machine**
365
+
366
+ Append to `<WT>/src/core/terminal-reply-guard.mjs`:
367
+
368
+ ```js
369
+ const SWALLOW = Object.freeze({ action: "swallow" });
370
+ const PASS = Object.freeze({ action: "pass" });
371
+
372
+ /**
373
+ * Issue #170 kill switch: `0`, `off` or `false` (case/space-insensitive)
374
+ * disable the guard; unset or anything else keeps it enabled.
375
+ */
376
+ export function isReplyGuardEnabled(value) {
377
+ if (value === undefined) return true;
378
+ return !["0", "off", "false"].includes(value.trim().toLowerCase());
379
+ }
380
+
381
+ /**
382
+ * Pure reply-fragment state machine (spec §3.3). Two states only — idle and
383
+ * armed — and no timer of its own: the caller feeds `now` and arms a host
384
+ * timer from `pendingSince()`.
385
+ *
386
+ * idle + head → armed (buffer = event, since = now) → swallow
387
+ * idle + complete → idle → drop/forward
388
+ * idle + plain | other → idle → pass
389
+ * armed + plain → armed, buffer += event → swallow
390
+ * armed + plain over cap → idle (buffer and event are dropped) → swallow
391
+ * armed + joined complete → idle → drop/forward joined
392
+ * armed + head → armed (stale buffer dropped) → swallow
393
+ * armed + complete → idle (stale buffer dropped) → handle event alone
394
+ * armed + other → idle (stale buffer dropped) → pass
395
+ * armed + expire in time → idle → swallow
396
+ */
397
+ export function createReplyGuard({
398
+ windowMs = REPLY_GUARD_WINDOW_MS,
399
+ maxAbsorbBytes = REPLY_GUARD_MAX_ABSORB_BYTES,
400
+ } = {}) {
401
+ let armed = false;
402
+ let buffer = "";
403
+ let since = null;
404
+
405
+ const reset = () => {
406
+ armed = false;
407
+ buffer = "";
408
+ since = null;
409
+ };
410
+ const decisionFor = (event, data) => (event.action === "forward" ? { action: "forward", data } : SWALLOW);
411
+
412
+ return {
413
+ feed(data, now) {
414
+ const event = classifyReplyEvent(data);
415
+ if (!armed) {
416
+ if (event.kind === "reply-head") {
417
+ armed = true;
418
+ buffer = data;
419
+ since = now;
420
+ return SWALLOW;
421
+ }
422
+ if (event.kind === "reply-complete") return decisionFor(event, data);
423
+ return PASS;
424
+ }
425
+ if (event.kind === "other") {
426
+ // Keys, mouse and paste win over absorbing one more fragment.
427
+ reset();
428
+ return PASS;
429
+ }
430
+ if (event.kind === "reply-head") {
431
+ // A fresh head means the previous sequence will never complete.
432
+ buffer = data;
433
+ since = now;
434
+ return SWALLOW;
435
+ }
436
+ if (event.kind === "reply-complete") {
437
+ reset();
438
+ return decisionFor(event, data);
439
+ }
440
+ const joined = buffer + data;
441
+ if (joined.length > maxAbsorbBytes) {
442
+ reset();
443
+ return SWALLOW;
444
+ }
445
+ const joinedEvent = classifyReplyEvent(joined);
446
+ if (joinedEvent.kind === "reply-complete") {
447
+ reset();
448
+ return decisionFor(joinedEvent, joined);
449
+ }
450
+ buffer = joined;
451
+ return SWALLOW;
452
+ },
453
+ expire(now) {
454
+ if (!armed || now - since < windowMs) return null;
455
+ reset();
456
+ return SWALLOW;
457
+ },
458
+ pendingSince() {
459
+ return armed ? since : null;
460
+ },
461
+ reset,
462
+ };
463
+ }
464
+ ```
465
+
466
+ - [ ] **Step 4: Run the tests to verify they pass**
467
+
468
+ Run: `cd <WT> && node --test --test-name-pattern="A2" test/terminal-reply-guard.test.mjs && node --test --test-name-pattern="A3" test/terminal-reply-guard.test.mjs && node --test --test-name-pattern="A5" test/terminal-reply-guard.test.mjs && node --test --test-name-pattern="A7" test/terminal-reply-guard.test.mjs && node --test test/terminal-reply-guard.test.mjs`
469
+ Expected: PASS for every pattern and for the whole file (no stale failures).
470
+
471
+ - [ ] **Step 5: Commit**
472
+
473
+ ```bash
474
+ git -C <WT> add src/core/terminal-reply-guard.mjs test/terminal-reply-guard.test.mjs
475
+ git -C <WT> commit -m "feat(attach): absorb fragmented terminal replies in a pure state machine (#170)"
476
+ ```
477
+
478
+ ---
479
+
480
+ ### Task 3: Component wiring + README kill switch (A6, A7 component half, A9)
481
+
482
+ **Files:**
483
+ - Modify: `<WT>/src/ui/pty-attach.ts` (imports; fields after `private oscQueryCarry`; constructor before `this.connect()`; `handleInput` first lines at :297; two helpers after `clearGracefulSocketCloseTimer()`; `close()` after `this.detachColorSchemeBridge()`; the `msg.type === "exit"` branch at :1385)
484
+ - Modify: `<WT>/README.md` (one row at the end of the `## Configuration` table, after the `AGENT_BOARD_TERMINAL_SNAPSHOT` row at :327)
485
+ - Create: `<WT>/test-support/attach-reply-guard-smoke.ts`
486
+ - Create: `<WT>/test/pty-attach-reply-guard.test.mjs`
487
+
488
+ **Interfaces:**
489
+ - Consumes: `createReplyGuard`, `isReplyGuardEnabled`, `REPLY_GUARD_WINDOW_MS` (Task 2); the existing `PtyAttachComponent.send`, `nowFn`, and `close()` cleanup block.
490
+ - Produces: `AGENT_BOARD_REPLY_GUARD` env contract; no new public exports.
491
+
492
+ - [ ] **Step 1: Write the failing smoke + assertion shell**
493
+
494
+ Create `<WT>/test-support/attach-reply-guard-smoke.ts`:
495
+
496
+ ```ts
497
+ // Issue #170 component smoke: the REAL PtyAttachComponent with only the socket
498
+ // transport replaced, so the guard wiring — not a stub — decides what reaches
499
+ // the child. Shape follows test-support/attach-scheme-readiness-smoke.ts (#180).
500
+ import { PtyAttachComponent } from "../src/ui/pty-attach.ts";
501
+
502
+ // Real bytes from upstream #10657: a DA1 reply delivered as four reads.
503
+ const DA1_CHUNKS = ["\x1b[?61;6;", "7;22;23;", "24;28;32", ";42c"];
504
+ const OSC11_REPLY = "\x1b]11;rgb:2887/2a18/353c\x1b\\";
505
+
506
+ function fixture() {
507
+ const sent: string[] = [];
508
+ const tui = {
509
+ terminal: { rows: 24, cols: 80, columns: 80, write: () => {} },
510
+ requestRender: () => {},
511
+ };
512
+ const attach = new PtyAttachComponent(
513
+ tui as never,
514
+ { fg: (_color: string, text: string) => text, bold: (text: string) => text } as never,
515
+ {} as never,
516
+ () => {},
517
+ { socketPath: "/nonexistent/reply-guard.sock", title: "reply-guard" },
518
+ );
519
+ // SAFETY: the production component's real private members; only socket
520
+ // delivery and the connection flag are replaced.
521
+ const internals = attach as unknown as {
522
+ send: (msg: { type: string; data?: string }) => void;
523
+ connected: boolean;
524
+ replyGuard: { pendingSince: () => number | null } | null;
525
+ replyGuardTimer: unknown;
526
+ };
527
+ internals.connected = true;
528
+ internals.send = (msg) => {
529
+ if (msg.type === "input" && msg.data) sent.push(msg.data);
530
+ };
531
+ return { attach, sent, internals };
532
+ }
533
+
534
+ const out: Record<string, unknown> = {};
535
+ {
536
+ const f = fixture();
537
+ try {
538
+ for (const chunk of DA1_CHUNKS) f.attach.handleInput(chunk);
539
+ out.guardedFragments = [...f.sent];
540
+ f.attach.handleInput(OSC11_REPLY);
541
+ out.guardedColorReply = [...f.sent];
542
+ } finally {
543
+ f.attach.dispose();
544
+ }
545
+ }
546
+ {
547
+ process.env.AGENT_BOARD_REPLY_GUARD = "0";
548
+ const f = fixture();
549
+ try {
550
+ for (const chunk of DA1_CHUNKS) f.attach.handleInput(chunk);
551
+ out.killSwitchFragments = [...f.sent];
552
+ } finally {
553
+ f.attach.dispose();
554
+ delete process.env.AGENT_BOARD_REPLY_GUARD;
555
+ }
556
+ }
557
+ {
558
+ const f = fixture();
559
+ f.attach.handleInput("\x1b[?61;6;");
560
+ out.armedFrames = [...f.sent];
561
+ out.pendingBeforeDispose = f.internals.replyGuard?.pendingSince() ?? null;
562
+ f.attach.dispose();
563
+ out.pendingAfterDispose = f.internals.replyGuard?.pendingSince() ?? null;
564
+ out.timerAfterDispose = f.internals.replyGuardTimer ?? null;
565
+ }
566
+ console.log(JSON.stringify(out));
567
+ ```
568
+
569
+ Create `<WT>/test/pty-attach-reply-guard.test.mjs`:
570
+
571
+ ```js
572
+ import assert from "node:assert/strict";
573
+ import { execFileSync } from "node:child_process";
574
+ import { fileURLToPath } from "node:url";
575
+ import test from "node:test";
576
+
577
+ const script = fileURLToPath(new URL("../test-support/attach-reply-guard-smoke.ts", import.meta.url));
578
+ const DA1_CHUNKS = ["\x1b[?61;6;", "7;22;23;", "24;28;32", ";42c"];
579
+ let cached = null;
580
+ const run = () => (cached ??= JSON.parse(execFileSync(process.execPath, ["--experimental-transform-types", script], {
581
+ encoding: "utf8", timeout: 15000,
582
+ })));
583
+
584
+ test("A6: fragmented DA1 never reaches the child, color replies still do", () => {
585
+ const out = run();
586
+ assert.deepEqual(out.guardedFragments, [], "four DA1 reads through the real handleInput must emit no input frame");
587
+ assert.deepEqual(out.guardedColorReply, ["\x1b]11;rgb:2887/2a18/353c\x1b\\"], "a complete color reply is forwarded byte-for-byte");
588
+ assert.deepEqual(out.killSwitchFragments, DA1_CHUNKS, "AGENT_BOARD_REPLY_GUARD=0 restores the pre-#170 passthrough");
589
+ });
590
+
591
+ test("A7: an armed window sends nothing and dispose() cleans it up", () => {
592
+ const out = run();
593
+ assert.deepEqual(out.armedFrames, [], "an armed guard must not emit frames");
594
+ assert.notEqual(out.pendingBeforeDispose, null, "the smoke needs an open window for the cleanup assertion");
595
+ assert.equal(out.pendingAfterDispose, null, "dispose() resets the guard");
596
+ assert.equal(out.timerAfterDispose, null, "dispose() clears the window timer");
597
+ });
598
+ ```
599
+
600
+ - [ ] **Step 2: Run the smoke to verify it fails**
601
+
602
+ Run: `cd <WT> && node --test test/pty-attach-reply-guard.test.mjs`
603
+ Expected: FAIL — `guardedFragments` holds the four DA1 chunks (pre-#170 passthrough), and `killSwitchFragments` may already pass.
604
+
605
+ - [ ] **Step 3: Wire the guard into the component**
606
+
607
+ In `<WT>/src/ui/pty-attach.ts`, in this order:
608
+
609
+ 1. Import next to the other `../core/…` imports (after the `terminal-query-sequences.mjs` import at :14):
610
+
611
+ ```ts
612
+ import { REPLY_GUARD_WINDOW_MS, createReplyGuard, isReplyGuardEnabled } from "../core/terminal-reply-guard.mjs";
613
+ ```
614
+
615
+ 2. Fields directly after `private oscQueryCarry = "";`:
616
+
617
+ ```ts
618
+ // Issue #170: inbound reply fragments (device attributes, kitty flags, cell
619
+ // size, DCS) are swallowed, and color replies are forwarded as one whole
620
+ // sequence instead of leaking into the child as garbage. Null when
621
+ // AGENT_BOARD_REPLY_GUARD=0 — the pre-#170 passthrough is the escape hatch.
622
+ private readonly replyGuard: ReturnType<typeof createReplyGuard> | null;
623
+ private replyGuardTimer: ReturnType<typeof setTimeout> | null = null;
624
+ ```
625
+
626
+ 3. In the constructor body, immediately before `this.connect();`:
627
+
628
+ ```ts
629
+ this.replyGuard = isReplyGuardEnabled(process.env.AGENT_BOARD_REPLY_GUARD) ? createReplyGuard() : null;
630
+ ```
631
+
632
+ 4. `handleInput` (:297) — the guard goes first, before `parseMouseInputChunk`:
633
+
634
+ ```ts
635
+ handleInput(data: string): void {
636
+ const guard = this.replyGuard;
637
+ if (guard) {
638
+ const decision = guard.feed(data, this.nowFn());
639
+ if (guard.pendingSince() !== null) this.armReplyGuardTimer();
640
+ if (decision.action === "forward") {
641
+ // Color replies only work for the child's parsers as one sequence.
642
+ this.send({ type: "input", data: decision.data });
643
+ return;
644
+ }
645
+ if (decision.action === "swallow") return;
646
+ }
647
+ const mouseInput = parseMouseInputChunk(data);
648
+ // …existing body unchanged from here…
649
+ ```
650
+
651
+ 5. Two helpers immediately after `clearGracefulSocketCloseTimer()`:
652
+
653
+ ```ts
654
+ /** Issue #170: an open reply window arms exactly one timer. Fragments do not
655
+ * re-arm it (the window is anchored at the head); a timer that fires while
656
+ * the window is still open — a newer head moved `since` forward — re-arms
657
+ * instead of dropping the still-open sequence. */
658
+ private armReplyGuardTimer(): void {
659
+ if (this.replyGuardTimer) return;
660
+ this.replyGuardTimer = setTimeout(() => {
661
+ this.replyGuardTimer = null;
662
+ this.replyGuard?.expire(this.nowFn());
663
+ if (this.replyGuard?.pendingSince() !== null) this.armReplyGuardTimer();
664
+ }, REPLY_GUARD_WINDOW_MS);
665
+ this.replyGuardTimer.unref?.();
666
+ }
667
+
668
+ private clearReplyGuardTimer(): void {
669
+ if (!this.replyGuardTimer) return;
670
+ clearTimeout(this.replyGuardTimer);
671
+ this.replyGuardTimer = null;
672
+ }
673
+ ```
674
+
675
+ 6. `close()` — directly after `this.detachColorSchemeBridge();`:
676
+
677
+ ```ts
678
+ this.clearReplyGuardTimer();
679
+ this.replyGuard?.reset();
680
+ ```
681
+
682
+ 7. The host-exit branch (`msg.type === "exit"`, :1385) — directly after its `this.detachColorSchemeBridge();`:
683
+
684
+ ```ts
685
+ this.clearReplyGuardTimer();
686
+ this.replyGuard?.reset();
687
+ ```
688
+
689
+ - [ ] **Step 4: Run the smoke and the guard unit tests**
690
+
691
+ Run: `cd <WT> && node --test test/pty-attach-reply-guard.test.mjs && node --test test/terminal-reply-guard.test.mjs`
692
+ Expected: PASS (A6 and A7 now green).
693
+
694
+ - [ ] **Step 5: Document the kill switch (A9)**
695
+
696
+ Append one row to the `## Configuration` table in `<WT>/README.md`, directly after the `AGENT_BOARD_TERMINAL_SNAPSHOT` row at :327:
697
+
698
+ ```markdown
699
+ | `AGENT_BOARD_REPLY_GUARD` | enabled; `0`, `off`, or `false` disables | Discard terminal reply fragments (device attributes, kitty flags, cell size, DCS) that pi-tui delivers as input, and forward color replies whole. While a reply head is open, up to 64 bytes / 250ms of following input is absorbed. |
700
+ ```
701
+
702
+ Verify the landing point:
703
+
704
+ Run: `cd <WT> && rg -n "AGENT_BOARD_REPLY_GUARD" README.md && rg -n "^## Configuration" README.md`
705
+ Expected: the row is at a line AFTER the `## Configuration` heading line, inside the variable table (not in prose).
706
+
707
+ - [ ] **Step 6: Run the full suite and typecheck**
708
+
709
+ Run: `cd <WT> && npm run typecheck && npm test`
710
+ Expected: typecheck clean; all tests pass. Watch specifically for `test/pty-attach-scheme-readiness.test.mjs` and whatever test drives `test-support/attach-startup-color-smoke.ts` — the latter feeds a complete OSC 11 reply through `handleInput` and must still see exactly one forwarded frame.
711
+
712
+ - [ ] **Step 7: Commit**
713
+
714
+ ```bash
715
+ git -C <WT> add src/ui/pty-attach.ts README.md test-support/attach-reply-guard-smoke.ts test/pty-attach-reply-guard.test.mjs
716
+ git -C <WT> commit -m "feat(attach): guard the inbound passthrough against terminal reply fragments (#170)"
717
+ ```
718
+
719
+ ---
720
+
721
+ ### Task 4: Branch verification sweep (A8, A9 evidence, U1–U3 handoff)
722
+
723
+ **Files:** none modified unless a step fails.
724
+
725
+ - [ ] **Step 1: Run the repository gate (A8)**
726
+
727
+ Run: `cd <WT> && npm run verify`
728
+ Expected: `typecheck` clean, perf gate passes, `npm test` green, coverage thresholds met, `npm pack --dry-run` lists no unexpected files. The new module must appear in the coverage report with no uncovered decision branch that changes behaviour.
729
+
730
+ - [ ] **Step 2: Record the A9 evidence**
731
+
732
+ Run: `cd <WT> && rg -n "AGENT_BOARD_REPLY_GUARD" README.md src/ui/pty-attach.ts src/core/terminal-reply-guard.mjs`
733
+ Expected: three hits — README table row, the `handleInput`/constructor wiring, and the `isReplyGuardEnabled` docblock.
734
+
735
+ - [ ] **Step 3: Hand off the manual acceptance items (U1–U3) — report as `pending`**
736
+
737
+ Deployment precondition (spec §5): install the modified checkout into the host Pi before observing anything —
738
+ `pi install "<WT>"` (per `VERIFY.md:55`) then restart Pi, or `/reload` (per `VERIFY.md:101`); confirm the extension loads without errors and `/agent-board` opens. Then:
739
+
740
+ | ID | Steps | Pass criterion |
741
+ |----|-------|----------------|
742
+ | U1 | In a slow-replying terminal (WezTerm+WSL2/ConPTY, mintty), attach to a Pi child and a zsh child, ≥5 times each | The child's composer / shell prompt shows no literal `c7c`-style fragment |
743
+ | U2 | Attach a child configured with a light-warm/dark theme from a light terminal | Theme follows the terminal correctly; `attach_settle_scheme` diagnostics still have an outcome (a `timeout` outcome is acceptable) |
744
+ | U3 | Attach, then: type normally, PageUp/PageDown, middle-click paste, drag-select copy, and type 10 characters inside 250ms | Five behaviours unchanged; the fast-typing loss never exceeds 64 bytes (record the actual shape otherwise) |
745
+
746
+ **Do not** mark U1–U3 as passed from unit tests alone; they stay `pending` until a human observes them in a real terminal. Report this as the only open item when the implementation tasks are green.
747
+
748
+ ---
749
+
750
+ ## Traceability (spec acceptance ID → plan location)
751
+
752
+ | Spec ID | Covered by | Verification command |
753
+ |---|---|---|
754
+ | A1 | Task 1 steps 1–4 | `node --test --test-name-pattern="A1" test/terminal-reply-guard.test.mjs` |
755
+ | A2 | Task 2 steps 1–4 | `node --test --test-name-pattern="A2" test/terminal-reply-guard.test.mjs` |
756
+ | A3 (①–⑧) | Task 2 steps 1–4 | `node --test --test-name-pattern="A3" test/terminal-reply-guard.test.mjs` |
757
+ | A4 (idle baseline + near-miss prefixes) | Task 1 (pass side), Task 2 (arm side) | `node --test --test-name-pattern="A4" test/terminal-reply-guard.test.mjs` |
758
+ | A5 (complete + fragmented) | Task 1 (complete), Task 2 (fragments) | `node --test --test-name-pattern="A5" test/terminal-reply-guard.test.mjs` |
759
+ | A6 | Task 3 steps 1, 2, 4 | `node --test test/pty-attach-reply-guard.test.mjs` |
760
+ | A7 (kill switch + cleanup) | Task 2 step 1 (unit), Task 3 steps 1, 2, 4 (smoke) | `node --test test/terminal-reply-guard.test.mjs test/pty-attach-reply-guard.test.mjs` |
761
+ | A8 | Task 3 step 6, Task 4 step 1 | `npm run typecheck && npm test` / `npm run verify` |
762
+ | A9 | Task 3 step 5, Task 4 step 2 | `rg -n "AGENT_BOARD_REPLY_GUARD" README.md` + landing-point check |
763
+ | U1–U3 | Task 4 step 3 (user-observed, reported `pending`) | manual, after `pi install "<WT>"` + restart |
764
+
765
+ No task above exists without an acceptance ID, and every automated ID maps to at least one task step.
766
+
767
+ ## Testability split (spec §6, held as a hard constraint)
768
+
769
+ - `classifyReplyEvent` — pure, no state, no time: only classification is tested here (A1, A4 pass side, A5 complete forms).
770
+ - `createReplyGuard` — pure state machine, `now` injected: fragments, window, cap, reset, kill-switch parsing (A2, A3, A5 fragment forms, A7 unit half).
771
+ - `PtyAttachComponent.handleInput` — thin wiring only: three-way decision landing in `send`/`return`, the kill switch, and teardown (A6, A7 smoke half). It must NOT re-implement classification or re-absorb the byte matrix that the pure module already covers.